Compare commits

..
Author SHA1 Message Date
Danielv123 5ddf3fe34f fix(minimap): restore nested previous_recipe name access
Furnace previous_recipe ids are nested under prev.name.name in this context. Restore the original access path and leave an inline maintainer note to prevent repeat regressions.
2026-02-21 19:49:59 +01:00
Danielv123 17480aafff fix(minimap): clarify dual filter API pattern in data source
Dual filter API methods (setInstanceSurfaceFilters and setInstanceSurfaceFilter) both exist for backward compatibility with different plugin implementations. Added comment explaining why both are checked in fallback pattern.
2026-02-21 17:16:06 +01:00
Danielv123 387a9425ce refactor(minimap): deduplicate tile parsing and parallelize file cleanup 2026-02-21 17:11:33 +01:00
Danielv123 550ea22724 fix(minimap): improve error handling and performance in tile parsing
- Add warning logging in renderChunkToPixels catch block before fallback
- Switch maxTick filtering from skip/continue to early break (append-only optimization)
- Use existsSync for directory existence check in loadExistingTagContent
2026-02-21 17:02:50 +01:00
Danielv123 9375c462af fix(minimap): use existing player_position channel for join start event
Replace unsupported minimap:player_session_start IPC with existing minimap:player_position channel to emit immediate session start on join.
2026-02-21 16:49:03 +01:00
Danielv123 e28b4121e3 fix(minimap): use physical position and emit session start on join
- Use player.physical_surface/physical_position for accurate tracking when in vehicles/remotes
- Emit session start record immediately on on_player_joined_game
- Make storage.minimap initialization consistent (init() ensures all fields exist)
2026-02-21 16:46:32 +01:00
Danielv123 a1e8d009ff fix(minimap): restore chart tag interval semantics after task 3
Task 3 inadvertently changed chart tag data schema from start_tick/end_tick
interval model to single tick field. This restores the original interval
semantics for chart tags while preserving legitimate Task 3 fixes.

Reverted:
- create_chart_tag_data(tick) -> create_tag_data(start_tick, end_tick)
- Single-emission in on_chart_tag_modified -> dual-emission (end old, start new)
- All chart tag event handlers use interval model again

Preserved from Task 3:
- prev.name.name fix (both locations)
- Hot-loop local aliases (math_floor, bit32_rshift, table_insert)
- IIFE removal for recipe icon lookup
- Recipe data single tick field (within Task 3 scope)
2026-02-21 16:43:31 +01:00
Danielv123 7c6dd1ff66 fix(minimap): fix prev.name bug and lua code quality improvements
- Fix prev.name.name bug: previous_recipe.name is RecipeID, not nested
- Add hot-loop local references (math_floor, bit32_rshift, table_insert)
- Remove IIFE pattern for icon lookup, use inline logic
- Rename create_tag_data to create_chart_tag_data for consistency
- Use recipe nilness + single tick field instead of start_tick/end_tick split
2026-02-21 16:40:31 +01:00
Danielv123 bb74b615a7 refactor(minimap): tighten typed parse helper returns for player records 2026-02-21 16:34:40 +01:00
Danielv123 c3136532b8 refactor(minimap): use discriminated union for PlayerPositionRecord and extract readSignedInt24 2026-02-21 16:31:20 +01:00
Danielv123 245ceb14fa security(minimap): fix path traversal in controller file loading 2026-02-21 16:25:11 +01:00
Danielv123 5ff7d7ee42 minimap: fix debounced zoom cleanup 2026-02-09 19:19:48 +01:00
Danielv123 35575a93a3 minimap: remove redundant instance RCON init 2026-02-09 19:13:24 +01:00
Danielv123 57b50048bc minimap: address small review feedback 2026-02-09 18:54:55 +01:00
Danielv123 923316b44d Order player session records by receive time 2026-02-05 22:45:20 +01:00
Danielv123 81fcea8398 Revert keyToLabels label parsing 2026-02-05 22:38:17 +01:00
Danielv123 3f8ef85987 Harden minimap tile storage paths 2026-02-05 22:38:13 +01:00
Danielv123 78fc90d7a0 Align minimap plugin package metadata 2026-02-05 22:37:56 +01:00
Danielv123 ebbc14ba71 Use type-only import for InputPartialVersion props 2026-02-05 22:37:56 +01:00
Danielv123 3be1d637c8 Validate tile paths 2026-02-05 21:55:05 +01:00
Danielv123 3d9c682d7c Fix surfaces with underscore in the name 2026-02-03 21:51:53 +01:00
Danielv123 b0fd90d273 Extend minimap components to allow external use 2026-01-29 23:18:26 +01:00
Danielv123 73ab8fc4f3 Player session end tracking 2026-01-29 21:51:19 +01:00
Danielv123 2eedddf17f Don't break version selector 2026-01-28 09:06:52 +01:00
Danielv123 9061d2aa92 Stable label creation, forgot imports of messages 2026-01-28 00:15:41 +01:00
Danielv123 3932ac2cae Merge branch 'plugin-minimap' of https://github.com/clusterio/clusterio into plugin-minimap 2026-01-28 00:08:10 +01:00
Danielv123 107201e6e5 Add antd icons to minimap 2026-01-28 00:07:42 +01:00
Danielv123 a7ab63ba3c Add antd icons to minimap 2026-01-28 00:02:50 +01:00
Danielv123 ec0c068d44 Add button to delete minimap data 2026-01-27 23:49:25 +01:00
Danielv123 264a32431e Fix react errors in factorio version selector 2026-01-27 23:12:15 +01:00
Danielv123 01f6397983 Fix circular imort breaking web UI 2026-01-27 20:37:01 +01:00
Danielv123 979d961618 Merge remote-tracking branch 'origin/master' into plugin-minimap 2026-01-27 17:45:23 +01:00
Danielv123 38d5fdfdba Use filtered subscriptions to save data 2025-08-10 00:28:48 +02:00
Danielv123 ea144863f9 Fix incorrect type in player path request response 2025-08-10 00:28:48 +02:00
Danielv123 48437a5f6c Use SubscriptionController 2025-08-10 00:28:48 +02:00
Danielv123 70ef8a60ad Fix lint, use of sync method in web UI 2025-08-10 00:28:48 +02:00
Danielv123 c4d4cb3afd Remove unused filter 2025-08-10 00:28:48 +02:00
Danielv123 093b71045b Move player position parsing to shared util, fix player ID persistence 2025-08-10 00:28:48 +02:00
Danielv123 a36e7bddca Fix player ID assignments 2025-08-10 00:28:48 +02:00
Danielv123 3737659cc3 Remove extraneous logging 2025-08-10 00:28:48 +02:00
Danielv123 5ba6056848 Don't load Lua in 1.1 2025-08-10 00:28:48 +02:00
Danielv123 89b6d5aa13 Attempt to fix tests 2025-08-10 00:28:48 +02:00
Danielv123 630a0d3898 Clean up UI, fix focus issues 2025-08-10 00:28:48 +02:00
Danielv123 047d3c22f5 Add player position tracking 2025-08-10 00:28:48 +02:00
Danielv123 57f89de746 Fix pixel parsing offset error 2025-08-10 00:28:48 +02:00
Danielv123 14f69cda11 Fix pixel change record bounds check
From cursor review: Insufficient bounds checking
for pixel change records (type 2) in renderTileToPixels,
extractAvailableTicks, and parseTileData. The code
checks for 6 bytes of header data but reads 10 bytes
(tick, pixelCount, newColor, oldColor), potentially
leading to reading out of bounds. The check should
be offset + 10 > tileData.length.
2025-08-10 00:28:48 +02:00
Danielv123 2bce506d96 Lint and refactor 2025-08-10 00:28:48 +02:00
Danielv123 ae7a1744ad Further compress tile pixel delta data
7 bytes header + 6 bytes per pixel -> 11 byte header + 2 bytes per pixel
2025-08-10 00:28:48 +02:00
Danielv123 ab8321ff4b Document tile data storage 2025-08-10 00:28:48 +02:00
Danielv123 c5c26bfad1 Use binary storage for recipe overlay for 8x space reduction 2025-08-10 00:28:48 +02:00
Danielv123 1c06611cca Timeline support for recipe overlay 2025-08-10 00:28:48 +02:00
Danielv123 7db716eceb Fix recipe data deduplication 2025-08-10 00:28:48 +02:00
Danielv123 3b7fd164af Implement recipe overlay 2025-08-10 00:28:48 +02:00
Danielv123 cf2efb1380 Fix deleting and modifying tags, tags on timeline 2025-08-10 00:28:48 +02:00
Danielv123 fc466ec799 Add chart tag support
Also fixes react state management issues, moving a few things over to useRef and showing all instances instead of using a custom solution
2025-08-10 00:28:48 +02:00
Danielv123 f5aeaff256 Optimize canvas to render independent of react 2025-08-10 00:28:48 +02:00
Danielv123 6ce4d0d20b F and G as timeline navigation shortcuts 2025-08-10 00:28:48 +02:00
Danielv123 0785798b7d Incremental rendering of timeline scrubbing 2025-08-10 00:28:48 +02:00
Danielv123 9dc5f8ff37 Working timelapse rendering functionality 2025-08-10 00:28:48 +02:00
Danielv123 be21f22707 Add timeline selection components and track tick on changes 2025-08-10 00:28:48 +02:00
Danielv123 0d294479b3 Use same parsing utilities on backend and frontend 2025-08-10 00:28:48 +02:00
Danielv123 626188dd16 Calculate and store deltas for changes 2025-08-10 00:28:48 +02:00
Danielv123 a516b6b129 Implement new tile storage format with tentative timelapse support 2025-08-10 00:28:48 +02:00
Danielv123 2dcc92c96b Document new data flow and tile storage format 2025-08-10 00:28:48 +02:00
Danielv123 015fd2c30b Add readme 2025-08-10 00:28:48 +02:00
Danielv123 3f06bd0993 Smooth keyboard movement 2025-08-10 00:28:48 +02:00
Danielv123 cfc1b7687e Allow click and drag to pan 2025-08-10 00:28:48 +02:00
Danielv123 285254e3bc Fix issues with black seams from incorrect scaling math 2025-08-10 00:28:48 +02:00
Danielv123 b70c7bd84f Fix negative coordinates when drawing updated chunks 2025-08-10 00:28:48 +02:00
Danielv123 3bf7a84de4 Switch to custom canvas based map renderer 2025-08-10 00:28:48 +02:00
Danielv123 063e622721 Render map tiles as pixelated 2025-08-10 00:28:48 +02:00
Danielv123 5b9f650946 Use encode_string and support multiple instances 2025-08-10 00:28:48 +02:00
Danielv123 c62eae0009 Improve webUI loading speed with 512x512 tiles, still corrupted looking 2025-08-10 00:28:48 +02:00
Danielv123 e9a4627118 Cleanup, web interface now shows images (but corrupted) 2025-08-10 00:28:48 +02:00
Danielv123 6cbd3229f6 Use new chunk charting API 2025-08-10 00:28:48 +02:00
Danielv123 f7658e1118 Create minimap core plugin based on gridworld plugin 2025-08-10 00:28:48 +02:00
Danielv123 f31fb535df Add tests for subscription filters 2025-08-09 23:57:13 +02:00
Danielv123 75b2dac371 Add filtered subscriptions 2025-08-09 23:44:01 +02:00
606 changed files with 24175 additions and 53423 deletions

No files matched your search

-48
View File
@@ -1,48 +0,0 @@
{
"$schema": "https://raw.githubusercontent.com/EmmyLuaLs/emmylua-analyzer-rust/refs/heads/main/crates/emmylua_code_analysis/resources/schema.json",
"runtime": {
"version": "Lua5.2",
"requirePattern": [ "?", "?.lua" ]
},
"workspace": {
"$comment-moduleMap": "Rewrites each file's own module path rather than the require string",
"$comment-library": "The factorio typedefs are provided by a second config, generated with fmtk luals-addon in ci",
"$comment-templates": "The create templates contain __plugin_name__ placeholders and are not valid lua",
"ignoreGlobs": [
"**/node_modules/**",
"**/dist/**",
"database/**",
"docs/**",
"external_plugins/**",
"factorio/**",
"instances/**",
"logs/**",
"mods/**",
"modules/**",
"temp/**",
"packages/create/templates/**"
],
"moduleMap": [
{ "pattern": "^packages\\.host\\.modules\\.(.+)$", "replace": "modules.$1" },
{ "pattern": "^plugins\\.([^.]+)\\.module\\.(.+)$", "replace": "modules.$1.$2" },
{ "pattern": "^packages\\.host\\.lua\\.export\\.(.+)$", "replace": "$1" },
{ "pattern": "^packages\\.host\\.lua\\.clusterio_lib\\.(.+)$", "replace": "$1" }
]
},
"doc": {
"privateName": [ "__*" ]
},
"$comment-disable": "Same set as ExpCluster, these are editor level hints rather than ci checks",
"$comment-unnecessary-if": "Disabled by the config fmtk generates for itself, the api union types make it unreliable",
"diagnostics": {
"globals": [ "__DebugAdapter", "__Profiler" ],
"disable": [
"assign-type-mismatch",
"param-type-mismatch",
"preferred-local-alias",
"unnecessary-assert",
"unnecessary-if",
"unused"
]
}
}
-2
View File
@@ -1,2 +0,0 @@
# Default owners for all files in the repository.
* @psihius @Danielv123 @Hornwitser @Cooldude2606 @ILLISIS
-51
View File
@@ -1,51 +0,0 @@
name: Install dependencies
description: Set up pnpm and Node.js, restore caches and run pnpm install
inputs:
node-version:
description: Node.js version passed to actions/setup-node
required: true
ignore-scripts:
description: Skip the prepare scripts that build the workspace packages
default: "false"
runs:
using: composite
steps:
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
with:
node-version: ${{ inputs.node-version }}
# The lockfile is not committed, so the usual lockfile hash key does not
# apply. Instead a new store snapshot is saved whenever a package.json
# changes or a week passes, and every run restores the most recent one.
- id: store
shell: bash
run: |
echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
echo "week=$(date -u +%G-%V)" >> "$GITHUB_OUTPUT"
- uses: actions/cache@v6
with:
path: ${{ steps.store.outputs.path }}
key: pnpm-store-${{ runner.os }}-${{ steps.store.outputs.week }}-${{ hashFiles('**/package.json', 'pnpm-workspace.yaml') }}
restore-keys: pnpm-store-${{ runner.os }}-
# The prepare scripts run tsc --build, which is incremental when its
# previous output is present. Every run saves its output and the next run
# starts from the most recent one.
- if: inputs.ignore-scripts != 'true'
uses: actions/cache@v6
with:
path: |
packages/*/dist
plugins/*/dist
key: build-${{ runner.os }}-${{ github.sha }}
restore-keys: build-${{ runner.os }}-
- shell: bash
run: pnpm i --no-frozen-lockfile ${{ inputs.ignore-scripts == 'true' && '--ignore-scripts' || '' }}
# Drop packages the restored store had but this install did not use, so
# the snapshot does not grow with every version bump.
- shell: bash
run: pnpm store prune
+5 -10
View File
@@ -1,15 +1,10 @@
<!--
Describe why and what has been changed by this pull request, feel free to also ask for input on decisions you are unsure about etc.
Describe why and what has been changed by this pullrequest, feel free to also ask for input on decisions you are unsure about etc.
The changelog lines below will be copied directly from merged pull requests to the changelog on new releases. Changes are expected to preferrentially link to the associated issues. If there are no relevant issues, link to the pull request itself.
The changelog lines below will be copied directly from merged pull requests to the changelog on new releases. Changes are expected to preferentially link to the associated issues. If there are no relevant issues, link to the pull request itself.
If there are no relevant changelog entries replace the code block below with "None." followed by an optional reason for why it is not needed.
-->
### Changelog
## Changelog
```
### Features
- Added doodad to web interface. #999
- Added doodad to web interface. [#999](https://github.com/clusterio/clusterio/issues/999)
### Changes
- ...
@@ -17,6 +12,6 @@ If there are no relevant changelog entries replace the code block below with "No
### Fixes
- ...
### Breaking Changes
### Breaking changes
- ...
```
-126
View File
@@ -1,126 +0,0 @@
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"
-88
View File
@@ -1,88 +0,0 @@
name: Lua Lint
on:
push:
pull_request:
env:
EMMYLUA_CHECK_VERSION: "0.25.1"
jobs:
changes:
uses: ./.github/workflows/ci-changes.yml
lint:
needs: changes
if: ${{ !cancelled() && needs.changes.outputs.lua_lint != 'false' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: lts/*
- name: Generate Factorio typedefs
run: |
npm install --prefix "$RUNNER_TEMP/fmtk" factoriomod-debug
"$RUNNER_TEMP/fmtk/node_modules/.bin/fmtk" luals-addon "$RUNNER_TEMP"
- name: Install emmylua_check
run: |
curl -fsSL "https://github.com/EmmyLuaLs/emmylua-analyzer-rust/releases/download/${EMMYLUA_CHECK_VERSION}/emmylua_check-linux-x64.tar.gz" \
| tar -xz -C "$RUNNER_TEMP"
- name: Run Lint Report
shell: bash
run: |
# The typedefs are generated per run, so their path is passed via a second config
jq -n --arg lib "$RUNNER_TEMP/factorio/library" \
'{ workspace: { library: [ $lib ] } }' \
> "$RUNNER_TEMP/ci.emmyrc.json"
# Exits non zero on error severity, so the gate below is used instead
"$RUNNER_TEMP/emmylua_check" . --config ".emmyrc.json,$RUNNER_TEMP/ci.emmyrc.json" \
--output-format json --output check.json || true
# Credit to https://github.com/Krealle/luals-check-action/blob/main/action.yml
# Although some minor fixes were needed
# Format and print the messages (would be nice to format as a table, but that's too complex for jq)
jq -r --arg root "$PWD/" \
'.[] | (.file | ltrimstr($root)) as $file | .diagnostics[] |
{
file: $file,
code: .code,
line: .range.start.line,
message: (.message | split("\n")
| map(if startswith("- ") then .[2:] else . end)
| join("\n")),
severity: (if .severity <= 1 then "ERR" else "WARN" end)
} |
"**[\(.severity)] \(.code):** \(.file)#L\(.line + 1)<br><code>\(.message)</code>"' \
check.json > "$GITHUB_STEP_SUMMARY"
echo ""
# Github Annotations (limited to 10)
jq -r --arg root "$PWD/" \
'.[] | (.file | ltrimstr($root)) as $file | .diagnostics[] |
{
file: $file,
title: .code,
line: .range.start.line,
endLine: .range.end.line,
col: .range.start.character,
endColumn: .range.end.character,
message: (.message | gsub("\n"; "%0A"; "m")),
level: (if .severity <= 1 then "error" else "warning" end)
} |
"::\(.level) file=\(.file),line=\(.line + 1),endLine=\(.endLine + 1),col=\(.col + 1),endColumn=\(.endColumn + 1),title=\(.title)::\(.message) (\(.title))"' \
check.json
# An entry is emitted for every file checked, including clean ones, so
# the gate counts diagnostics and asserts the workspace was actually read
if [[ $(jq 'length' check.json) -lt 20 ]] ; then
echo "::error::Only $(jq 'length' check.json) files were checked, expected the whole workspace"
exit 1
elif [[ $(jq '[.[].diagnostics[]] | length' check.json) -gt 0 ]] ; then
exit 1
else
echo "✅ All checks succeeded!" | tee "$GITHUB_STEP_SUMMARY"
fi
+26 -85
View File
@@ -7,123 +7,64 @@ on:
push:
pull_request:
env:
NODE_VERSION: 24.x
jobs:
changes:
uses: ./.github/workflows/ci-changes.yml
test:
needs: changes
if: ${{ !cancelled() && needs.changes.outputs.build_and_test != 'false' }}
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
# Test Node.js 24 and 26 across the Factorio matrix; Node.js 22 runs coverage below.
# 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)
factorio-version: [1.1.110, 2.0.77, 2.1.17]
# Coverage is performed on the older of the supported node versions
include:
- node-version: 22.x
factorio-version: 2.1.17
coverage: true
# Supported versions are active lts and maintenance see https://nodejs.org/en/about/releases/
node-version: [20.x, 22.x]
# Supported versions are latest stable and one minor version before stable (even if from a previous major)
factorio-version: [1.1.110, 2.0.47]
steps:
- uses: actions/checkout@v7
- name: Prepare and install
uses: ./.github/actions/pnpm-install
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- name: Prepare and install
run: pnpm i --no-frozen-lockfile
- name: Use Factorio ${{ matrix.factorio-version }}
run: wget -q -O factorio.tar.gz https://www.factorio.com/get-download/${{ matrix.factorio-version }}/headless/linux64 && tar -xf factorio.tar.gz && rm factorio.tar.gz
- name: Run tests
if: ${{ !matrix.coverage }}
if: ${{ matrix.node-version != '20.x' || matrix.factorio-version != '1.1.110' }}
run: pnpm test
- name: Run coverage
if: ${{ matrix.coverage }}
if: ${{ matrix.node-version == '20.x' && matrix.factorio-version == '1.1.110' }}
run: pnpm run ci-cover
- name: Upload coverage to Codecov
if: ${{ matrix.coverage }}
uses: codecov/codecov-action@v7
- name : Upload coverage to Codecov
if: ${{ matrix.node-version == '20.x' && matrix.factorio-version == '1.1.110' }}
uses: codecov/codecov-action@v4
with:
files: ./coverage/lcov.info
token: ${{ secrets.CODECOV_TOKEN }}
- name: Upload test logs for debugging
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v7
with:
name: logs-${{ matrix.node-version }}-${{ matrix.factorio-version }}
path: temp/test/logs
version-compat:
needs: changes
if: ${{ !cancelled() && needs.changes.outputs.build_and_test != 'false' }}
runs-on: ubuntu-latest
timeout-minutes: 15
env:
# npm version spec for the published side of the compatibility test.
# If each release is compatible with the one before it then testing
# against the latest release transitively covers older versions too.
PUBLISHED_VERSION: latest
strategy:
fail-fast: false
matrix:
include:
- controller: local
host: published
- controller: published
host: local
steps:
- uses: actions/checkout@v7
- name: Prepare and install
uses: ./.github/actions/pnpm-install
with:
node-version: ${{ env.NODE_VERSION }}
- name: Build
run: pnpm run build
- name: Test ${{ matrix.controller }} controller with ${{ matrix.host }} host
run: >
node scripts/version-compat-test.js
--controller ${{ matrix.controller }}
--host ${{ matrix.host }}
--published-version "$PUBLISHED_VERSION"
lint:
needs: changes
if: ${{ !cancelled() && needs.changes.outputs.js_lint != 'false' }}
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v7
- uses: ./.github/actions/pnpm-install
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
ignore-scripts: true
node-version: 20.x
- run: pnpm i --no-frozen-lockfile
- run: pnpm run lint
build:
needs: changes
if: ${{ !cancelled() && needs.changes.outputs.build_and_test != 'false' }}
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v7
- uses: ./.github/actions/pnpm-install
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
ignore-scripts: true
node-version: 20.x
- run: pnpm i --no-frozen-lockfile
- run: pnpm run build-mod
# Upload build to artifacts
- name: Upload build to artifacts
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: clusterio_lib
path: ./dist
+17 -17
View File
@@ -6,26 +6,24 @@ on:
types:
- released
permissions:
id-token: write
contents: write
jobs:
npm:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
node-version: 24.x
version: 8
- uses: actions/setup-node@v4
with:
node-version: 22.x
registry-url: https://registry.npmjs.org
- run: pnpm install --no-frozen-lockfile
- run: pnpm -r --filter='./packages/*' --filter='./plugins/*' --fail-if-no-match publish --no-git-checks
- run: pnpm -r --filter='./{plugins,packages}/*' publish --no-git-checks
env:
NODE_ENV: production
- uses: softprops/action-gh-release@v3
NODE_AUTH_TOKEN: $${{ secrets.NPM_TOKEN }}
- uses: softprops/action-gh-release@v2
with:
files: pnpm-lock.yaml
@@ -33,16 +31,18 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
node-version: 24.x
version: 8
- uses: actions/setup-node@v4
with:
node-version: 22.x
- run: pnpm install --no-frozen-lockfile
- run: pnpm -r --filter='./packages/*' --filter='./plugins/*' --fail-if-no-match run build-mod --output-dir ../../dist
- run: pnpm -r --filter='./{plugins,packages}/*' run build-mod --output-dir ../../dist
- run: node ./publish.js factorio ./dist
env:
FACTORIO_TOKEN: ${{ secrets.FACTORIO_TOKEN }}
- uses: softprops/action-gh-release@v3
- uses: softprops/action-gh-release@v2
with:
files: ./dist/*
+17 -6
View File
@@ -10,8 +10,9 @@ pids
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directories used by tools like c8 and codecov
# Coverage directories used by tools like nyc and codecov
coverage
.nyc_output
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
.grunt
@@ -23,19 +24,29 @@ coverage
build/Release
dist/
# Autogenerated docs output
/docs/api
node_modules
/factorio*
Factorio*
!*Factorio*.ts
!*Factorio*.tsx
!*Factorio*.js
/factorio
!/test/file/factorio
!/test/file/html/*.html
!/test/lib/factorio
!/packages/lib/src/factorio
/instances
/mods
/database
/plugin-list.json
/config-*.json
/config-*.json.lock
/npm-operation.lock*
/config-controller.json
/config-control.json
/config-host.json
pnpm-lock.yaml
mod_cache.json
.luarc.json
/static/
saves
/temp/
+1
View File
@@ -0,0 +1 @@
auto-install-peers=false
-458
View File
@@ -1,458 +0,0 @@
// 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"
]
}
]
}
-36
View File
@@ -1,36 +0,0 @@
# 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).
+1 -425
View File
@@ -31,430 +31,6 @@ Many thanks to the following for contributing to this release:
[@username](https://github.com/username)
-->
## Unreleased
### Major Features
- Added a player proxy on the controller that accepts Factorio clients on a single UDP port, sends each player to an instance picked by plugins or the instance they were last on, and starts stopped instances on demand. It listens on port 34197 by default. Instances can also get a dedicated proxy port with `instance.player_proxy_port`. See [Player proxy](/docs/player-proxy.md).
- Added the Lobby plugin, which sends players connecting through the proxy to a lobby running their Factorio version where they pick the instance to join, creating the lobby on a host with that version if there is none, and can list the cluster and individual instances, including stopped ones, in the Factorio server browser.
### Features
- Added the `chooseSave` instance hook for plugins to pick or create the save an instance starts with, and `setSaveScenario` in `@clusterio/host` to make the save patcher stop loading the freeplay scenario of a save.
- Added the `playerProxyRoute` controller hook for plugins to decide which instance a player connecting through the player proxy goes to, given the name, Factorio version and mods of the player.
- Added `HostFactorioVersionsRequest` for the controller to ask a host which Factorio versions it has installed and whether it can download others.
### Changes
- Changed the default `host.factorio_port_range` of new hosts to 34200-34299, so it doesn't include the player proxy port 34197.
### Fixes
- Fixed map positions after the first one in exchange strings being decoded relative to the wrong previous position.
## Version 2.0.3
### Features
- 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
### Features
- Added a cluster overview landing page. [#928](<https://github.com/clusterio/clusterio/issues/928>)
- Remember the instance console chat/log toggle. [#932](<https://github.com/clusterio/clusterio/pull/932>)
- Show the public address on the instance details page. [#934](<https://github.com/clusterio/clusterio/issues/934>)
- Navigation links can now be opened in a new tab. [#929](<https://github.com/clusterio/clusterio/issues/929>)
- Instances can target a Factorio release channel (e.g. `stable` or `experimental`) as their version, resolved from `factorio.com/api/latest-releases`. [#937](<https://github.com/clusterio/clusterio/pull/937>)
- The instance console in the Web UI can be resized by dragging its bottom edge, and the chosen height is remembered across page reloads. [#938](<https://github.com/clusterio/clusterio/issues/938>)
- Tables now remember their sort and filters in the URL. [#927](<https://github.com/clusterio/clusterio/issues/927>)
- Display Factorio server version in login server list. [#906](<https://github.com/clusterio/clusterio/issues/906>)
- Add "Connect via Steam" button to login server list. [#915](<https://github.com/clusterio/clusterio/pull/915>)
### Fixes
- 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>)
- Instances always create their config file on assignment preventing duplicate folder creation. [#945](<https://github.com/clusterio/clusterio/pull/945>)
### Changes
- 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>)
- 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>)
- SubscriptionFilter now directly extends Set<string>. [#942](<https://github.com/clusterio/clusterio/pull/942>)
- EventSubscriber now exposes network errors, include permission errors. [#942](<https://github.com/clusterio/clusterio/pull/942>)
- Quick bar syncing now supports quality filters. [#944](<https://github.com/clusterio/clusterio/pull/944>)
- Added support for recommended mod dependencies. [#946](<https://github.com/clusterio/clusterio/pull/946>)
### Meta
- Node 20 is no longer officially supported as it went EoL in March 2026. Please update to v22 or v24. [#943](<https://github.com/clusterio/clusterio/pull/943>)
Many thanks to the following for contributing to this release:
[@bbassie](<https://github.com/bbassie>)
[@Cooldude2606](<https://github.com/Cooldude2606>)
## Version 2.0.0-alpha.26
### Features
- Added filter state to `EventSubscriber` allowing plugings to more easilly filter events. [#913](<https://github.com/clusterio/clusterio/pull/913>)
- Added `replace` subscription action allowing filter state to be fully replaced with a single request. [#913](<https://github.com/clusterio/clusterio/pull/913>)
- Added `ValueSubscriber` and `MapSubscriber` as subclasses of `EventSubscriber`. [#913](<https://github.com/clusterio/clusterio/pull/913>)
- Added changelog viewer accessible from the version number in the web interface. [#907](<https://github.com/clusterio/clusterio/issues/907>)
- Added about popup to user menu containing support links, build, clusterio, and debug info. [#914](<https://github.com/clusterio/clusterio/pull/914>)
- Added user status filtering (admin, whitelisted, banned) to the users table. [#905](<https://github.com/clusterio/clusterio/issues/905>)
- Added instance selector to users page to scope user stats by instance. [#905](<https://github.com/clusterio/clusterio/issues/905>)
### Fixes
- Online user table on instance page now correctly defaults to online only filter. [#909](<https://github.com/clusterio/clusterio/pull/909>)
- Fixed popup not opening when pressing kill within the instance control button. [#859](<https://github.com/clusterio/clusterio/issues/859>)
- Fix division by zero in CPU usage calculation. [#916](<https://github.com/clusterio/clusterio/issues/916>)
- Fixed permissions list not including `grantByDefault` in the json representation. [#913](<https://github.com/clusterio/clusterio/pull/913>)
- Fixed subscription resources being `synced = false` if the remote data was empty. [#913](<https://github.com/clusterio/clusterio/pull/913>)
- Fix incorrect use of `path.parse` within create template for plugins. [#919](<https://github.com/clusterio/clusterio/pull/919>)
- Fixed active filter icon behaviour in user search using empty string. [#917](<https://github.com/clusterio/clusterio/pull/917>)
### Changes
- Player auth now uses a scroll pane for listing instance names. [#909](<https://github.com/clusterio/clusterio/pull/909>)
- `subscriptionUpdate` now fires for all subscription requests rather than just added subscriptions. [#913](<https://github.com/clusterio/clusterio/pull/913>)
- Unified instance and global user table columns for consistent behaviour. [#917](<https://github.com/clusterio/clusterio/pull/917>)
- Improved loading states for tables and inputs using synced state. [#917](<https://github.com/clusterio/clusterio/pull/917>)
- Refactored username rendering into a reusable component. [#917](<https://github.com/clusterio/clusterio/pull/917>)
### Breaking Changes
- Renamed `lib.Address.index` to `requestIndex` and added `addressIndex`. [#913](<https://github.com/clusterio/clusterio/pull/913>)
- `EventSubscriber` no longer includes a values map, use `MapSubscriber` instead. [#913](<https://github.com/clusterio/clusterio/pull/913>)
### Meta
- Version dependencies now use `^` which enables updates to be applied one package at a time. [#803](<https://github.com/clusterio/clusterio/issues/803>)
- Updated change log creation to include new lines and user contributions. [#909](<https://github.com/clusterio/clusterio/pull/909>)
Many thanks to the following for contributing to this release:
[@Cooldude2606](<https://github.com/Cooldude2606>)
## Version 2.0.0-alpha.25
### Features
- Added double interrupt logic to ctl to force immediate exit. [#887](<https://github.com/clusterio/clusterio/issues/887>)
- Allow selection of factorio versions not listed on the Wube website for instances and mod packs. [#867](<https://github.com/clusterio/clusterio/issues/867>)
### Fixes
- Fix the webui being unable to set host config values and that no error was displayed. [#891](<https://github.com/clusterio/clusterio/issues/891>)
- Fix instance config not updating on changes being applied. [#901](<https://github.com/clusterio/clusterio/pull/901>)
- Incorrect acccessing of player inventory during crafting queue serialisation when in remote view. [#892](<https://github.com/clusterio/clusterio/issues/892>)
- Use physical location and controller for syncing player to prevent teleporting with remote view. [#894](<https://github.com/clusterio/clusterio/pull/894>)
- Fixed player exiting space platform hub on sync and other remote view edge cases. [#894](<https://github.com/clusterio/clusterio/pull/894>)
### Changes
- Config validation errors are no longer logged when raised during an request handler. [#888](<https://github.com/clusterio/clusterio/issues/888>)
- Updated role permission editor to use grouped layout with search and improved formatting. [#902](<https://github.com/clusterio/clusterio/pull/902>)
### Breaking Changes
- Removed obsolete functionality to download headless server in the installer. [#890](<https://github.com/clusterio/clusterio/pull/890>)
Many thanks to the following for contributing to this release:
[@Cooldude2606](<https://github.com/Cooldude2606>)
[@Daniel](<https://github.com/Daniel>)
[@Hornwitser](<https://github.com/Hornwitser>)
## Version 2.0.0-alpha.24
### Features
- Added `FactorioIcon` component for showing icons from the game in the web UI. [#875](https://github.com/clusterio/clusterio/pull/875)
- Added `useDefaultModPack` hook to the web UI. [#875](https://github.com/clusterio/clusterio/pull/875)
- Added types for the exported metadata for mod packs. [#526](https://github.com/clusterio/clusterio/issues/526)
- Added config value validation beyond simple type checks. [#856](https://github.com/clusterio/clusterio/pull/856)
- Added config staging to allow setting many values at once. [#856](https://github.com/clusterio/clusterio/pull/856)
- Added a warning when attempting to set player autosave slots lower than autosave slots. [#855](https://github.com/clusterio/clusterio/issues/855)
- Added `controller.authSecret` as the type of config field `controller.auth_secret` was inconsistent. [#856](https://github.com/clusterio/clusterio/pull/856)
- Added support for the `node:` prefix in import statements in web UI code. [#881](https://github.com/clusterio/clusterio/pull/881)
- Added config validation to load plugin to error when save patching or script commands are disabled [#884](https://github.com/clusterio/clusterio/issues/884)
### Fixes
- Fixed host printing a deprecation warning on startup. [#855](https://github.com/clusterio/clusterio/issues/855)
- Fixed exported prototype metadata missing localised_name. [#875](https://github.com/clusterio/clusterio/pull/875)
- Fixed headless download option in installer failing with `TypeError: callback is not a function`. [#860](https://github.com/clusterio/clusterio/issues/860)
- Fixed controller crashing if a host attempted to download a mod that had been deleted from the controller's filesystem while the controller was running. [#860](https://github.com/clusterio/clusterio/issues/860)
### Changes
- Moved `/icon-reference` to `/mods/mod-packs/:id/icon-reference` and linked it from the mod pack page. [#875](https://github.com/clusterio/clusterio/pull/875)
- Made the `useAccount`, `useModPacks`, `useMods`, `useRoles`, `useSaves`, `useSystems`, `useUsers` and related hooks in the web UI available to plugins. [#875](https://github.com/clusterio/clusterio/pull/875)
- Exported prototypes are now based on the `defines.prototypes` mapping from the game. [#875](https://github.com/clusterio/clusterio/pull/875)
- Removed usage of fs-extra. [#860](https://github.com/clusterio/clusterio/issues/860)
### Breaking Changes
- Removed `useExportManifest` from the web UI. Use `.exportManifest` on a mod pack from one of the `useModPack` hooks instead. [#875](https://github.com/clusterio/clusterio/pull/875)
- Replaced the 8 `useTypeMetadata` hooks in the web UI such as `useItemMetadata` with `useExportPrototypeMetadata`. [#875](https://github.com/clusterio/clusterio/pull/875)
- `.item-name` css classes are no longer created in the web UI, and will not render icons from the game. Use the `FactorioIcon` component instead and pass it a prototype from `useExportPrototypeMetadata`. [#875](https://github.com/clusterio/clusterio/pull/875)
- Renamed `useLocale` hook in web UI to `useExportLocale` which now requires the mod pack to load the exported locale from as an argument. [#875](https://github.com/clusterio/clusterio/pull/875)
- Fluids are no longer merged with items in the exported prototype metadata for mod packs. [#875](https://github.com/clusterio/clusterio/pull/875)
### Meta
- Changelog entries are now parsed with a script from pull request descriptions. [#871](https://github.com/clusterio/clusterio/pull/871)
Many thanks to the following for contributing to this release:
[@Cooldude2606](https://github.com/Cooldude2606)
[@Hornwitser](https://github.com/Hornwitser)
## Version 2.0.0-alpha.23
### Major Features
- Added automatic headless download and update on linux. [#330](https://github.com/clusterio/clusterio/issues/330)
- Added recipe notification sync to inventory sync. [#506](https://github.com/clusterio/clusterio/issues/506)
### Features
- Added config lock files to prevent process duplication. [#815](https://github.com/clusterio/clusterio/issues/815)
- Added process and system start times to system metrics. [#826](https://github.com/clusterio/clusterio/issues/826)
- Added icons to status tags for host and instances. [#827](https://github.com/clusterio/clusterio/issues/827)
- Extended export-data to produce icons for recipes, signals, technologies, planets, qualities and entities. [#838](https://github.com/clusterio/clusterio/pull/838)
- Added Icon Reference page to web interface at `/icon-reference` for browsing all exported icons. [#838](https://github.com/clusterio/clusterio/pull/838)
- Added visual flash to RCON result box to confirm command execution, even when the output is identical to the previous result. [#793](https://github.com/clusterio/clusterio/issues/793)
### Changes
- Login screen now displays the cluster name. [#818](https://github.com/clusterio/clusterio/issues/818)
- Instance versions can now be set using Major.Minor without specifying a patch version. [#820](https://github.com/clusterio/clusterio/issues/820)
- Versions for factorio and mod packs can now be selected using a drop down menu. [#820](https://github.com/clusterio/clusterio/issues/820)
- The controller will now refuse to start if no users were loaded from file. [#814](https://github.com/clusterio/clusterio/issues/814)
- Improved wording and display of the generate host token modal. [#828](https://github.com/clusterio/clusterio/issues/828)
- Installations where multiple conflicting clusterio/lib packages have been installed will now fail with an error [#863](https://github.com/clusterio/clusterio/issues/863).
- Export completion now logs per-category icon counts. [#838](https://github.com/clusterio/clusterio/pull/838)
### Fixes
- Removed debug log statement from equipment grid serialization. [#807](https://github.com/clusterio/clusterio/pull/807)
- Fix that the documentation had a dead link to details of the message passing protocol [#812](https://github.com/clusterio/clusterio/issues/812)
- Fixed case where achievements were not disabled. [#817](https://github.com/clusterio/clusterio/pull/817)
- Fix headless download on linux install. [#816](https://github.com/clusterio/clusterio/issues/816)
- Fix option `--no-download-headless` being ignored. [#816](https://github.com/clusterio/clusterio/issues/816)
- Fixed action buttons overflowing on narrow screens across the web interface. [#810](https://github.com/clusterio/clusterio/issues/810)
- Fixed player logistics failing to load on servers without unlocked logistics networks. [#806](https://github.com/clusterio/clusterio/issues/806)
- Fixed mod dependency parser breaking with inconsistent spaces. [#834](https://github.com/clusterio/clusterio/issues/834)
- No longer attempts to sync inventory in single player. [#835](https://github.com/clusterio/clusterio/issues/835)
- Fixed tests failing when the configured factorio_directory contains stuff other than Factorio installs [#540](https://github.com/clusterio/clusterio/issues/540).
- Always create a whitelist file so it can be watched for changes. [#865](https://github.com/clusterio/clusterio/issues/865)
- Added missing await statements which could cause an instance to start with an outdated admin/ban/whitelist. [#869](https://github.com/clusterio/clusterio/issues/869)
- Fixed metrics breaking when labels contain comma or equal signs [#861](https://github.com/clusterio/clusterio/issues/861)
- Fixed Host page show repeated errors when viewing an offline local host [#848](https://github.com/clusterio/clusterio/issues/848)
- Fixed bad mod paths in icon specifications crashing the export. [#841](https://github.com/clusterio/clusterio/issues/841)
- Fixed prototypes with no icon data causing errors during export. [#841](https://github.com/clusterio/clusterio/issues/841)
### Breaking Changes
- Custom permission methods should use `IUser` rather than `IControllerUser` [#837](https://github.com/clusterio/clusterio/pull/837)
- `controller.userManager` has been refactored, see PR for details. [#837](https://github.com/clusterio/clusterio/pull/837)
- `controller.instances` has been refactored, see PR for details. [#837](https://github.com/clusterio/clusterio/pull/837)
- `User/Instance/HostInfo` renamed to `User/Instance/HostRecord` [#837](https://github.com/clusterio/clusterio/pull/837)
- Removed `host.tls_ca` and `control.tls_ca` config options, see [section on self signed certificates](https://github.com/clusterio/clusterio/blob/master/docs/setting-up-tls.md#creating-and-using-a-self-signed-certificate) for the replacement for this option [#857](https://github.com/clusterio/clusterio/issues/857)
Many thanks to the following for contributing to this release:
[@Cooldude2606](https://github.com/Cooldude2606)
[@Daniel](https://github.com/Danielv123)
[@Double](https://github.com/DoubleThoughtTheProgrammer)
[@Hornwitser](https://github.com/Hornwitser)
[@Ryan Walker](https://github.com/solarcloud7)
## Version 2.0.0-alpha.22
### Major Features
@@ -751,7 +327,7 @@ Many thanks to the following for contributing to this release:
## 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.
See the [migration guide](https://github.com/clusterio/clusterio/blob/e0755cb4ee7bce0068c844ab6271da481a843e62/docs/migrating-from-2.0.0-alpha.13.md) for how to update.
See the [migration guide](/docs/migrating-from-2.0.0-alpha.13.md) for how to update.
### Major Features
+2 -2
View File
@@ -1,4 +1,4 @@
FROM node:24 AS subspace_storage_builder
FROM node:20 as subspace_storage_builder
RUN apt update && apt install -y git
WORKDIR /
RUN git clone https://github.com/clusterio/subspace_storage.git
@@ -6,7 +6,7 @@ WORKDIR /subspace_storage
RUN npm install \
&& node build
FROM node:24 AS clusterio_builder
FROM node:20 as clusterio_builder
RUN apt update \
&& apt install -y wget \
&& mkdir /clusterio
+35 -45
View File
@@ -10,11 +10,9 @@ Discord for development/support/play: https://discord.gg/5XuDkje
## Important notice
Clusterio 2.0 is the current stable release and requires Node.js 22 or newer.
For new installations, use the latest Node.js LTS release (see [Installation](#installation)).
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).
Clusterio 2.0 is still in alpha, however the previous stable has been abandoned and is no longer supported.
Despite being alpha it's reasonably stable now and there's no major breakages expected before a stable release of 2.0.
If you are starting a new cluster, it's highly recommended to use the 2.0 alpha.
### Ways to support me/the project:
@@ -42,7 +40,7 @@ Plugin authors should also follow the [2.0 migration guide](docs/devs/plugin-mig
* [Setting up remote hosts](#setting-up-remote-hosts)
* [Setting up clusterioctl](#setting-up-clusterioctl)
* [Known issues](#known-issues)
* [Common problems](#common-problems)
* [Common problems](#Common-problems)
## Introduction
@@ -73,12 +71,11 @@ Plugins add functionality to Factorio servers, Clusterio itself or both.
These are the plugins supported and maintained by the Clusterio developers:
- [Global Chat](/plugins/global_chat/README.md): share the in-game chat between servers.
- [Lobby](/plugins/lobby/README.md): lobby servers where players pick the server to join, and listing of the cluster in the Factorio server browser.
- [Research Sync](/plugins/research_sync/README.md): synchronize research progress and technologies unlocked and the research queue between servers.
- [Research Sync](/plugins/research_sync/README.md): synchronize research progress and technologies unlocked between servers.
- [Statistics Exporter](/plugins/statistics_exporter/README.md): collect in-game statistics from all the servers and makes it available to the Prometheus endpoint on the controller.
- [Subspace Storage](https://github.com/clusterio/subspace_storage): Provide shared storage that can transport items between servers via teleport chests.
- [Player Auth](/plugins/player_auth/README.md): Provides authentication to the cluster via logging into a Factorio server.
- [Player Sync](/plugins/player_sync/README.md): Synchronizes your inventory and player settings between servers.
- [Inventory Sync](/plugins/inventory_sync/README.md): Synchronizes your inventory between servers.
There's also plugins developed and maintained by the community:
@@ -86,24 +83,15 @@ There's also plugins developed and maintained by the community:
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.
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?
Check out the documentation on [Writing Plugins](/docs/writing-plugins.md) for where to start.
If you have a plugin written for an earlier 2.0 alpha, see [Migrating Plugins to 2.0](/docs/devs/plugin-migration-to-2.0.md).
Note that from 2.0 onwards minor versions of Clusterio may contain breaking changes for plugins, while breaking changes for users running a cluster are held back for major versions.
## Installation
Install the latest [Node.js LTS release](https://nodejs.org/en/download), which is recommended for new Clusterio installations.
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:
Clusterio runs on all LTS version of NodeJS, it is distributed via npm and comes with a guided install script.
If you already have a recent Node.js installed, you can set it up in a new directory with:
npm init "@clusterio"
@@ -112,14 +100,12 @@ Check out all our of decisions for [node, factorio, and operation support](/docs
### Ubuntu setup
1. Install the latest Node.js LTS release using the NodeSource repository for Ubuntu/Debian.
The distribution's default repository may provide an older version.
1. Install Node.js v18 or higher.
Note the version of Node.js provided by the Ubuntu/debian repos are too old and you will have to use the nodesource PPA
curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash - &&\
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - &&\
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:
mkdir clusterio
@@ -151,7 +137,7 @@ Your instances (save files etc) will be stored there.
### Windows setup
1. Download and install the latest [Node.js LTS release](https://nodejs.org/en/download).
1. Download and install the latest LTS release from http://nodejs.org.
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.
@@ -165,8 +151,7 @@ Your instances (save files etc) will be stored there.
### MacOS Setup
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`.
1. Install the latest Node.js LTS release from http://nodejs.org or use brew (`brew install node`).
2. Open Terminal or Command prompt in the directory you want to install to and run the following commands.
@@ -182,12 +167,16 @@ Your instances (save files etc) will be stored there.
### Installing Plugins
For well-known plugins you can select them during installation and no further steps are necessary to make them work.
Plugins that are not offered by the installer can be installed by the name of the npm package that provides them, for example
Installing plugins that are not offered by the installer consists of two steps.
First install the package the plugin is provided by via npm, for example
npx clusteriocontroller plugin install @clusterio/plugin-subspace_storage
npm install @clusterio/plugin-subspace_storage
This installs the package with npm and adds it to the default `plugin-list.json` file which in the shared folder setup is loaded by controller, host and ctl.
The command must be run from the directory clusterio was installed in and will refuse to run anywhere else.
Then tell clusterio that this plugin exists by adding it as a plugin.
npx clusteriocontroller plugin add @clusterio/plugin-subspace_storage
This adds it to the default `plugin-list.json` file which in the shared folder setup is loaded by controller, host and ctl.
If you have hosts or ctl installed on separate computers (or directories) then you need to repeat the plugin install process for all of them.
The clusteriocontroller, clusteriohost and clusterioctl commands has the plugin sub-command so you do not need to install clusteriocontroller to add plugins, instead use the clusterio command you have available.
@@ -220,11 +209,7 @@ See the [readme for @clusterio/controller](/packages/controller/README.md) for m
## Managing Factorio mods
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.
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.
![Select mod pack for instance](docs/assets/config_select_mod_pack.png)
@@ -240,9 +225,7 @@ Once this is done, the mod settings will show up under the modpack configuration
## 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.
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.
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.
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.
@@ -290,13 +273,20 @@ If you want to use a different user for the command line interface, you can gene
## Known Issues
- `@hornwitser/server_select@2.0.1` fails to load with Clusterio 2.0 because its entrypoints omit the required `.js` extension.
`@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).
- When the `server_select` plugin is installed you may see `[error] Unhandled event server_select:UpdateInstancesEvent` logged when starting an instance.
This should be ignored as it does not indicate an error occured.
- 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.
- 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
+12 -13
View File
@@ -6,11 +6,12 @@
* clean-all: Removes all build, test, and execution artifacts. Including cluster config and logs!
*/
import path from "node:path";
import fs from "node:fs/promises";
import yargs from "yargs";
"use strict";
const path = require("path");
const fs = require("fs-extra");
const yargs = require("yargs");
import npmPackage from "./package.json" with { type: "json" };
const npmPackage = require("./package.json");
const workspaces = npmPackage.workspaces
.map(p => p.slice(0, -2))
.filter(p => !p.includes("external_plugins"));
@@ -23,15 +24,13 @@ let DRY = false;
* @param {string} filePath The path to remove.
*/
async function tryRemove(filePath) {
try {
await fs.access(filePath);
if (await fs.exists(filePath)) {
// eslint-disable-next-line no-console
console.log(filePath);
} catch {
return;
}
if (!DRY) {
await fs.rm(filePath, { force: true, recursive: true, maxRetries: 10 });
await fs.remove(filePath);
}
}
@@ -49,7 +48,7 @@ async function removeBuildArtifacts() {
tryRemove(path.join(basePath, packageName, "dist"))
);
tasks.push(
tryRemove(path.join(basePath, packageName, "node_modules", ".vite"))
tryRemove(path.join(basePath, packageName, "node_modules", ".cache"))
);
}
}
@@ -74,7 +73,7 @@ async function removeTestArtifacts() {
async function removeExecutionArtifacts() {
const artifacts = [
"database", "instances", "logs", "mods",
"config-ctl.json", "config-controller.json", "config-host.json",
"config-control.json", "config-controller.json", "config-host.json",
"FactorioAdminToken.txt", "plugin-list.json",
];
@@ -105,7 +104,7 @@ async function main() {
removeBuildArtifacts();
removeTestArtifacts();
})
.command("$0", "Remove all build artifacts", () => {}, argv => {
.command("fast", "Remove all build artifacts", () => {}, argv => {
DRY = argv.dry;
removeBuildArtifacts();
})
@@ -115,6 +114,6 @@ async function main() {
}
// Run main if started from command line
if (import.meta.main) {
if (module === require.main) {
main();
}
@@ -1 +0,0 @@
<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>
-236
View File
@@ -1,236 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 13 KiB

+21 -43
View File
@@ -1,58 +1,36 @@
# Communication Layer
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.
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.
<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>
Communication is facilitated by the lib/link library and is based on three core abstractions: links, connectors and messages.
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.
## Links and routing
## Link
[Link](../packages/lib/src/link/link.ts) handles request/response matching, event dispatch, message registration, and routing through an attached router.
Its connector identifies the local and remote endpoints through `connector.src` and `connector.dst`, both `Address` objects.
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.
The link also knows what the endpoint types it's connected to are via the source and target attributes.
The controller's `WsServer` stores one link for each host or control connection.
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.
The endpoint types currently implemented are controller, host, instance, and control.
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.
Requests can pass through several links before reaching their destination.
Each receiving link checks the message name, allowed endpoint types, and source address before routing it.
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.
Upon creation the link will register with the connector in order to receive and process messages from it.
The messages received are validated using the validator registered for the type of the message.
A message for which there is no validator for is considered an error.
## Connectors and sessions
## Connector
[Connectors](../packages/lib/src/link/connectors.ts) transport messages, assign sequence numbers, and deliver incoming messages to links.
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.
The connector used with a link is responsible for establishing and/or maintain the connection that messages are sent over.
Reconnect logic is expected to be implemented in the connector, though this has not yet been done.
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).
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.
## Message definitions and registration
## Message
Core request and event classes live under [packages/lib/src/data/](../packages/lib/src/data/).
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.
There's two types of messages currently implemented apart from the Link builtin close message and that's Request and Event.
The builtin messages are defined in [packages/lib/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.
`Link.register` registers request and event classes and compiles their payload validators.
Core classes are registered by the link module; `registerPluginMessages` registers each plugin's `messages` array.
It also registers the plugin's `errors` array with `Link.registerError`, so an error thrown by a request handler is rejected on the requester as the same class.
Incoming messages invoke handlers attached with `link.handle(MessageClass, handler)`.
Plugins attach these handlers from their entrypoint functions rather than relying on method-name conventions.
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.
The messages define which links they are valid on and only attaches handlers for those links.
There's also a forwarding mechanism for when a request or event is to be forwarded another hop.
+7 -61
View File
@@ -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>
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, instance, and ctl configs.
Plugins can add their own config fields for the controller and instances configs.
The fields are defined early in the startup, and fields for disabled plugins will still be created.
The built-in config fields are defined in [packages/lib/src/config/definitions.ts](/packages/lib/src/config/definitions.ts) while plugins add fields through `controllerConfigFields`, `hostConfigFields`, `instanceConfigFields`, and `ctlConfigFields` on the `plugin` export.
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.
The built-in config fields are defined in [packages/lib/src/config/definitions.ts](/packages/lib/src/config/definitions.ts) while plugins can add their own config field definitions to the `controllerConfigFields` and `instanceConfigFields` properties of the `plugin` export.
A `<plugin_name>.load_plugin` field is automatically created for each plugin and it's used to enable/disable the loading plugins on startup.
## Defining Fields
## Defing Fields
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.
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`.
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`.
**access**:
The locations this config field can be read and modified from.
@@ -44,61 +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):
Value this config field should take in a newly initialized config.
This can also be a synchronous function returning the value to use.
**validator** (optional):
Function called with the value being set and the config it belongs to.
It should throw an error if the value is not acceptable, and the message of the error is passed on to whoever tried to set it.
Common validators such as `greaterThanZero`, `greaterThanEqualZero`, `integer` and `filePath` are available on `Config.validators`, along with `optional` to skip null and `all` to combine several.
The config is read only while the validator runs.
**dependsOn** (optional):
Names of other fields the validator reads the value of.
When one of those fields is changed the validator of this field is run again against the new value, and the change is rejected if it fails.
**restartRequired** (optional):
True if a restart of the controller, host or instance the config belongs to is needed for changes to take effect.
Changing such a field sets the `restartRequired` flag on the config, which the web interface uses to show that a restart is pending.
Defaults to false.
**restartRequiredProps** (optional):
For object fields, names of properties that have the opposite of `restartRequired`.
Only used by the web interface to show which properties need a restart, `factorio.settings` uses it to mark the settings that are applied live.
**credential** (optional):
Locations allowed to read the value of this field.
Other locations can still change the value if allowed by `access`, which is what you want for tokens and passwords.
**readonly** (optional):
Locations allowed to change the value of this field.
Other locations can still read the value if allowed by `access`.
**hidden** (optional):
True if the web interface should not show this field.
Used for internal values that are managed by Clusterio.
**autoComplete** (optional):
Value for the autocomplete attribute of the input in the web interface, e.g. `section-factorio new-password`.
Only used for string and number fields.
**inputComponent** (optional):
Name of the input component the web interface should render for this field instead of the default for its type.
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`.
This can also be an async function returning the value to use.
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.
@@ -116,7 +62,7 @@ E.g.:
let value = configInstance.fields["group.field"]
The set method also takes an extra argument, which is the value to set the field to.
If the value is not valid this will throw an InvalidValueError exception, if the field doesn't exist it will throw an InvalidFieldError exception.
If the value is not valid this will throw an InvalidValue exception, if the field doesn't exist it will throw an InvalidField exception.
## Config Lifecycle
+33 -406
View File
@@ -1,15 +1,5 @@
# Configuration
Config values are changed with the `config set` commands of clusterioctl, clusteriocontroller and clusteriohost, or from the settings pages in the web interface.
See [Managing a Cluster](/docs/managing-a-cluster.md) for the commands.
All values are checked against the type of the field when set.
Some fields have additional rules on top of that, such as a number having to be greater than 0, and these are listed under the field.
Setting a value that fails a check is rejected with an error and the old value is kept.
A few fields are internal to Clusterio.
These are managed by Clusterio itself, hidden in the web interface and should not be modified by hand.
## Controller Configuration
@@ -30,30 +20,11 @@ This is also used by plugins to store its data.
Defaults to "database".
### controller.mods_directory
Directory where mods shared with the cluster are stored.
Mods uploaded through the web interface or downloaded from the mod portal end up here, and hosts fetch the mods they need for their instances from here.
Needs to be writeable by the controller.
Defaults to "mods".
### controller.autosave_interval
Interval in seconds the controller saves the data it holds in memory, such as users, instances, mod packs and plugin data, to controller.database_directory.
The data is also saved on shutdown.
Must be greater than 0.
Defaults to 60.
### controller.http_port
Port to host HTTP server on.
If set to null no HTTP server will be exposed.
At least one of this and controller.https_port needs to be set to a port.
Must be a whole number greater than 0.
Defaults to 8080.
@@ -64,7 +35,6 @@ Port to host HTTPS server on.
Uses controller.tls_certificate and controller.tls_private_key as the certificate and private key for the HTTPS server.
If set to null no HTTPS server will be exposed.
At least one of this and controller.http_port needs to be set to a port.
Must be a whole number greater than 0.
See the [Setting up TLS](/docs/setting-up-tls.md) document for a guide to setting up HTTPS with Clusterio.
@@ -81,56 +51,27 @@ interfaces.
Defaults to null.
### controller.trusted_proxies
Comma separated list of IP addresses and/or CIDR blocks of reverse proxies in front of the controller to trust the `X-Forwarded-For` header from.
When a connection comes from one of these addresses the last address in the `X-Forwarded-For` header is used as the remote address of the connection instead of the address of the proxy.
Entries that are not valid IP addresses are logged as errors and ignored.
Defaults to null meaning the header is never trusted.
### controller.public_url
Public address the controller is accessible on.
Used as the URL in configs generated with `clusteriocontroller bootstrap create-ctl-config` and `clusterioctl host create-config`, and by plugins like Player Auth that need to show the address of the web interface in-game.
Currently only used for `clusteriocontroller bootstrap create-ctl-config` in order to give the right url to connect to.
This should be a full URL ending with a /.
Defaults to null meaning assume localhost.
### controller.static_url
Used to override where static assets such as the web bundle is loaded from.
This can be used if you want to put the static assets on a separate cdn url like `https://cdn.example.com/static/` while keeping the API hosted directly by clusterio.
Due to a limitation on how the 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.
Defaults to null meaning use the internally hosted assets.
### controller.grafana_url
URL of a Grafana dashboard to link to from the overview page of the web interface, including the protocol.
If null no link is shown.
Defaults to null.
### controller.tls_certificate
Path to TLS certificate to use for the HTTPS server when controller.https_port is configured.
Both this and controller.tls_private_key have to be set for the controller to start with controller.https_port configured.
Defaults to null.
Defaults to "database/certificates/cert.crt".
### controller.tls_private_key
Path to TLS private key to use for the HTTPS server when controller.https_port is configured.
Defaults to null.
Defaults to "database/certificates/cert.key".
### controller.auth_secret
@@ -138,7 +79,6 @@ Defaults to null.
Base64 encoded authentication secret used to verify tokens issued to hosts, users, and sessions.
This should contain 256 bytes of random data, and if changed will cause all tokens become invalid.
Keep this secret if leaked an attacker could easily compromise the cluster.
The value can only be read and changed from the controller itself, it is not available through clusterioctl or the web interface.
Defaults to null which means generate a secure secret on startup.
@@ -148,7 +88,6 @@ Defaults to null which means generate a secure secret on startup.
Interval in seconds heartbeats are sent out at for WebSocket connections.
If a WebSocket connection hasn't received a heartbeat in 2 times the heartbeat interval it will be considered stale and closed.
A lower value means less time between connections going stale and them being closed.
Must be greater than 0.
Defaults to 15.
@@ -160,113 +99,25 @@ A higher timeout gives more time for a client to recconect and resume an active
In the case of the controller or client crashing the data loss is unavoidable.
Value should not be less than the configured `max_reconnect_delay` of the clients and it should be greater than 2 times `controller.heartbeat_interval`.
Must be greater than 0.
Defaults to 60.
### controller.max_send_buffer_size
Size in MiB of messages sent to a connection that it has not yet acknowledged before the controller ends its session.
The controller keeps these so they can be resent if the connection drops and resumes.
A client that stops acknowledging messages, or falls far behind, gets disconnected and starts a new session when it reconnects.
Changes apply to new sessions.
Set to 0 for no limit.
Defaults to 64.
### controller.metrics_timeout
Timeout in seconds before a call to gather metrics from a host times out.
This should be less than both the timeout and collection interval configured for the collection job in Prometheus.
Must be greater than 0.
Defaults to 8.
### controller.max_subscription_filters
Maximum number of filters one connection can hold for a single subscribed event.
Subscription requests that would go over this limit are rejected.
Set to 0 for no limit.
Lowering the limit does not remove filters connections already hold.
Defaults to 10000.
### controller.system_metrics_interval
Interval in seconds the controller gathers CPU, memory and disk usage from itself and every connected host for display in the web interface.
Whether the controller needs a restart to apply an update is also checked on this interval.
If 0 system metrics are not gathered.
Must be 0 or greater.
Defaults to 10.
### controller.proxy_stream_timeout
### controller.stream_timeout
Timeout in seconds for proxy streams through the controller that has been created but is not yet sending data to be automatically closed.
Streams that have started sending data before this timeout is not affected.
Must be greater than 0.
Defaults to 15.
### controller.player_proxy_port
UDP port the controller accepts connections from Factorio clients on.
Players connecting to it are forwarded to an instance, which is started if it's stopped, see [Player proxy](player-proxy.md).
Disabled when null.
Must be greater than 0.
Requires a restart of the controller to take effect.
Defaults to 34197, the default port of Factorio servers.
### controller.player_proxy_default_instance_id
ID of the instance players connecting through the player proxy are sent to when no plugin picks an instance for them and they have not been on any instance before.
If null, a running instance is picked.
Defaults to null.
### controller.factorio_username
Username of the Factorio.com account to authenticate with the Factorio API with.
Together with controller.factorio_token this is used by the controller to download mods from the mod portal, and if controller.share_factorio_credentials_with_hosts is enabled, as the credentials in the server settings of instances.
Defaults to null.
### controller.factorio_token
Token of the Factorio.com account to authenticate with the Factorio API with, see controller.factorio_username.
This is the token shown on your profile page on factorio.com, not your password.
The value is treated as a credential, it can be set from anywhere but only read back from the controller itself.
Defaults to null.
### controller.share_factorio_credentials_with_hosts
If enabled hosts get controller.factorio_username and controller.factorio_token from the controller when they start an instance and put them in the server settings of that instance.
Credentials set on the host with host.factorio_username and host.factorio_token, or on the instance through `username` and `token` in factorio.settings, take precedence over the ones from the controller.
Disable this if you don't want the controller's credentials to be sent to the hosts.
Defaults to true.
### controller.default_mod_pack_id
ID of the mod pack to use for instances that don't have factorio.mod_pack_id set.
Instances without a mod pack of their own fail to start if this is null.
Defaults to null.
### controller.default_role_id
Role to automatically grant to new users.
@@ -275,55 +126,6 @@ If null no role is granted.
Defaults to 1 which correspond to the default Player role.
### controller.mod_portal_cache_duration_minutes
Time in minutes the controller caches the list of mods fetched from the Factorio mod portal when browsing it from the web interface.
Set to 0 to disable the cache and fetch the list on every request.
Must be 0 or greater.
Defaults to 30.
### controller.mod_portal_page_size
Number of results to request per page when fetching the list of mods from the Factorio mod portal.
Must be greater than 0.
Defaults to 1000.
### controller.allow_remote_updates
Whether the `controller update` command in clusterioctl and the update button in the web interface are allowed to update the `@clusterio/controller` package with npm.
For security reasons this can only be changed locally with `clusteriocontroller config set` while the controller is stopped, and it is hidden in the web interface.
See [Managing a Cluster](/docs/managing-a-cluster.md) for details on remote updates.
Defaults to true.
### controller.allow_plugin_updates
Whether remote requests to update plugins installed on the controller with npm are allowed.
Like controller.allow_remote_updates this can only be changed locally.
Defaults to true.
### controller.allow_plugin_install
Whether remote requests to install new plugins on the controller with npm are allowed.
Like controller.allow_remote_updates this can only be changed locally.
Defaults to false.
### controller.version
Version of the controller package that last loaded this config.
Set automatically on startup and used to detect when the controller has been updated or downgraded.
Internal, hidden in the web interface and should not be changed.
### <plugin_name>.load_plugin
Whether to load the plugin on the controller.
@@ -345,7 +147,6 @@ Defaults to "New Host".
### host.id
Immutable numeric id of the host which uniquely identifies this host in the cluster.
Hidden in the web interface.
Defaults to a random 31 bit number.
@@ -358,14 +159,6 @@ This can either point to a Factorio server directory, or to a directory containi
Defaults to "factorio".
### host.mods_directory
Directory the host stores mods downloaded from the controller in.
When an instance starts the mods in its mod pack are downloaded here if missing and then linked into the mods directory of the instance.
Defaults to "mods".
### host.instances_directory
Directory to store instances in.
@@ -385,11 +178,18 @@ Defaults to "http://localhost:8080/".
Access token used for authenticating with the controller.
You can generate an access token with `clusterioctl host generate-token --id <host-id>`, or use the `clusterioctl host create-config` to create a new host config with the correct url and token in it.
The value can only be read and changed from the host itself.
Defaults to "enter token here".
### host.tls_ca
Path to certificate in PEM format to use for validating a TLS connection to the controller.
If you have a self signed certificate on the controller you will need to copy the certificate to your hosts and set it as the certificate authority with this option.
Defaults to null meaning use Node.js's default set of trusted certificate authorities.
### host.public_address
External address instances hosted on this host can be accessed on.
@@ -398,84 +198,14 @@ This is used by plugins like Server Select to give the correct address to switch
Defaults to "localhost".
### host.factorio_port_range
UDP ports the host hands out to instances that don't have factorio.game_port set.
Written as a comma separated list of ports and/or ranges of ports separated with a dash, for example "34200-34299" or "34100, 34200-34210".
A port is picked when the instance is started and remembered in factorio.host_assigned_game_port, so an instance keeps the same port as long as it is still free.
Starting an instance fails if all ports in the range are taken.
Defaults to "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
Username of the Factorio.com account to put in the server settings of instances on this host.
If set this takes precedence over the credentials shared by the controller, but not over `username` set in factorio.settings of an instance.
Defaults to null.
### host.factorio_token
Token of the Factorio.com account to go with host.factorio_username.
The value is treated as a credential, it can be set from anywhere but only read back from the host itself.
Defaults to null.
### host.max_reconnect_delay
Maximum delay in seconds to wait after connection to the controller is dropped before attempting to reconnect to it.
The actual delay on each reconnect will be a random number between 0 and this configured value to avoid all clients trying to reconnect at the same time.
Must be greater than 0.
Defaults to 60.
### host.allow_remote_updates
Whether the `host update` command in clusterioctl and the update button in the web interface are allowed to update the `@clusterio/host` package with npm.
For security reasons this can only be changed locally with `clusteriohost config set` while the host is stopped, and it is hidden in the web interface.
See [Managing a Cluster](/docs/managing-a-cluster.md) for details on remote updates.
Defaults to true.
### host.allow_plugin_updates
Whether remote requests to update plugins installed on the host with npm are allowed.
Like host.allow_remote_updates this can only be changed locally.
Defaults to true.
### host.allow_plugin_install
Whether remote requests to install new plugins on the host with npm are allowed.
Like host.allow_remote_updates this can only be changed locally.
Defaults to false.
### host.version
Version of the host package that last loaded this config.
Set automatically on startup and used to detect when the host has been updated or downgraded.
Internal, hidden in the web interface and should not be changed.
### <plugin_name>.load_plugin
Whether to load the plugin on the host.
Only plugins with a host or instance part have this field in the host config.
Plugins not loaded on the host will not be loaded on the instances it hosts.
Defaults to true.
## Instance Configuration
### instance.name
@@ -488,7 +218,6 @@ Defaults to "New Instance".
### instance.id
Imutable numeric id of the instance which uniquely identifies this instance in the cluster.
Hidden in the web interface.
Defaluts to a random 31 bit integer.
@@ -498,7 +227,6 @@ Defaluts to a random 31 bit integer.
Host this instance is assigned to.
To change this you need to use the separate assign instance interface.
After being assigned to a host the directory for the instance is created on the host.
Internal and hidden in the web interface.
Defaults to null meaning not assigned.
@@ -511,15 +239,6 @@ Does not affect start or stop of the instance while the host is running.
Defaults to false.
### instance.player_proxy_port
Extra UDP port the player proxy on the controller listens on that sends players straight to this instance, starting it if it's stopped.
Only used when controller.player_proxy_port is set, see [Player proxy](player-proxy.md).
Must be greater than 0, and should not be in the host.factorio_port_range of a host on the same machine as the controller.
Defaults to null.
### instance.exclude_from_start_all
If enabled this instance will be excluded from the "Start all" button operation in the web interface.
@@ -531,52 +250,23 @@ Defaults to false.
### factorio.version
Version of Factorio to use for this instance.
Can be a specific version like "2.0" or "2.0.15", a release channel like "stable" or "experimental", or the special string "latest" meaning the latest version of Factorio found on the host.
A release channel is resolved to a version through the latest releases data the controller keeps, and the instance fails to start if that is unavailable.
Values that don't match one of these forms are rejected.
Can be a version like "1.0.0" or special string "latest" meaning the latest version of Factorio found on the host.
Defaults to "latest".
### factorio.executable_path
Path relative to the Factorio installation directory of the executable to run.
If null the path is auto detected, which is `bin/x64/factorio` on Linux and Windows and `MacOS/factorio` on macOS.
Only needed in special setups.
Defaults to null.
### factorio.shutdown_timeout
Time in seconds to wait for the Factorio server to exit after asking it to stop before the process is killed.
Set to 0 to wait forever.
Changing this while the instance is stopping restarts the timer with the new value.
Must be 0 or greater.
Defaults to 300.
### factorio.game_port
UDP port to run the Factorio server on.
If null a port from host.factorio_port_range on the host is assigned to the instance.
Negative values are rejected.
If null a random port in the dynamyc range assigned each time the instance starts.
Defaults to null.
### factorio.host_assigned_game_port
Port from host.factorio_port_range the host has assigned to this instance while factorio.game_port is null.
Internal, set by the host and only accessible from the host.
### factorio.rcon_port
TCP port to start the RCON interface on the Factorio server on.
If null a random port in the dynamic range is assigned each time the instance starts.
Negative values are rejected.
Defaults to null.
@@ -585,18 +275,16 @@ Defaults to null.
Password to use for the RCON interafec.
If null a random secure password is generated and used each time the instance starts.
The value is treated as a credential and can only be read back by the controller and the host.
Defaults to null.
### factorio.player_online_autosave_slots
### player_online_autosave_slots
Keeps a separate pool for autosaves where at least on player has been online since the previous autosave was done.
This requires regular autosaving to be enabled and will rename autosaves to `_autosave_poN.zip` (where N is a slot number) whenever a player has been online for that save.
The slot numbers start at 1, are incremented by 1 after each autosave that is renamed and reset back to 1 whenever this value is exceeded.
Setting this to a value lower than `autosave_slots` in factorio.settings is rejected, unless it is 0.
The same check is done when factorio.settings is changed.
Note that this value should be set to a higher value than the value of `autosave_slots` in `factorio.settings`.
Useful for preventing the loss of all progress when nobody is around on always online servers with auto pause disabled.
If 0 no autosave renaming takes place.
@@ -604,55 +292,16 @@ If 0 no autosave renaming takes place.
Defaults to 5.
### factorio.mod_pack_id
ID of the mod pack to use on this instance.
The mods in the pack are linked into the mods directory of the instance every time it starts, and any other files in that directory are removed.
If null the mod pack set in controller.default_mod_pack_id is used.
Defaults to null.
### factorio.enable_save_patching
Whether to use save patching or not.
When enabled lua code is patched into the game save before starting it.
Most plugins require the use of save patching to function.
Turning it off makes it possible to use Clusterio to run and manage regular vanilla games and scenarios not compatible with Clusterio.
Plugins that need save patching can't be loaded on the instance while this is disabled.
Disabling it while such a plugin is loaded is rejected, and so is loading such a plugin while it is disabled.
Defaults to true.
### factorio.enable_script_commands
Allow Clusterio and plugins to send commands that disable achievements to the server over RCON, that is `/c`, `/sc`, `/mc`, `/cheat`, `/editor` and their long forms.
Clusterio integrations and most plugins need this, and when enabled achievements are disabled on the server at startup.
When disabled attempts to send such a command fail with an error.
This does not stop players from using these commands themselves.
Like factorio.enable_save_patching, plugins that need script commands can't be loaded on the instance while this is disabled.
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
Turn on the whitelist on the server.
@@ -660,13 +309,6 @@ Turn on the whitelist on the server.
Defaults to false.
### factorio.enable_authserver_bans
Pass the `--use-authserver-bans` flag to Factorio, which makes the server check connecting players against the multiplayer bans on Factorio.com and report bans and unbans done on the server back to it.
Defaults to false.
### factorio.settings
Object with the settings to put into `server-settings.json` for the Factorio server.
@@ -674,9 +316,7 @@ The settings in `data/server-settings.example.json` of the Factorio installation
Changes to the following properties will be applied live if the instance is running while it is changed: `afk_autokick_interval`, `allow_commands`, `autosave_interval`, `autosave_only_on_server`, `description`, `ignore_player_limit_for_returning_players`, `max_players`, `max_upload_slots`, `max_upload_in_kilobytes_per_second`, `name`, `only_admins_can_pause_the_game`, `game_password`, `require_user_verification`, `tags`, `visibility`.
With `auto_pause` on, the plugins included with Clusterio stop their periodic commands while no players are online, so the game stays paused.
Defaults to a set of common settings filled in when the instance is created, such as `"tags": ["clusterio"]`, `"auto_pause": false` and a `name` and `description` based on the cluster and instance names.
Defaults to {"tags":["clusterio"],"auto_pause":false}.
### factorio.verbose_logging
@@ -687,14 +327,6 @@ This prints more messages to the log.
Defaults to false.
### factorio.console_logging
Pass the `--console-log` flag to Factorio to make it write the console output, such as chat, commands and player joins, to `console.log` in the instance directory.
Useful for 3rd party tools that read the console log of the server.
Defaults to false.
### factorio.strip_paths
Strip down absolute paths in the server log going to files inside the instance directory such that they are relative to the instance directory.
@@ -717,35 +349,25 @@ Defaults to true.
### factorio.sync_adminlist
Synchronize in-game admin list with admin status of the users in cluster.
One of "disabled", "enabled" or "bidirectional".
If enabled Clusterio will generate a new `server-adminlist.json` on instance startup based on the users in the cluster with admin status set to true, and keep the admins in sync with the users that have admin status while the instance is running.
With "bidirectional" players promoted or demoted in-game with `/promote` and `/demote` are also updated in the cluster, and from there on the other instances.
Configs from before this became a choice are migrated from true and false to "enabled" and "disabled".
Defaults to "bidirectional".
Defaults to true.
### factorio.sync_whitelist
Synchronize in-game whitelist with the whitelisted status of the users in the cluster.
One of "disabled", "enabled" or "bidirectional".
If enabled Clusterio will generate a new `server-whitelist.json` on instance startup based on the users in the cluster with whitelisted status set to true, and keep the whitelisted users in sync with the users that have whitelisted status while the instance in running.
With "bidirectional" changes made in-game with `/whitelist` are picked up by watching `server-whitelist.json` for changes and updated in the cluster as well.
This requires factorio.enable_whitelist to be on, as Factorio doesn't write the file otherwise.
Configs from before this became a choice are migrated from true and false to "enabled" and "disabled".
Defaults to "bidirectional".
Defaults to true.
### factorio.sync_banlist
Synchronize in-game banlist with the banned status of users in the cluster.
One of "disabled", "enabled" or "bidirectional".
If enabled Clusterio will generate a new `server-banlist.json` on instance startup based on users in the cluster with banned status set to true, and keep the banned users in sync with the users that have the banned status while the instance is running.
With "bidirectional" players banned or unbanned in-game with `/ban` and `/unban` are also updated in the cluster, and from there on the other instances.
Configs from before this became a choice are migrated from true and false to "enabled" and "disabled".
Defaults to "bidirectional".
Defaults to true.
### factorio.max_concurrent_commands
@@ -762,7 +384,6 @@ This means a high number of concurrent commands can amplify resends due to packe
Prior to 0.18.7 the maximum practical size of game update sent where arround 8 kB due to the UDP receive buffer on windows defaulting to this value, larger updates would not get through the receive buffer and cause the connection to drop.
To stay below the 8 kB limit the maximum safe value for this option would be around 7000 / (3 * game_speed * maximum_segment_size), which is around 20 in normal circumstances.
Must be greater than 0.
Defaults to 5.
@@ -770,22 +391,21 @@ Defaults to 5.
### <plugin_name>.load_plugin
Whether to load the given plugin on the instance.
Note that plugins need to be loaded on the controller and the host in order for them to be loaded on instances.
Loading a plugin that requires save patching or script commands is rejected while factorio.enable_save_patching or factorio.enable_script_commands respectively is disabled on the instance.
Note that plugins need to be loaded on the controller in order for them to be loaded on instances.
Defaults to true.
## Ctl Configuration
## Control Configuration
### ctl.controller_url
### control.controller_url
URL to connect to the controller to.
Defaults to null meaning complain about it not being set and exit.
### ctl.controller_token
### control.controller_token
Access token used for authenticating with the controller.
You can generate an access token with `clusteriocontroller bootstrap generate-user-token <username>`, or use the `clusteriocontroller bootstrap create-ctl-config <username>` to create a new ctl config with the correct url and token in it.
@@ -793,10 +413,17 @@ You can generate an access token with `clusteriocontroller bootstrap generate-us
Defaults to null meaning complain about it not being set and exit.
### ctl.max_reconnect_delay
### control.tls_ca
Path to certificate in PEM format to use for validating a TLS connection to the controller.
If you have a self signed certificate on the controller you will need to copy the certificate to the computer you run clusterctl from and set it as the certificate authority with this option.
Defaults to null meaning use Node.js's default set of trusted certificate authorities.
### control.max_reconnect_delay
Max duration in seconds to wait before attempting to reconnect with the controller after the connection is dropped.
The actual delay on each reconnect will be a random number between 0 and this configured value to avoid all clients trying to reconnect at the same time.
Must be 0 or greater.
Defaults to 60.
+33 -175
View File
@@ -3,28 +3,23 @@
You are probably reading this because you want to contribute code to Clusterio.
Great! We need people like you.
Though we have some conventions and workflows that we kindly ask you to follow.
This document covers the development workflow, commands, and repository layout.
Read our [past decisions](/docs/decisions.md) for compatibility policies and design rationale.
We have included all the pratical considerations in this document.
Although, we recomend checking out out our [past decisions](/docs/decisions.md) if you have the time.
## Contents
- [Project map](#project-map)
- [Workflow](#workflow)
- [Starting a Feature Branch](#starting-a-feature-branch)
- [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)
- [Cleaning generated and runtime files](#cleaning-generated-and-runtime-files)
- [Submitting a Pull Request](#submitting-a-pull-request)
- [Breaking Compatibility with the Last Release](#breaking-compatibility-with-the-last-release)
- [Editing your Feature Branch](#editing-your-feature-branch)
- [Resolving Merge Conflicts](#resolving-merge-conflicts)
- [Updating Your GitHub Fork](#updating-your-github-fork)
- [Publishing releases to npm](#publishing-releases-to-npm)
- [Changelog](#changelog)
- [Supported Node.js and Factorio Versions](#supported-nodejs-and-factorio-versions)
- [Supported Node.js and Factorio Version](#supported-node.js-and-factorio-version)
- [Code Style](#code-style)
- [Naming Style](#naming-style)
- [Units](#units)
@@ -33,28 +28,6 @@ Read our [past decisions](/docs/decisions.md) for compatibility policies and des
- [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
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.
@@ -79,20 +52,16 @@ Do not run `git pull upstream` it will mess thing up!
### Setting up the Development Environment
Run commands from the repository root unless stated otherwise.
Use the latest Node.js LTS release and the pnpm version pinned by `packageManager` in root `package.json`.
Install dependencies with:
The project is organized into several packages that are all tied together using pnpm's workspace feature.
In order to get the development environment up and running you will need to run the following command:
pnpm install --no-frozen-lockfile
pnpm install
This installs dependencies and builds the packages.
`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:
This installs dependencies needed by tests and links the packages up so they work from the git work tree.
To start a cluster from the repo you will first need to initialize it, this can be done using the installer:
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
node packages/ctl plugin add ./path/to/plugin
@@ -108,62 +77,31 @@ The documentation uses the binaries provided by the packages, but for the develo
By default node does not enable source maps, to get stack traces pointing to the `.ts` files you will need to pass `--enable-source-maps` to `node` or set the environment variable `NODE_OPTIONS=--enable-source-maps`.
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 control config first:
node packages/controller bootstrap create-ctl-config <admin-user>
node packages/controller bootstrap create-ctl-config [admin-user]
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.
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.
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.
For web development on the controller there are also the following flags:
`--dev` - Serve the `web_ui` package from source through Vite's development server with hot module replacement.
`--dev` - Recompile the `web_ui` package on the fly providing live reloading of it.
`--dev-plugin [name]` - Serve the web module for the given plugin from source the same way.
`--dev-plugin [name]` - Recompile the web module for the given plugin on the fly proving live reloading of it.
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.
### 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.
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.
### Starting a Feature Branch
Development is done exclusively in feature branches.
To start a new feature branch, first check out master from the main repository.
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 do this check out that branch from the main repository.
git fetch upstream
git checkout upstream/master
@@ -179,61 +117,13 @@ But it's nice to have its name be somewhat descriptive of what it implements.
### Checking your code for errors
Tests use Mocha and Node assertions, mostly as JavaScript ESM importing compiled packages.
Run the test suite and linter from the repository root:
There's automated tests and linting setup for the project.
Please do add tests for any code you add and run both:
pnpm run test
pnpm run lint
`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.
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.
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.
To check that your changes pass the integration tests as well as the ESLint rules set up for the project.
### Submitting a Pull Request
@@ -247,39 +137,6 @@ You can also pass the `--set-upstream` (or `-u`) flag to make `git push` do the
After your feature branch have been merged you should consider it final.
If there are more changes you want to have merged or mistakes you need to fix you must start a new feature branch and submit a new pull request.
Pull request descriptions should contain a `### Changelog` section with additions for the changelog contained inside a code block, see the [CHANGELOG.md](/CHANGELOG.md) file for the formatting of changelog entries and possible sections to use.
If there are no changelog entries to add, remove the code block and start the changelog section with the text `None.`, followed by an optional reason for why it is not is needed.
### Breaking Compatibility with the Last Release
The version-compat job in CI runs the local controller with the latest published host, and the published controller with the local host.
It fails if a link message changed its src, dst, data or response schema since that release.
If the break is intended, add an entry to [scripts/version-compat-breaks.json](../scripts/version-compat-breaks.json) in the same pull request.
Breaking changes are only accepted if the next release is intended to include them, see our decision on [breaking changes](/docs/decisions.md#breaking-changes).
The entry is keyed by the published version directly before the breaking change was introduced, which is the latest release at the time.
Every entry needs the pull request number, a reason saying what breaks, and an impact saying why end users are not or only minimally affected.
```json
{
"2.0.3": {
"messages": {
"request FactorioVersionsRequest": {
"pr": 1129,
"reason": "What breaks between the two versions.",
"impact": "Why end users are not or only minimally affected."
}
},
"connect": { "pr": 1234, "reason": "Why a host cannot connect at all.", "impact": "..." }
}
}
```
Listed messages are reported as warnings with the reason and impact instead of failing the job.
Only add `connect` when the published and local versions cannot connect at all, it skips starting the cluster.
Listing a message that did not change fails the job.
Entries only apply while their version is the latest release, after the next release they are ignored and can be removed.
Describe the break in the Breaking Changes section of the changelog.
### Editing your Feature Branch
@@ -336,14 +193,12 @@ The format of this log should be self explanatory, please follow it carefully. P
## Supported Node.js and Factorio Versions
For development, use the latest Node.js LTS release, following the [installation instructions](../README.md#installation).
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.
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.
The GitHub Actions tests are run on whatever version of Node.js v18 and v20 is the default.
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).
Older versions from 0.17 onwards receive passive support where it does not restrict actively supported versions.
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.
For Factorio Clusterio 2.0 aims to support version 0.17.69 and up, including the latest experimental release.
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.
The GitHub Actions tests are run against the latest experimental release.
Check out all our of decisions for [node, factorio, and operation support](/docs/decisions.md#supportability).
@@ -364,10 +219,13 @@ Fields in configs uses lowercase_underscore.
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.
Use ESM imports, including `.js` extensions in TypeScript relative imports.
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.
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.
Relative imports in lib package are prefixed with lib to make them easier to distinguish, as the names tend to be very generic.
E.g.:
const libLink = require("./link");
const libLuaTools = require("./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.
@@ -376,7 +234,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:
E.g.:
const startTimeMs = Date.now();
const startTimeMs = date.now();
const endNs = process.hrtime();
This makes it easier to spot different unit bases are being mixed in the code.
@@ -417,7 +275,7 @@ For example:
### Markdown
Markdown files are formatted using one line per sentence to improve diff reports when making changes.
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.
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.
For example:
```md
+24 -33
View File
@@ -18,7 +18,7 @@ If there is a particular issue or concern you would like the team to comment on
- [Naming Times and Durations](#naming-times-and-durations)
- [Strings](#strings)
3. Code Style (TS / JS / Web)
- [Oxlint](#oxlint)
- [ESLint](#eslint)
- [Imports Within `lib` Package](#imports-within-lib-package)
- [Name Style](#name-style-ts)
4. Code Style (Lua)
@@ -38,7 +38,7 @@ If there is a particular issue or concern you would like the team to comment on
- [React](#react)
- [Separation of Host and Controller](#separation-of-host-and-controller)
- [Typescript](#typescript)
- [Vite](#vite)
- [Webpack](#webpack)
8. Supportability
- [Breaking Changes](#breaking-changes)
- [Distribution](#distribution)
@@ -91,11 +91,6 @@ Therefore, we decided to use TSDoc comments for this purpose as it is well inter
Our specific implementation is TypeDoc because it is lightweight it and can output TSDoc comments as html.
API Extractor was also considered but not selected.
🧾 29th October 2026 | [Github PR #1147](https://github.com/clusterio/clusterio/pull/1147)
We dropped TypeDoc as the generated docs were never published and it does not support TypeScript 7.
TSDoc comments are still used for editor hover text.
## Code Style (General)
@@ -162,15 +157,14 @@ Therefore, we selected double quotes as our standard.
## Code Style (TS / JS / Web)
### Oxlint
### ESLint
🧾 20th October 2026
🧾 Time Immemorial
> Use Oxlint for style checking, with formatting rules from `@stylistic/eslint-plugin`.
> Use ESLint for style checking.
We needed to maintain a consistent style across all our files and used ESLint for it from the start.
Oxlint runs the same rules about three times faster, reads ESLint disable comments, and does not depend on the TypeScript compiler API.
The formatting rules ESLint deprecated run through `@stylistic/eslint-plugin` loaded as an Oxlint JS plugin, so the code style did not change.
We needed to maintain a consistent style across all our files.
Therefore, we utilise the industry-standard ESLint, as it is familiar and highly configurable.
### Imports Within `lib` Package
@@ -353,31 +347,30 @@ 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.
### Vite
### Webpack
🧾 20th October 2026
🧾 Time Immemorial
> Our web stack uses Vite. Plugin web bundles are ES modules that import shared dependencies through an import map.
> Our web stack uses Webpack and associated plugins.
We used Webpack with Module Federation for loading plugins from the start, as it was the standard at the time.
Vite builds the web interface several times faster, its development server updates modules without reloading the page, and plugin bundles no longer depend on one bundler's runtime.
See [#1127](https://github.com/clusterio/clusterio/issues/1127) for the discussion.
We required a bundler for our web build, and at the time, Webpack was the standard.
While we have considered alternatives since, transitioning to another solution would be a significant undertaking.
## Supportability
### Breaking Changes
🧾 7th October 2026
🧾 Time Immemorial
> From 2.0.0 onwards, major versions contain breaking changes for end users, while minor versions may contain breaking changes for the plugin API.
> All breaking changes are documented in the changelog along with migration actions.
> We follow symver versioning for breaking changes.
> In our alpha stage, we allow every alpha to contain breaking changes as long as their are documented and provide migration actions.
> Once we enter beta, breaking changes will be more restricted.
We aim to be highly transparent with breaking changes and maintain compatibility with as many existing installations as possible.
During the alpha stage every alpha was allowed to contain breaking changes, as long as they were documented and provided migration actions.
From 2.0 onwards changes which require action from people running a cluster, such as changes to configs, stored data or the command line, are held back for the next major version.
Changes to the plugin API may still be made in minor versions.
Plugin authors should check the changelog when a new minor version is released, non-trivial changes come with a migration guide such as [Migrating Plugins to 2.1](/docs/devs/plugin-migration-to-2.1.md).
However, as we are currently in the alpha stage, things are unstable, and breaking changes occur frequently.
As such, we have decided to document all breaking changes and provide migration steps where the changes are non-trivial.
Once we enter the beta stage, we will implement stricter restrictions on breaking changes.
### Distribution
@@ -459,13 +452,11 @@ While we aim to increase the number of useful checks performed by CI in the futu
🧾 Time Immemorial
> We publish packages and mods when a maintainer publishes a stable GitHub release.
> We do not use any CD actions, and have no plans of implementing them.
Changes merged into the main branch are bundled into releases before publication.
Publishing a stable GitHub release triggers the [Publish workflow](../.github/workflows/publish.yml), which publishes npm packages and Factorio mods.
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.
Unlike CI, we do not find continuous deployment useful at our current stage of the project.
This is due to the frequent breaking changes merged into the main branch, which we prefer to bundle together as a single version.
In the future, we may automate the deployment process, but it will always require manual activation.
### Mocha
@@ -484,5 +475,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.
The [current CI workflow](../.github/workflows/node.js.yml) tests selected Node.js major versions and pinned Factorio releases.
We perform tests on all versions of NodeJS and Factorio that we support.
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.
+9 -63
View File
@@ -10,9 +10,7 @@ This document describes the various different types of content that can be creat
- [event_handler interface](#event_handler-interface)
- [Communicating with Clusterio](#communicating-with-clusterio)
- [Clusterio Modules](#clusterio-modules)
- [Initialisation and recovery](#initialisation-and-recovery)
- [Clusterio Lua API](#clusterio-lua-api)
- [Compatibility library](#compatibility-library)
- [Clusterio Plugins](#clusterio-plugins)
@@ -75,7 +73,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.
- `events`:
Table mapping event ids as defined in `defines.events` to callbacks.
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.
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.
- `on_nth_tick`:
Table mapping the nth tick number to a callback.
For example the table `{ [30] = bar }` will call bar every 30th tick.
@@ -94,7 +92,7 @@ For example a module could be defined as
```lua
-- example.lua
local function my_handler(event)
local name = game.get_player(event.player_index).name
local name = game.player[event.player_index].name
game.print("It appears that " .. name .. " has died")
end
@@ -178,27 +176,12 @@ 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/`.
It's also possible to require files from other modules this way too.
Persistent state is shared by all save-patched modules and the scenario.
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
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.
It's recommended to prefix the global variables and data in the global table that your module has with the name of your module.
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.
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.
The Clusterio Lua API provides the custom `on_server_startup` event that can be used as a substitute, see the next section.
## Clusterio Lua API
@@ -207,29 +190,6 @@ 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.
### 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
**require path**
@@ -268,8 +228,7 @@ Event data:
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 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.
It is invoked on the first tick the server runs after the save has been patched, before most other events.
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.
@@ -277,7 +236,7 @@ Keep in mind that both mods and Clusterio modules can switch from any version to
#### clusterio_api.send_json(channel, data)
Send JSON data to Clusterio over the given channel.
The `data` argument should be a table that can be serialized with `compat.table_to_json`.
The `data` argument should be table that can be serialized with `game.table_to_json`.
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.
@@ -290,20 +249,7 @@ Strings embedded into the tables sent must be valid UTF-8 text.
Parameters:
- `channel`: string identifying which channel to send it on.
- `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.
- `data`: table that can be converted to JSON with game.table_to_json
### serialize library
@@ -314,7 +260,7 @@ From a module
From a mod
`local serialize = require("__clusterio_lib__/serialize")`
Serializes inventories and item stacks into a table that in turn can be converted into JSON with `compat.table_to_json`.
Serializes inventories and item stacks into a table that in turn can be converted into JSON with `game.table_to_json`.
Properly handles the extra data for most items.
#### serialize.serialize_equipment_grid(LuaEquipmentGrid)
+8 -3
View File
@@ -14,7 +14,7 @@ See the following example code for a hypothetical `Data` class with a single pro
* A Data class example
* @alias module:Data
*/
export class Data {
class Data {
/** @type {string} */
member = "foo";
@@ -41,6 +41,8 @@ export class Data {
return json;
}
}
module.exports = Data;
```
Serialising an instance of `Data` for JSON is as simple as doing `serialised = JSON.stringify(data)` as the `toJSON` method is automatically called by `JSON.stringify` to create a suitable object.
@@ -51,13 +53,14 @@ For example a class holding a data property with an instance of the Data class f
```js
// Composed.js
import Data from "./Data";
"use strict";
const Data = require("./Data");
/**
* Composed data class example
* @alias module:Data
*/
export class Composed {
class Composed {
/** @type {module:Data|undefined} */
data;
@@ -80,6 +83,8 @@ export class Composed {
// toJSON is optional and not needed for this particular class
}
module.exports = Composed;
```
It's easy to create a new instance of Composed by calling `new Composed(new Data("spam"))` and serialising to and from JSON is the same as for Data.
-333
View File
@@ -1,333 +0,0 @@
# Migrating Plugins to 2.0
This guide covers the changes needed to bring a plugin written for 2.0.0-alpha.27 up to 2.0.0.
If your plugin targets an older alpha, go through the Breaking Changes sections in the [changelog](/CHANGELOG.md) for the versions in between first.
Plugins still on alpha 13 should start with the [alpha 14 guide](plugin-migration-to-alpha-14.md).
## Versioning from 2.0
From 2.0.0 onwards a major version (3.0.0) contains breaking changes for users running a cluster, while a minor version (2.1.0) may contain breaking changes for the plugin API.
This means a plugin written for 2.0 is not guaranteed to work with 2.1 without changes.
Check the Breaking Changes section of the changelog when a new minor version is released and publish an update to your plugin if needed.
See [Breaking Changes](/docs/decisions.md#breaking-changes) in the decision log.
## Convert to ES modules
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`.
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.
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`.
**Alpha 27**
```js
"use strict";
const lib = require("@clusterio/lib");
const messages = require("./messages");
const plugin = {
name: "foo_frobber",
controllerEntrypoint: "controller",
messages: [messages.Frobnicate],
};
module.exports = {
plugin,
};
```
**2.0**
```js
import * as lib from "@clusterio/lib";
import * as messages from "./messages.js";
export const plugin = {
name: "foo_frobber",
controllerEntrypoint: "controller.js",
messages: [messages.Frobnicate],
};
```
The entrypoint paths in the plugin declaration must include the `.js` extension as well, for TypeScript plugins this means `"./dist/node/controller.js"` instead of `"./dist/node/controller"`.
`__dirname` and `__filename` do not exist in ES modules, use `import.meta.dirname` and `import.meta.filename` instead.
JSON files are imported with `import packageJson from "./package.json" with { type: "json" };`.
A `webpack.config.js` written with `require()` stops working once the package is an ES module.
Rename it to `webpack.config.cjs`, webpack-cli finds it under that name without further changes.
If your `prepare` script passes `--config webpack.config.js` update it to the new name.
TypeScript plugins using `"module": "node16"` in their tsconfig, as the plugin templates do, emit ES modules once `"type": "module"` is set and need no further config changes.
Node.js can still import a CommonJS plugin, but it only finds the `plugin` export if it can detect it from the source, which works for `module.exports = { plugin };` and fails for other forms such as an object literal written in place.
Converting the plugin avoids the problem.
## Replace plugin classes with entrypoint functions
Entrypoints now export a default async function instead of a class deriving from `BaseControllerPlugin`, `BaseHostPlugin`, `BaseInstancePlugin`, `BaseCtlPlugin` or `BaseWebPlugin`.
The function is called once when the plugin loads and attaches handlers to the hooks it needs.
Class exports still load, but they are deprecated and a warning is logged for each one.
See [Defining the plugin entrypoint](/docs/writing-plugins.md#defining-the-plugin-entrypoint) for the details.
**Alpha 27**
```js
const { BaseControllerPlugin } = require("@clusterio/controller");
class ControllerPlugin extends BaseControllerPlugin {
async init() {
this.controller.handle(Frobnicate, this.handleFrobnicate.bind(this));
}
async onSaveData() {
await this.saveFrobnicationData();
}
async onInstanceStatusChanged(instance, prev) {
this.logger.info(`Instance ${instance.id} went from ${prev} to ${instance.status}`);
}
async handleFrobnicate(request) { /* ... */ }
}
module.exports = {
ControllerPlugin,
};
```
**2.0 (JS)**
```js
export default async function(context) {
const { controller, logger, plugin } = context;
controller.handle(Frobnicate, async (request) => { /* ... */ });
controller.hooks.save.attach(plugin.name, async () => {
await saveFrobnicationData();
});
controller.hooks.instanceStatusChanged.attach(plugin.name, async (instance, prev) => {
logger.info(`Instance ${instance.id} went from ${prev} to ${instance.status}`);
});
}
```
**2.0 (TS)**
```ts
import type { ControllerPluginContext } from "@clusterio/controller";
export default async function(context: ControllerPluginContext) {
// Same as above
}
```
The code from `init()` goes into the body of the function, and state that lived on `this` becomes local variables.
The context types are `ControllerPluginContext`, `HostPluginContext`, `InstancePluginContext`, `CtlPluginContext` and `WebPluginContext` from the respective packages.
### Keeping a class for state
If the plugin has enough state that a class is still the better fit, give the class a private constructor and an async `static fromContext` that creates it from the context and attaches its hooks.
`fromContext` can then be the default export of the entrypoint.
Instances are kept in a static `WeakMap` keyed by the controller, host or instance they belong to, which lets other code look them up without holding a reference to them.
```ts
import type { Controller, ControllerPluginContext } from "@clusterio/controller";
import type * as lib from "@clusterio/lib";
export class Frobber {
private static instances = new WeakMap<Controller, Frobber>();
private frobnications = new Map<string, number>();
private constructor(
private controller: Controller,
private logger: lib.Logger,
) { }
static async fromContext(context: ControllerPluginContext) {
const frobber = new Frobber(context.controller, context.logger);
await frobber.load();
context.controller.hooks.save.attach(context.plugin.name, () => frobber.save());
Frobber.instances.set(context.controller, frobber);
return frobber;
}
static get(controller: Controller) {
return Frobber.instances.get(controller);
}
async load() { /* ... */ }
async save() { /* ... */ }
}
export default Frobber.fromContext;
```
The loader calls the default export without `this`, so refer to the class by name inside `fromContext` rather than using `this`.
Don't extend the deprecated base classes for this, they attach `on*` methods to hooks themselves and log a warning.
### Method to hook names
Each `on*` method has a hook of the same name without the `on` prefix and with the first letter lowercased, so `onInstanceStatusChanged` becomes `controller.hooks.instanceStatusChanged`.
The exceptions are:
- `onSaveData` on the controller is `controller.hooks.save`.
- `addCommands` on ctl plugins is `context.hooks.addCommands`.
- The `pages`, `loginForms` and `inputComponents` properties of web plugins are the `pages`, `loginForms` and `inputComponents` hooks on `control.hooks`, with handlers that return the value.
- The `componentExtra` property of web plugins is the `control.hooks.extensionComponents` hook.
Controller hooks are on `controller.hooks`, host hooks on `host.hooks`, instance hooks on `instance.hooks`, and web hooks on `control.hooks`.
The ctl hooks are passed directly as `context.hooks`.
**Alpha 27**
```jsx
class WebPlugin extends BaseWebPlugin {
async init() {
this.pages = [
{ path: "/foo_frobber", sidebarName: "Foo Frobber", content: <FrobberPage/> },
];
}
}
```
**2.0**
```jsx
export default async function(context) {
const { control, plugin } = context;
control.hooks.pages.attach(plugin.name, () => [
{ path: "/foo_frobber", sidebarName: "Foo Frobber", content: <FrobberPage/> },
]);
}
```
### Replacing other class members
The class properties are on the context object passed to the entrypoint:
- `this.info` is `context.plugin`.
- `this.logger` is `context.logger`.
- `this.controller`, `this.host`, `this.instance` and `this.control` are `context.controller`, `context.host`, `context.instance` and `context.control`.
- `this.metrics` on controller plugins is `context.metrics`.
- `this.package` on web plugins is `context.package`.
The helper methods of the base classes have the following replacements:
- `this.sendRcon(message, expectEmpty)` is `instance.sendRcon(message, expectEmpty, plugin.name)`.
The plugin name is used to label the RCON metrics.
- `this.broadcastEventToHosts(event)` is `controller.sendTo("allHosts", event)`.
- `this.sendOrderedRcon(message)` has no replacement.
If your plugin needs commands to execute in order, queue them in the plugin and send the next one after the previous one resolves.
### Changes to hook behaviour
Handlers attached by different plugins to the same hook now run concurrently, where the `on*` methods were called one plugin at a time.
Don't rely on another plugin's handler having finished before yours runs.
A handler that throws or takes more than 15 seconds is logged and does not prevent the other handlers from running.
Each hook accepts one handler per name, attach it under `plugin.name`.
## Accessing other plugins
The `plugins` map on `Controller`, `Host` and `Instance` has been removed.
It's replaced with `loadedPlugins`, a set of the plugin info objects for the plugins that loaded.
The set holds info objects rather than names, so look the info up in `pluginInfos` by name first to check if a plugin is loaded on the controller or host:
```js
const info = controller.pluginInfos.find(info => info.name === "other_plugin");
const loaded = info !== undefined && controller.loadedPlugins.has(info);
```
In the web interface `control.loadedPlugins` is a map from plugin name to plugin info.
`control.plugins` is deprecated and only holds plugins still using the class export.
With no plugin class instances, `controller.plugins.get("other_plugin")` has no direct replacement.
If one plugin needs to call into another, have the other plugin export its class and use the static lookup from [Keeping a class for state](#keeping-a-class-for-state):
```ts
import { Frobber } from "other_plugin/dist/node/controller.js";
const frobber = Frobber.get(controller);
if (!frobber) {
throw new Error("other_plugin is not loaded");
}
```
This only works if both plugins resolve `other_plugin` to the same file on disk, otherwise the lookup goes to a different copy of the class and returns undefined.
For state shared between components of a web plugin, assign it to a module level variable in the entrypoint.
The web plugin template created by `@clusterio/create` shows this for a subscriber.
## Declare permissions on the plugin
Permissions can now be declared under `permissions` in the plugin declaration instead of with `lib.definePermission()`.
`lib.definePermission()` still works, but don't use both for the same permission name.
For TypeScript plugins `user.checkPermission()`, `account.hasPermission()` and the `permission` of web pages now only accept known permission names.
Add your permission names to the `Permissions` interface or these calls will fail to compile, even if you keep using `lib.definePermission()`:
```ts
declare module "@clusterio/lib" {
export interface Permissions {
"foo_frobber.frobnicate": never;
}
}
```
The plugin template's `tsconfig.browser.json` now includes `index.ts` so the web build also sees the declaration, add it to the `include` list if your plugin has its own tsconfig for the browser.
See [Plugin Permissions](/docs/writing-plugins.md#plugin-permissions).
## Renamed ctl classes and config
The remaining uses of "control" for clusterioctl were renamed to "ctl":
- `controlConfigFields` in the plugin declaration is `ctlConfigFields`.
Fields under the old name are silently ignored.
- The `ControlConfigFields` interface is `CtlConfigFields`.
- `lib.ControlConfig` is `lib.CtlConfig`.
- The `Control` class exported by `@clusterio/ctl` is `Ctl`.
The term "control" is still used in message `src` and `dst` and in config field locations, where it covers both ctl and the web interface.
## Static assets in the web interface
`staticRoot` now includes the `static/` prefix.
Plugins that fetch assets with `${staticRoot}static/...` should drop the `static/` part.
## Package dependencies
npm installs peer dependencies by default, so listing `@clusterio/web_ui` under `peerDependencies` installs it everywhere the plugin is installed even though it's only needed to build the web bundle.
Move `@clusterio/web_ui` to `devDependencies` and mark `@clusterio/controller`, `@clusterio/host` and `@clusterio/ctl` as optional in `peerDependenciesMeta`:
```json
"peerDependencies": {
"@clusterio/controller": "^2.0.0",
"@clusterio/host": "^2.0.0",
"@clusterio/lib": "^2.0.0"
},
"peerDependenciesMeta": {
"@clusterio/controller": {
"optional": true
},
"@clusterio/host": {
"optional": true
}
}
```
-61
View File
@@ -1,61 +0,0 @@
# 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`.
+84 -114
View File
@@ -2,32 +2,38 @@
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 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.
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.
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 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
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 `registerHost` or `registerControl` message depending on what kind of client it is to the server.
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.
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 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.
Upon leaving the handshake phase both parties should start sending heartbeat messages with the sequence number of the last received message.
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.
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.
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 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 session resume is successful then the server will respond with a `continue` message and the connection is active again.
The client then re-sends buffered messages newer than the `lastSeq` in `continue`.
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.
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.
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`.
![Connection flow](../assets/connection_flow.svg)
@@ -35,42 +41,36 @@ The [send-buffer setting](../configuration.md#controllermax_send_buffer_size) co
## 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.
There's three kinds of connectors defined: `WebSocketClientConnector`, `WsServerConnector` and `VirtualConnector`.
There's three kinds of connectors defined: `WebSocketClientConnector`, `WebSocketServerConnector` and `VirtualConnector`.
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:
- `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.
- `connected` - The connector is successfully connected.
- `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:
- `connect` - A new session is established, moving from `connecting` (client) or `closed` (server) to `connected`.
- `drop` - The transport was lost but the session is retained for resumption, moving from `connected` to `resuming`.
- `resume` - The retained session is reconnected and unacknowledged buffered messages are replayed, moving from `resuming` to `connected`.
- `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.
- `connect` From `connecting` (client) or `closed` (server) to `connected`
- `drop` - From `connected` to `resuming`
- `resume` - From `resuming` to `connected`
- `invalidate` - From `resuming` to `connecting` or `closed`
- `close` - From any state to `closed`
![Conector States](../assets/connector_states.svg)
### WsClientConnector
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.
For example, policy violation (1008) stops retries, while try-again-later (1013), used for send-buffer overflow, permits reconnecting.
Connection retries use exponential backoff.
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.
Other than that, the logic is pretty straight forward.
![WsClientConnector](../assets/WsClientConnector.svg)
### WsServerConnector
The [server connector](/packages/controller/src/WsServerConnector.ts) doesn't do the work of connecting or validating the handshake.
The server connector 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.
Because of this there's no `connecting` state, and once a session times out from the `resuming` state it goes directly to closed.
@@ -79,26 +79,19 @@ Because of this there's no `connecting` state, and once a session times out from
## Wire format
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.
All communication in the protocol is done via WebSocket text frames containing a JSON payload with the following properties:
An address is `[type, id]`, optionally followed by a request ID: `[type, id, requestId]`.
Address types are 0 for controller, 1 for host, 2 for instance, 3 for control, and 4 for broadcast.
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.
- `seq: integer | null` - The sequence number of the message, can only be null for handshake and heartbeats.
- `type: string` - The type of message
- `data: object` - Message data
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.
This ID is separate from transport `seq`, which is used for acknowledgements and buffering.
Before a connection is established it's in the handshake phase and the `seq` property is `null`.
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.
### Handshake Messages
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.
The following messages are the only valid messages that can be sent during the handshake phase.
#### `hello`
@@ -109,7 +102,7 @@ Sent by the controller when the WebSocket connection has been opened.
- `plugins: Object<string, string>` -
Object mapping plugin names to plugin version for plugins that are loaded on the controller.
#### `registerHost`
#### `register_host`
Host handshake for establishing a new connection session.
@@ -119,7 +112,7 @@ Host handshake for establishing a new connection session.
- `plugins: Object<string, string>` -
Object mapping plugin names to plugin version for plugins that are available on the host.
#### `registerControl`
#### `register_control`
Control handshake for enstablishing a new connection session.
@@ -130,32 +123,29 @@ Control handshake for enstablishing a new connection session.
Handshake for resuming an existing connection session where the connection had dropped unexpectedly.
- `sessionToken: string` - Session token to resume session with.
- `lastSeq: integer` (optional) - Last transport sequence number received by the client; omitted if none has been received.
- `session_token: string` - Session token to resume session with.
- `last_seq: integer | null` - Last message seq received by the client.
#### `invalidate`
Sent by the controller in reply to a resume for a session that cannot be resumed.
The frame is `{"type":"invalidate"}`, without a `data` object.
The client should start a new session with `registerHost` or `registerControl` in response.
The client should start a new session with a register\_\* message in response.
#### `continue`
Sent by the controller to signal resuming the session was successful.
- `lastSeq: integer` (optional) - Last transport sequence number received by the server; omitted if none has been received.
- `sessionTimeout: number` - Session timeout in seconds.
- `heartbeatInterval: number` - Interval between heartbeats in seconds.
- `last_seq: integer | null` - Last message seq received by the server.
- `session_timeout: integer` - Session timeout in seconds.
- `heartbeat_interval: number` - Rate client should send heartbeats at.
#### `ready`
Sent by the controller to signal the connection has been established with a new session.
- `src: Address` - The client's address, used as the source of messages it sends.
- `sessionToken: string` - Token to resume the session with.
- `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`.
- `session_token: string` - Token to resume the session with.
- `session_timeout: integer` - Session timeout in seconds.
- `heartbeat_interval: number` - Rate client should send heartbeats at.
### Heartbeat message
@@ -168,84 +158,64 @@ 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.
- `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.
- `last_seq: integer` - Last message seq received over the link.
### Events
Events are messages sent in one direction that the receiver acts upon without replying; use [requests](#requests) when a response is needed.
Their wire `type` is `"event"`, with a separate `name` identifying the registered event class.
Plugin names are prefixed, for example `foo_frobber:SomeEvent`.
The `data` payload, when present, contains event-specific properties.
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).
By convention message types ending in `*_event` are events.
The data payload of an event consists solely of event specific properties.
See [packages/lib/src/data/](/packages/lib/src/data/) for core event definitions.
Plugins may define their own events as well.
See [packages/lib/src/link/messages.ts](/packages/lib/src/link/messages.ts) for the recognized core events and their contents.
Plugins may defined their own events as well.
#### `event`
#### `*_event`
- `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.
- any properties defined by the event type.
### Requests
Requests expect a response in return.
They function like HTTP requests except that either party of a connection can initiate one.
The wire `type` is `"request"`; a successful reply uses `"response"`, and a failed request uses `"responseError"`.
The request's `name` identifies the registered class, prefixed with the plugin name for plugin messages, such as `foo_frobber:Frobnicate`.
Requests are messages which expects 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.
By convention message types ending in `*_request` are requests, and is expected to be replied to with a corresponding `*_response` message.
If an error occured while processing the request an error response containing an error property in the data payload is sent instead.
See [packages/lib/src/data/](/packages/lib/src/data/) for core request definitions.
See [packages/lib/src/link/messages.ts](/packages/lib/src/link/messages.ts) for the recognized core requests and their contents.
Plugins may define their own requests as well.
#### `request`
#### `*_request`
- `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.
- any properties defined by the request type.
#### `response` success
#### `*_response` success
- `seq: integer` - This response's transport sequence number, not the request's sequence number.
- `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.
- `seq: integer` - The seq number of the request message.
- any additional properties defined by the request type.
#### `responseError`
#### `*_response` error
- `seq: integer` - This error response's transport sequence number.
- `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.
- `seq: integer` - The seq number of the request message.
- `error: string` - Human readable text message describing the error.
### Graceful Disconnect
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 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.
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.
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.
#### `disconnect` with `data: "prepare"`
#### `prepare_disconnect_request`
The frame `{"type":"disconnect","data":"prepare"}` requests a graceful disconnect.
Request graceful disconnect of the connection.
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.
#### `disconnect` with `data: "ready"`
#### `prepare_disconnect_response`
The frame `{"type":"disconnect","data":"ready"}` signals that preparation is complete and the connection can be closed.
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.
There is no error-response variant; the connector also has a timeout so an unresponsive peer cannot block shutdown indefinitely.
Signals the preparation for disconnect is complete and that 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.
If the error response variant is received it should be logged if possible and treated as a success.
### Example handshake
@@ -254,10 +224,10 @@ Server hello after opening the WebSocket.
```json
{
"seq": null,
"type": "hello",
"data": {
"version": "2.0.0",
"plugins": {}
"version": "2.0.0"
}
}
```
@@ -266,12 +236,12 @@ Host registering a new connection session.
```json
{
"type": "registerHost",
"seq": null,
"type": "register_host",
"data": {
"token": "xyzabc123",
"version": "2.0.0",
"id": 123,
"plugins": {}
"id": 123
}
}
```
@@ -280,12 +250,12 @@ Controller reply signaling the session is established and ready.
```json
{
"seq": null,
"type": "ready",
"data": {
"src": [1, 123],
"sessionToken": "xyz.azy.bzy",
"sessionTimeout": 60,
"heartbeatInterval": 15
"session_token": "xyz.azy.bzy",
"session_timeout": 60,
"heartbeat_interval": 15
}
}
```
-24
View File
@@ -1,25 +1,3 @@
# 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.
![Clusterio system diagram showing a controller, web and command-line clients, and two hosts managing three Factorio servers each](assets/clusterio_2.0_block_diagram.svg)
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
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.
@@ -27,8 +5,6 @@ The save patching is based on the built in [event_handler](https://github.com/wu
Mods on the other hand work without modifications.
This can be disabled with the `factorio.enable_save_patching` config on a per instance basis, however disabling it means that modules are not loaded into the game and most plugins will not function.
To use the modules outside of Clusterio the `npx clusteriohost create-scenario` command creates a scenario with the modules patched into it.
Note that the IPC between the game and the host is not available outside of Clusterio, so modules that depend on it will not work as expected in such a scenario.
<sub>1: Before version 2.0 of Clusterio this was done at runtime with a patcher called Hotpatch, but due to the difficulty in supporting run time patching and the lack of documentation it was replace it with save patching instead.</sub>
+11 -116
View File
@@ -5,7 +5,7 @@ This document uses the shorthand `ctl> foo` to indicate `npx clusterioctl foo` s
Mandatory parameters are shown in `<angles bracket>` and optional pameters are in `[square brackets]`.
Before `clusterioctl` can be used it needs to be configured for the cluster it will connect to.
The easiest way to do this is to run `npx clusteriocontroller bootstrap create-ctl-config <username>` on the controller, which creates the necessary `config-ctl.json` for managing the cluster as the given user.
The easiest way to do this is to run `npx clusteriocontroller bootstrap create-ctl-config <username>` on the controller, which creates the necessary `config-control.json` for managing the cluster as the given user.
## Hosts
@@ -30,10 +30,9 @@ Create a host config. `--id` id the host id. `--name` is the host name. `--gener
### Update Clusterio
ctl> host update <host> [--all] [--restart]
ctl> host update [--id <host id>] [--name <host name>] [--restart]
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.
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.
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:
@@ -41,17 +40,9 @@ 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:
ctl> controller update [--all] [--restart]
ctl> controller update [--restart]
controller> config set controller.allow_remote_updates true/false
### Mark restart required
ctl> host mark-restart-required <host>
Marks the host as requiring a restart, the same way a plugin update does. Use it after installing or changing a plugin by other means than `host plugin install`, such as a plugin checked out into `external_plugins`. The mark shows up in the web interface on the next system metrics poll. The controller has the same command:
ctl> controller mark-restart-required
## Instances
### List Instances
@@ -74,12 +65,6 @@ Terminates the running Factorio server without giving it a chance to save or cle
Note: This may cause loss of data.
### Save game
ctl> instance save-game <name>
Saves the running game of the instance to the save it was started from and waits for the save to finish.
### Extract players from instance
ctl> instance extract-players <name>
@@ -117,7 +102,7 @@ Edit instance config in a text editor. Once file is saved and editor has been ex
### Assign instance to host
ctl> instance assign <instance> [host]
ctl> instace 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.
@@ -231,38 +216,6 @@ Note: If the player joins the cluster again a new account will be made for them
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)
To be written.
@@ -283,49 +236,17 @@ This command lists all plugins currently known to the remote. Newly installed pl
### Update
ctl> controller plugin update <name>[@<version>] [--restart]
ctl> host plugin update <host> <name>[@<version>] [--restart]
ctl> controller plugin update <name> [--restart]
ctl> host plugin update <host> <name> [--restart]
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
ctl> controller update --all [--restart]
ctl> host update <host> --all [--restart]
Updates the Clusterio package and all plugins installed from npm in a single `npm update --save` command. Updating everything at once avoids ending up with multiple versions of `@clusterio/lib` in the installation. Requires both remote updates and remote plugin updates to be enabled on the remote.
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.
### Install
ctl> controller plugin install <name>[@<version>] [--restart]
ctl> host plugin install <host> <name>[@<version>] [--restart]
If remote plugin installs are enabled on the remote, this will execute `npm install --save <name>`.
Pass `<name>@<version>` to install a specific version, for example to match the version used by the rest of the cluster.
The package, and the version if given, must be published on npm.
Changes will not be applied until the remote is restarted.
If the `--restart` flag is provided, the remote will automatically restart once the update is complete.
### Search
ctl> controller plugin search [query] [--page <n>] [--page-size <n>]
Searches the npm registry for packages tagged with the clusterio-plugin keyword.
The search is done by the controller and does not require remote installs to be enabled.
The web UI has a plugin browser on the plugins page that uses the same search.
### Concurrent Operations
Only one npm operation runs at a time on a machine.
A standalone controller and host share their working directory, so the operation holds the `npm-operation.lock` file there while npm runs.
Other installs and updates on the same machine or installation are rejected with "Another package operation is already in progress on this machine" until it finishes.
A lock file left behind by a process that is no longer running is removed automatically.
ctl> controller plugin install <name> [--restart]
ctl> host plugin install <host> <name> [--restart]
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
@@ -340,29 +261,3 @@ Remote updates are enabled by default but can be disabled on a per-machine basis
controller> config set controller.allow_plugin_install true/false
Remote installs are disabled by default but can be enabled 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 above commands locally while the remote is offline.
## Prometheus Metrics
The controller exports metrics for the whole cluster on the `/metrics` endpoint.
Fetching it requires a token for a user with the `core.controller.metrics` permission, sent either as an `Authorization: Bearer <token>` header or as an `X-Access-Token` header.
Requests without a valid token get a 401 response and requests for users lacking the permission get a 403 response.
Create a role with just this permission, a user to hold it, and generate a token for that user.
Give the user a name that is not a valid Factorio player name, such as one starting with `#`, so a player joining the cluster can never claim the account and its token:
ctl> role create Prometheus --permissions core.controller.metrics
ctl> user create #prometheus
ctl> user set-roles #prometheus Prometheus
$ npx clusteriocontroller bootstrap generate-user-token '#prometheus'
Then pass the token in the scrape job for the controller in `prometheus.yml`:
scrape_configs:
- job_name: clusterio
authorization:
credentials: <token>
static_configs:
- targets: ["localhost:8080"]
Concurrent scrapes share a single round of metrics gathering from the hosts, so multiple scrapers polling the controller do not multiply the load on the hosts.
+46
View File
@@ -0,0 +1,46 @@
# 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.
-71
View File
@@ -1,71 +0,0 @@
# 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.
-83
View File
@@ -1,83 +0,0 @@
# 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.
+11 -38
View File
@@ -5,27 +5,14 @@
- CHANGELOG.md in the repository root
- The changelog.txt file in mod directories
Pull requests will usually have suggestions for changelog entries. These can be automatically parsed and formatted by running `node scripts/pr_to_changelog.mts`, though you will have to manually sort through which should go in the changelog in the repository root and which should go in the changelogs in mod directories.
If the script fails due to GitHub's 60/hour public access rate limit, you can set the `GH_TOKEN` environment variable to a fine-grained personal access token which has a much higher rate limits.
Pull requests will usually have suggestions for changelog entries.
## 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]
> You will need commit access to the repository as well publish access to the @clusterio org on npm to do this.
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.
1. Ensure you are on the latest version of master branch with no untracked files
```sh
git switch master
@@ -33,45 +20,31 @@ The steps below describe publishing npm packages manually instead.
git status
```
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.
2. Bump the version in the `package.json` files
```sh
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
```
sed -i 's/2\.0\.0-alpha\.XX/2\.0\.0-alpha\.YY/' {packages,plugins}/*/package.json
````
3. Review and commit the version and changelog changes, then open and merge a release pull request.
3. Tag and push the release to github
```sh
git add -p
git commit -m "Prepare 2.0.2 release"
git commit -m v2.0.0-alpha.XX
git tag v2.0.0-alpha.XX
```
4. After the release pull request is merged, tag the merged commit and push the tag.
4. Perform a clean install and publish the packages.
```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 install --no-frozen-lockfile
pnpm -r --filter='./packages/*' --filter='./plugins/*' --fail-if-no-match publish
pnpm -r --filter='./{plugins,packages}/*' publish
```
## Plugins
1. Bump the version in the `package.json` file
2. Run `pnpm install --no-frozen-lockfile` in the repository root
2. Run `pnpm install` in the repository root
3. Run `pnpm adduser` to authenticate
4. Run `pnpm publish` in the plugin/package directory
+4 -19
View File
@@ -63,25 +63,10 @@ This creates a private key file named `key.pem` and a public certificate file na
npx clusteriocontroller config set controller.tls_certificate cert.pem
npx clusteriocontroller config set controller.tls_private_key key.pem
Additionally you will need copy the `cert.pem` file to all of the computers you want to set up as hosts and configure node to trust it by setting the `NODE_EXTRA_CA_CERTS` environment variable to a path to this file before the host is started. You can do this by passing `--self-signed-cert path/to/cert` to the installer when setting up a host, or manually by editing run-host.cmd, run-host.sh and/or clusteriohost.service by adding the following line next to the one setting `NODE_OPTIONS`:
Additionally you will need copy the `cert.pem` file to all of the computers you want to set up as hosts and configure it as the `host.tls_ca` option:
**For run-host.cmd**
npx clusteriohost config set host.tls_ca cert.pem
set "NODE_EXTRA_CA_CERTS=path\to\cert.pem"
You will also need to copy the `cert.pem` file to all of the computers you want to remotely manage the cluster via clusterctl and configure it as `control.tls_ca` option:
**For run-host.sh**
export "NODE_EXTRA_CA_CERTS=path/to/cert.pem"
**For clusteriohost.service**
Environment=NODE_EXTRA_CA_CERTS=path/to/cert.pem
You will also need to copy the `cert.pem` file to all of the computers you want to remotely manage the cluster via clusterctl and set the `NODE_EXTRA_CA_CERTS` environment variable as a path to that file before running clusterioctl. For example:
# On Windows
set "NODE_EXTRA_CA_CERTS=path\to\cert.pem"
# On Linux
export NODE_EXTRA_CA_CERTS="path/to/cert.pem"
# You can now use clusterioctl
npx clusterioctl instance list
npx clusterioctl control-config set control.tls_ca cert.pem
+168 -526
View File
@@ -1,17 +1,15 @@
# Writing Plugins
Plugins for Clusterio are JavaScript modules that run under Node.js.
Each entrypoint of a plugin exports a function that attaches handlers to hooks called during various stages and operations of Clusterio.
Plugins for Clusterio are classes written in JavaScript that run under Node.js.
The plugin classes have pre-defined hooks that are called during various stages and operations of Clusterio.
## Contents
- [Plugin Structure](#plugin-structure)
- [Defining the plugin entrypoint](#defining-the-plugin-entrypoint)
- [Defining the plugin class](#defining-the-plugin-class)
- [Logging Messages](#logging-messages)
- [Plugin Configuration](#plugin-configuration)
- [Plugin Permissions](#plugin-permissions)
- [Migrating Stored Data](#migrating-stored-data)
- [Communicating with Factorio](#communicating-with-factorio)
- [Defining Link Messages](#defining-link-messages)
- [Sending Link Messages](#sending-link-messages)
@@ -29,7 +27,7 @@ The basic file structure of a plugin is the following.
+- package.json
+- controller.js
+- instance.js
+- ctl.js
+- control.js
+- test/
| +- plugin.js
+- module/
@@ -39,11 +37,6 @@ 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.
The usual guides for creating such packages apply.
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`.
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.
@@ -52,50 +45,26 @@ 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.
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`:
```js
import * as messages from "./messages.js";
export const plugin = {
module.exports.plugin = {
name: "foo_frobber",
title: "Foo Frobber",
description: "Does advanced frobnication",
instanceEntrypoint: "instance.js",
controllerEntrypoint: "controller.js",
messages: [messages.Frobnicate],
instanceEntrypoint: "instance",
controllerEntrypoint: "controller",
messages: {
/* See below */
},
};
```
The following properties are recognized:
**name**:
Stable internal identifier of the plugin, used as its key in the plugin list.
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`.
Internal name of the plugin.
Must be the same as the plugin's directory name
**title**:
Name of the plugin as shown to users.
@@ -106,7 +75,7 @@ The following properties are recognized:
Currently not used.
**instanceEntrypoint**:
Path to a Node.js module relative to the plugin directory which exports the instance entrypoint function for this plugin.
Path to a Node.js module relative to the plugin directory which contains the InstancePlugin class definition for this plugin.
This is an optional paramater.
A plugin may have code only for instances but it must still be loaded on the controller in order for it to be possible to load it on an instance.
@@ -115,122 +84,86 @@ The following properties are recognized:
See [Plugin Configuration](#plugin-configuration)
**controllerEntrypoint**:
Path to a Node.js module relative to the plugin directory which exports the controller entrypoint function for this plugin.
Path to a Node.js module relative to the plugin directory which contains the ControllerPlugin class definiton for this plugin.
This is an optional parameter.
A plugin can be made that only runs on the controller.
**controllerConfigFields**:
Object mapping controller configuration fields to `FieldDefinition` objects for this plugin.
**ControllerConfigGroup**:
Object mapping controller configuration fields to `FieldDefinitions`for this plugin.
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**:
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 contains the CtlPlugin class definition for this plugin.
This is an optional paramater.
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**:
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)
**messages**:
Array of request and event classes for this plugin.
Object with link messages definitions for this plugin.
See guide for [defining link messages](#defining-link-messages) below.
**errors**:
Array of error classes for this plugin.
See [Request errors](#request-errors).
**permissions**:
Array of permission definitions for this plugin.
See [Plugin Permissions](#plugin-permissions)
The optional module folder contains a Clusterio module that will be patched into the save when the plugin is loaded.
See the section on [Clusterio Modules](developing-for-clusterio.md) in the Developing for Clusterio document.
The only restriction imposed on modules embedded into plugins is that they must be named the same as the plugin.
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 ControllerPlugin class definition into controller.js and the InstancePlugin class definition into instance.js.
You can put them into whatever file you want (even the same one for both).
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.
For both instanceEntrypoint and controllerEntrypoint the path should not end with .js and it should use forward slashes for directory separators if any.
## Defining the plugin entrypoint
## Defining the plugin class
Each entrypoint module exports a default async function which is called once when the plugin is loaded.
It is passed a context object with what the plugin needs to interact with Clusterio, and attaches handlers to the hooks it is interested in.
For example, a controller entrypoint can look like this:
The plugin class should derive from its respective base class defined in `lib/plugin`.
For example, to define a ControllerPlugin class the following code can be used:
```js
export default async function(context) {
const { controller, logger, plugin } = context;
const { BaseControllerPlugin } = require("@clusterio/controller");
controller.hooks.save.attach(plugin.name, async () => {
await saveFrobnicationData();
});
class ControllerPlugin extends BaseControllerPlugin {
async init() {
this.foo = 42;
await this.startFrobnication();
}
controller.hooks.shutdown.attach(plugin.name, async () => {
logger.info("Stopping frobnication");
});
// ...
}
module.exports = {
ControllerPlugin,
}
```
The context has the following properties:
For the instance plugin it's exactly the same except "Controller" is replaced with "Instance", for host plugins "Host" is used and for the clusterioctl plugin "Ctl" is used.
The available hooks that you can override are documented in the base classes.
**plugin**:
The plugin's own info, as exported from the main entrypoint.
It's best to avoid defining a constructor, but if you insist on defining one, forward all arguments to the base class.
E.g.:
**logger**:
Logger for this plugin, see [Logging Messages](#logging-messages).
```js
constructor(...args) {
super(...args);
**controller**, **host**, **instance**, **hooks** or **control**:
The object the entrypoint is loaded for.
Controller entrypoints get `controller`, host entrypoints get `host`, instance entrypoints get both `instance` and `host`, clusterioctl entrypoints get `hooks` and web entrypoints get `control`.
// Code here
}
```
The available hooks are documented on the `ControllerHooks`, `HostHooks`, `InstanceHooks`, `CtlHooks` and `WebHooks` classes.
Handlers are attached under a name, which should be the name of the plugin, and a hook only accepts one handler per name.
An error thrown by a handler is logged and does not affect the other handlers, the same goes for handlers that take more than 15 seconds to complete.
For TypeScript the context types are exported as `ControllerPluginContext`, `HostPluginContext`, `InstancePluginContext`, `CtlPluginContext` and `WebPluginContext` from their respective packages.
### Class based plugins
Plugins written as classes deriving from `BaseControllerPlugin`, `BaseHostPlugin`, `BaseInstancePlugin`, `BaseCtlPlugin` or `BaseWebPlugin` still load, but this way of defining plugins is deprecated and a warning is logged when such a plugin is loaded.
The base class constructor attaches the `on*` methods overridden by the plugin to the corresponding hooks, and the async `init` method is called immediately after.
See [Migrating Plugins to 2.0](devs/plugin-migration-to-2.0.md) for how to convert them.
The arguments passed may change, and attempting to modify them will result in unpredicatable behaviour.
The async init method is always called immediatly after the constructor, so there's little reason to do this.
## Logging Messages
The load context provides a winston logger for logging messages to the shared cluster log.
The base plugin classes provide a winston logger for logging messages to the shared cluster log.
For instances a copy of the log is also stored on the host the instance is on.
To use it, pass a string to one of the log levels functions, for example:
```js
export default async function(context) {
context.logger.info("Initializing frobbing");
async init() {
this.logger.info("Initializing frobbing");
}
```
Metadata covering which plugin and instance (for instance entrypoints) the log event happened on will automatically be attached to the log message.
Metadata covering which plugin and instance (for instance plugin classes) the log event happened on will automatically be attached to the log message.
The available levels are fatal, error, warn, audit, info, and verbose.
These levels have roughly the following meanings:
@@ -256,8 +189,8 @@ For guarding unexpected errors the best option is to log a short description alo
try {
// Operation that should not throw but may end up throwing
} catch (err) {
logger.error(`Operation failed:\n${err.stack}`);
}
this.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.
@@ -266,7 +199,7 @@ For plugin hooks expections thrown are automatically catched and logged, but for
## Plugin Configuration
Clusterio provides a configuration system that handles storing, distributing, editing and validating config fields for you.
Declare fields under `controllerConfigFields`, `hostConfigFields`, `instanceConfigFields`, or `ctlConfigFields` in the `plugin` export.
You can take advantage of it by declaring config fields under either `controllerConfigFields` or `instanceConfigFields` in the `plugin` export.
For example in index.js:
```ts
@@ -280,7 +213,7 @@ declare module "@clusterio/lib" {
}
}
export const plugin = {
export default {
...
controllerConfigFields: {
"foo_frobber.level": {
@@ -292,12 +225,12 @@ export const plugin = {
} satisfies lib.PluginDeclaration;
```
The controller entrypoint will then be able to access the level config field through the `Config` object at `controller.config`:
Code inside the `ControllerPlugin` class will then be able to access the level config field through the `Config` object at `this.controller.config`, for example in the ControllerPluginClass:
```ts
export default async function(context: ControllerPluginContext) {
const level = context.controller.config.get("foo_frobber.level");
context.logger.info(`I got a frobnication level of ${level}`);
async init() {
let level = this.controller.config.get("foo_frobber.level");
this.logger.info(`I got a frobnication level of ${level}`);
}
```
@@ -307,146 +240,33 @@ See [Configuration System](config-system.md) for more details on how this system
### Handling Invalid Configuration
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:
If the plugin requires a certain feature to be enabled to function it should throw an error during init if this is not the case.
The most common such feature is the save patching, which can be disabled to run vanilla or scenarios not compatible with Clusterio.
For example:
```js
export const plugin = {
name: "foo_frobber",
// ...
features: ["SavePatching"],
};
```
Enabling the plugin on an instance that has the feature turned off then fails config validation, so the mistake is caught when the config is edited instead of when the instance starts.
Other constraints on config fields are expressed with a `validator` on the field definition, which throws when the value is not acceptable.
Use `dependsOn` when the check involves other fields so that the validator runs when those change too.
See [Configuration System](config-system.md) for details.
## Plugin Permissions
Permissions are granted to roles and checked on users, both on the controller and in the web UI.
A plugin declares the permissions it uses under `permissions` in the `plugin` export.
Names must start with the plugin name followed by a dot.
For example in index.ts:
```ts
import type * as lib from "@clusterio/lib";
declare module "@clusterio/lib" {
// Extend the interface of known permission names so that
// user.checkPermission(...) accepts our permission.
export interface Permissions {
"foo_frobber.frobnicate": never;
async init() {
if (!this.instance.config.get("factorio.enable_save_patching")) {
throw new Error("foo_frobber plugin requires save patching.");
}
}
export const plugin = {
...
permissions: [
{
name: "foo_frobber.frobnicate",
title: "Frobnicate",
description: "Run frobnication on instances.",
grantByDefault: false, // Whether the generated Player role is granted this permission
},
],
} satisfies lib.PluginDeclaration;
```
The `Permissions` interface only exists at the type level, its values are always `never` and only the keys are used.
Adding your permission names to it gives you autocomplete and a compile error on typos when calling `user.checkPermission`, `account.hasPermission` or setting the `permission` of a web UI page.
In JavaScript the `declare module` block is not needed.
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.
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
For pushing data into Factorio there's RCON, which lets you send arbitrary Lua commands to invoke whatever code you want in the game.
This is done by calling the `sendRcon` method on the instance.
This is done by calling the `sendRcon` method on the plugin object.
For example:
```js
instance.hooks.start.attach(plugin.name, async () => {
const response = await instance.sendRcon(
async onStart() {
let response = await this.sendRcon(
"/sc rcon.print('data')"
);
// Do stuff with response.
});
}
```
Because data into Factorio is streamed at a rate of 3-6 kB/s by default, it is recommended to avoid sending large commands as much as possible, and to strip down the data on the ones you send to only what's strictly necessary.
@@ -459,16 +279,15 @@ From a plugin you listen for an event named `ipc-channel_name` in order to get d
For example in the plugin code:
```js
export default async function(context) {
const { instance, logger } = context;
instance.server.on("ipc-my_plugin_foo", content =>
handleFoo(content).catch(err => logger.error(
async init() {
this.instance.server.on("ipc-my_plugin_foo", content =>
this.handleFoo(content).catch(err => this.logger.error(
`Error handling foo:\n${err.stack}`
))
);
}
async function handleFoo(content) {
async handleFoo(content) {
// Do stuff with content
}
```
@@ -485,11 +304,11 @@ clusterio_api.send_json("my_plugin_foo", { data = 123 })
For convience a helper function is exposed on server specifically for handling IPC events.
```js
export default async function(context) {
context.instance.server.handle("my_plugin_foo", handleFoo);
async init() {
this.instance.server.handle("my_plugin_foo", this.handleFoo.bind(this));
}
async function handleFoo(content) {
async handleFoo(content) {
// Do stuff with content
}
```
@@ -497,7 +316,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`.
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.
With raw `server.on(...)` listeners, catch errors in the handler; `server.handle(...)` already handles error logging for IPC handlers.
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.
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.
@@ -505,62 +324,6 @@ Data out from Factorio does not have the same limits as data into Factorio, RCON
For `send_json` it's possible that payloads greater than 4kB are received after payloads that were sent at a later point in time.
For RCON, commands longer than 50 characters may end up being executed after shorter commands sent after it.
### Auto pause
Each RCON command makes a paused game run one tick, so commands sent on a timer keep a server with `auto_pause` on running while nobody is online.
`instance.autoPaused` is true while the instance was started with `auto_pause` on and no players are online, and the `autoPauseChanged` hook is invoked with the new value when it changes.
Skip periodic commands while it is true:
```js
instance.hooks.start.attach(plugin.name, async () => {
timer = setInterval(() => {
if (!instance.autoPaused) {
instance.sendRcon("/sc my_plugin.update()").catch(handleError);
}
}, 1000);
});
```
Commands sent in response to events from elsewhere in the cluster are fine, they cost one tick each.
A game paused with `game.tick_paused` does not run ticks for commands.
### Communicating over UDP
Instances can optionally exchange UDP packets with the Factorio server for low latency fire-and-forget communication, using the `--enable-lua-udp` option of Factorio.
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
@@ -575,59 +338,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.
The requests are similar to HTTP requests, except both parties of a link may initiate one.
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, `messages.js` can contain:
Messages are defined as items of the `plugin.messages` array exported by `index.js`.
For example, the following could be defined in `plugin`:
```js
export class Frobnicate {
static plugin = "foo_frobber";
static type = "request";
static src = "instance";
static dst = "controller";
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: {
messages: [
class Frobnicate {
static plugin = "foo_frobber"; // Plugin name
static type = "request";
static src = "controller";
static dst = "instance";
static jsonSchema = {
type: "object",
properties: {
report: {
type: "array",
items: { type: "string" },
"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",
properties: {
"report": {
type: "array",
items: { type: "string" },
},
},
},
required: ["report"],
additionalProperties: false,
},
fromJSON(json) {
return json;
},
};
}
fromJSON(json) {
return json
}
}
}
]
```
This specifies a request sent from an instance to the controller through its host.
This specifies a request that can be sent from the controller to an instance, being routed through the correct host behind the scenes.
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 response must contain a `report` property containing an array of strings.
It also defines that the response sent may contain a `report` property mapping to an array of strings.
The following properties are recognized as part of a message class:
@@ -656,34 +419,6 @@ 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.
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
A JSON schema that specifies what's valid to send in the message.
@@ -695,8 +430,7 @@ This should return an object that's valid according to `jsonSchema` and accepted
#### static fromJSON
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.
Required function to restore the class from a JSON object.
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.
@@ -705,138 +439,60 @@ 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.
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
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:
```js
import * as messages from "./messages.js";
// In an instance entrypoint
async function frobnicate(instance) {
const response = await instance.sendTo("controller", new messages.Frobnicate({
// In an InstancePlugin class
async frobnicate() {
const response = await this.instance.sendTo("controller", new messages.Frobnicate({
verbosity: 2,
special: false,
}));
console.log(response); // { report: [...] }
}
```
Register the corresponding handler on the controller:
```js
import * as messages from "./messages.js";
export default async function({ controller }) {
controller.handle(messages.Frobnicate, async request => ({
report: [`Frobnication verbosity: ${request.verbosity}`],
}));
}
```
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.
The destination can identify a particular control, host, or instance.
The `allHosts`, `allControls`, and `allInstances` broadcast destinations are for events, not requests.
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.
Example destinations:
```js
connection.sendTo("controller", message);
connection.sendTo("allHosts", event);
connection.sendTo("allControls", event);
connection.sendTo("allInstances", event);
connection.sendTo({ hostId: 123 }, message);
connection.sendTo({ instanceId: 123 }, message);
connection.sendTo({ controlId: 123 }, message);
sendTo("controller", message);
sendTo("allHosts", message);
sendTo("allControls", message);
sendTo("allInstances", message);
sendTo({ hostId: 123 }, message);
sendTo({ instanceId: 123 }, message);
sendTo({ controlId: 123 }, message);
```
### Request errors
An error thrown by a request handler is sent back to the requester, and the promise returned by `.sendTo()` rejects with an instance of the same class.
The error classes follow the HTTP status codes:
```
Error
├── ClientError (4xx)
│ ├── ValidationError (400)
│ │ ├── InvalidValueError
│ │ └── InvalidFieldError
│ ├── ForbiddenError (403)
│ │ ├── PermissionError
│ │ └── InvalidAccessError
│ ├── NotFoundError (404)
│ ├── TimeoutError (408)
│ └── ConflictError (409)
└── ServerError (5xx)
├── InternalServerError (500)
└── UnavailableError (503)
```
Throw a `ClientError` subclass when the request can't be done as asked, and `UnavailableError` when the host or instance that should handle it is not connected or not running, for example:
```js
controller.handle(messages.Frobnicate, async request => {
if (!frobnicators.has(request.id)) {
throw new lib.NotFoundError(`Frobnicator ${request.id} does not exist`);
}
// ...
});
```
Errors of a class that is not registered are logged as unexpected by the side that threw them and are received as `InternalServerError`.
Registered errors are not logged.
The message and stack trace of the original error are kept in both cases.
Plugins can define their own error classes by listing them in `errors` of the plugin declaration.
They need a static `plugin` property set to the plugin name and must take the message as the first constructor argument:
```js
export class FrobnicatorJammed extends lib.ConflictError {
static plugin = "foo_frobber";
}
```
A subclass that is not registered is received as its closest registered parent class.
### Handling connection events
There are a few connection related events that plugins neeed to repsond to in order to avoid data loss and connection problems.
The most important is the prepare disconnect for the link between controller and host.
This is signaled to controller plugins via the `prepareHostDisconnect` hook and to instance plugins via the `prepareControllerDisconnect` hook.
This is signaled to `ControllerPlugin` classes via the `onPrepareHostDisconnect` hook and to `InstancePlugin` classes via the `onPrepareControllerDisconnect` hook.
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.
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, an instance plugin can avoid starting a request while the host is disconnected or preparing to disconnect:
For example the sending of an event from an `InstancePlugin` class 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:
```js
if (host.connected) {
await instance.sendTo("controller", new messages.Frobnicate({ verbosity: 2, special: false }));
if (this.host.connected) {
this.instance.sendTo("controller", new messages.Frobnicate({ foo: "bar" }));
}
```
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 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.
If the event or request needs to be sent to the controller it can be put into a queue stored on the plugin instance 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 `onControllerConnectionEvent` and `onHostConnectionEvent` hooks.
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 `onControllerConnectionEvent` for `InstancePlugin` classes and through `onHostConnectionEvent` for `ControllerPlugin` classes.
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 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.
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.
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.
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.
@@ -850,7 +506,7 @@ In its simplest form collecting data from plugins consists of defining the metri
For example:
```js
import { Counter } from "@clusterio/lib";
const { Counter } = require("@clusterio/lib");
const fooMetric = new Counter(
"clusterio_foo_frobber_foo_metric", "Measures the level of foo",
@@ -860,13 +516,13 @@ const fooMetric = new Counter(
fooMetric.inc();
```
This works for controller plugins, and the metric will be automatically available through the /metrics HTTP endpoint.
This works for controller plugins, and the metric will be automatically available through the /metric HTTP endpoint.
It's recommended that plugin metrics follow `clusterio_<plugin_name>_<metric_name>` as the naming scheme.
For metrics that are per-instance, you must define an `instance_id` label and set it accordingly, for example:
```js
import { Gauge } from "@clusterio/lib";
const { Counter } = require("@clusterio/lib");
const barMetric = new Gauge(
"clusterio_foo_frobber_bar_metric", "Bar instance level",
@@ -874,48 +530,23 @@ const barMetric = new Gauge(
);
// Somewhere in the instance plugin code
barMetric.labels(String(instance.id)).set(someValue);
barMetric.labels(String(this.instance.id)).set(someValue);
```
Metrics are automatically registered to the default registry, and this default registry is automatically polled by the controller on hosts.
This means that it's important that you place the definition of the metric at module level so that it's not created more than once over the lifetime of a host.
Since the metrics remember their values and would continue to be exported after an instance is shutdown, there's code at instance shutdown that removes all the values where the `instance_id` label matches the id of the instance shut down.
For statistics you need to update on collection there's a `metrics` hook on the controller, host and instance that is run before the metrics in the default registry are collected.
```js
import { Gauge } from "@clusterio/lib";
const frobnicationsMetric = new Gauge(
"clusterio_foo_frobber_frobnications", "Frobnications currently in progress"
);
// In the controller entrypoint
controller.hooks.metrics.attach(plugin.name, async () => {
frobnicationsMetric.set(frobnications.size);
});
```
A handler may also return the results of collecting collectors that are not in the default registry, and they are added to the response.
```js
import { CollectorRegistry, Counter } from "@clusterio/lib";
const fooRegistry = new CollectorRegistry();
const fooCounter = new Counter("clusterio_foo_frobber_foos", "Foos frobbed", { register: false });
fooRegistry.register(fooCounter);
controller.hooks.metrics.attach(plugin.name, async () => fooRegistry.collect());
```
For statistics you need to update on collection there's an `onMetrics` hook on both controller and instance plugins that is run before the metrics in the default registry are collected.
## Adding Custom Commands to clusterioctl
The ctl entrypoint for plugins allows you to extend clustectl with your own commands.
The control entrypoint for plugins allows you to extend clustectl with your own commands.
The creation of custom commands typically starts with defining a command tree for the plugin:
```js
import { Command, CommandTree } from "@clusterio/lib";
const { Command, CommandTree } = require("@clusterio/lib");
const fooFrobberCommands = new CommandTree({
name: "foo-frobber", description: "Foo Frobber Plugin commands"
});
@@ -924,30 +555,41 @@ const fooFrobberCommands = new CommandTree({
Then commands are added to the the plugin's command tree:
```js
import * as messages from "./messages.js";
const info = require("./info");
fooFrobberCommands.add(new Command({
definition: ["frobnicate <type>", "Do frobnications", (yargs) => {
yargs.positional("type", {
yargs.positional("level", {
describe: "type of frobnication", type: "string"
});
}],
handler: async function(args, ctl) {
await ctl.sendTo("controller", new messages.FrobnicateRequest(args.type));
handler: async function(args, control) {
await info.messages.frobnicate.send(control, {
instance_name: "Console",
content: args.message,
});
},
}));
```
For a command the `definition` is the arguments to pass to [yargs.command](http://yargs.js.org/docs/#api-reference-commandcmd-desc-builder-handler) (see also [yargs.positional](http://yargs.js.org/docs/#api-reference-positionalkey-opt) and [yargs.options](http://yargs.js.org/docs/#api-reference-optionskey-opt) for setting up positional and optional arguments to commands).
The `handler` is an async function that's invoked when the command is executed and it's passed the parsed command line arguments and a reference to the `Ctl` class of clusterioctl.
Note that messages sent from clusterioctl needs to have `"control"` as a part of `src` for it to be accepted by the controller, see [Defining Link Messages](#defining-link-messages) for how to define the messages that can be sent to the controller.
The `handler` is an async function that's invoked when the command is executed and it's passed the parsed command line arguments and a reference to the `Control` class of clusterioctl.
It's possible to optain a reference to the plugin class with `control.plugins.get(info.name)`.
To have the command tree become part of clusterioctl it needs to be added to the rootCommand tree in the `addCommands` hook of the ctl entrypoint:
Note that messages sent from clusterioctl needs to have `"control-controller"` as a part of the links array for it to be accepted by the controller, see [Defining Link Messages](#defining-link-messages) for how to define the messages that can be sent to the controller.
To have the command tree become part of clusterioctl it needs to be added to the rootCommand tree in `addCommands` callback of the Ctl plugin:
```js
export default async function(context) {
context.hooks.addCommands.attach(context.plugin.name, async (rootCommand) => {
const { BaseCtlPlugin } = require("@clusterio/ctl");
class CtlPlugin extends BaseCtlPlugin {
async addCommands(rootCommand) {
rootCommand.add(fooFrobberCommands);
});
}
}
module.exports = {
CtlPlugin,
}
```
+503
View File
@@ -0,0 +1,503 @@
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\\.",
}],
"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: "commonjs",
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/**/*.{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/"])]);
-1
View File
@@ -1 +0,0 @@
<?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

Before

Width:  |  Height:  |  Size: 6.5 KiB

+39 -31
View File
@@ -2,33 +2,33 @@
"name": "root",
"private": true,
"files": [],
"packageManager": "pnpm@12.4.1+sha512.2e81e399d73fe8390dab25e06aa788ab7a5908248d2f5a370f82b481147a6a7a367bf8048f9a6fdb6460f21a66f0542dedb8b94ca2c8723596741920b1656d4c",
"type": "module",
"packageManager": "pnpm@10.28.1+sha512.7d7dbbca9e99447b7c3bf7a73286afaaf6be99251eb9498baefa7d406892f67b879adb3a1d7e687fc4ccc1a388c7175fbaae567a26ab44d1067b54fcb0d6a316",
"scripts": {
"build": "tsc --build",
"watch": "tsc --build --watch",
"build-mod": "pnpm run -r build-mod --output-dir ../../dist",
"test": "pnpm run build && NODE_EXTRA_CA_CERTS=$(pwd)/test/file/tls/cert.pem mocha --enable-source-maps --check-leaks -R spec --recursive test \"plugins/*/test/**\" --exclude \"test/manual/**\"",
"test": "pnpm run build && mocha --enable-source-maps --check-leaks -R spec --recursive test \"plugins/*/test/**\" --exclude \"test/manual/**\"",
"silent-test": "SILENT_TEST=y pnpm test",
"fast-test": "FAST_TEST=y pnpm test",
"no-external-test": "NO_EXTERNAL_TEST=y pnpm test",
"cover": "c8 pnpm test",
"fast-cover": "FAST_TEST=y c8 pnpm test",
"ci-cover": "c8 --reporter=lcovonly pnpm test",
"lint": "oxlint packages plugins test",
"lint-fix": "oxlint --fix packages plugins test",
"clean": "node ./clean.js",
"cover": "nyc pnpm test",
"fast-cover": "FAST_TEST=y nyc pnpm test",
"ci-cover": "nyc --reporter=lcovonly pnpm run-script test",
"lint": "eslint packages plugins test",
"lint-fix": "eslint --fix packages plugins test",
"clean": "node ./clean.js fast",
"clean-tests": "node ./clean.js tests",
"clean-all": "node ./clean.js all",
"docs": "typedoc",
"bundle-dependencies": "bundle-dependencies"
},
"c8": {
"nyc": {
"exclude": [
"test/**",
"plugins/*/test/**",
"temp/**"
"plugins/*/test/**"
]
},
"engines": {
"node": ">=22"
"node": ">=20"
},
"workspaces": [
"packages/*",
@@ -37,22 +37,30 @@
"external_plugins/*/*"
],
"devDependencies": {
"@clusterio/controller": "workspace:^",
"@clusterio/ctl": "workspace:^",
"@clusterio/host": "workspace:^",
"@clusterio/lib": "workspace:^",
"@clusterio/web_ui": "workspace:^",
"@stylistic/eslint-plugin": "^5.10.0",
"@types/node": "catalog:",
"c8": "^12.0.0",
"express": "catalog:",
"form-data": "^4.0.6",
"jsonwebtoken": "catalog:",
"mocha": "^11.8.0",
"oxlint": "^1.88.0",
"typescript": "catalog:",
"yargs": "catalog:",
"yauzl": "catalog:",
"yazl": "catalog:"
"@clusterio/controller": "workspace:*",
"@clusterio/ctl": "workspace:*",
"@clusterio/host": "workspace:*",
"@clusterio/lib": "workspace:*",
"@clusterio/web_ui": "workspace:*",
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "^9.32.0",
"@stylistic/eslint-plugin": "^5.2.2",
"@swc/core": "^1.13.3",
"@typescript-eslint/eslint-plugin": "^8.38.0",
"@typescript-eslint/parser": "^8.38.0",
"eslint": "^9.32.0",
"eslint-plugin-node": "^11.1.0",
"express": "^5.1.0",
"form-data": "^4.0.4",
"fs-extra": "^11.3.0",
"globals": "^16.3.0",
"jsonwebtoken": "^9.0.2",
"jszip": "^3.10.1",
"mocha": "^11.7.1",
"nyc": "^17.1.0",
"phin": "^3.7.1",
"typedoc": "^0.28.9",
"typescript": "^5.9.2",
"yargs": "^18.0.0"
}
}
+1 -1
View File
@@ -1,2 +1,2 @@
vite.config.js
webpack.config.js
dist/browser
+6 -37
View File
@@ -15,12 +15,9 @@ Common options:
See the `plugin` command for managing this list.
* `--config <file>` JSON file to use for storing configuration for the controller.
Defaults to `config-controller.json` and is required for operation.
Defaults to `config-controller.json` and will be created if it does not exist.
See the `config` command for inspecting and modifying the configuration.
* `--create-config` If the config file is missing, create a new one and carry on instead of exiting with an error.
Alternative to the `config create` command.
### `plugin <command>`
@@ -28,28 +25,17 @@ Configure plugins available to be loaded by the controller.
The available plugins will be loaded unless they have been disabled in the configuration, see the config command for disabling plugins.
#### `plugin install <package>`
Install a plugin from npm and add it to the plugin list.
The package is passed to `npm install --save` so anything npm accepts works, including a version like `@clusterio/plugin-subspace_storage@2.0.0`.
This command refuses to run outside the directory Clusterio was installed in, as the plugin has to end up in that directory's node_modules to be found.
For example, installing the Subspace Storage plugin:
npx clusteriocontroller plugin install @clusterio/plugin-subspace_storage
Since the `plugin-list.json` is shared between controller, host and ctl you usually only need to do this once per machine.
#### `plugin add <path>`
Add plugin either by require path or relative/absolute path to the plugin directory.
A relative path must start with ./ or ../ (or .\ and ..\ on Windows) otherwise it will be assumed to be a require path for an installed package in node_modules.
This is mainly useful for plugins under development, packages published on npm are better installed with `plugin install`.
For example, adding a plugin checked out in the external_plugins directory:
For example, installing the Subspace Storage plugin:
npx clusteriocontroller plugin add ./external_plugins/my_plugin
npm install @clusterio/plugin-subspace_storage
npx clusteriocontroller plugin add @clusterio/plugin-subspace_storage
Since the `plugin-list.json` is shared between controller, host and ctl you usually only need to do this once per machine.
#### `plugin remove <name>`
@@ -77,13 +63,6 @@ Manage the controller configuration offline.
This should only be used when the controller is stopped, otherwise the config read might be out of date and config changes will be overwritten when the controller shuts down.
#### `config create`
Create a new config file with the default options inserted into if it doesn't already exist.
See also the `--create-config` option.
#### `config set <config-entry> [value]`
Set a config entry to the given value.
@@ -125,16 +104,6 @@ This can be used both in the web interface and as the `controller_token` to conn
Creates a clusterioctl config for the given user with url and token set up for connecting to the cluster.
### `copy-static [target]`
Copies the static web assets to the given target folder (which defaults to `./static/`).
Useful in case you want to host the assets on a CDN or separate web server and want provide the files directly instead of fetching them from the controller.
Note that mod pack exports will create additional static files that also have to be hosted, these are written to the `./static/` folder in the clusterio installation directory.
See also the `controller.public_url` config option for setting where static assets are loaded from.
### `run`
Runs the controller.
+94 -115
View File
@@ -15,9 +15,10 @@
*/
import path from "path";
import fs from "node:fs/promises";
import fs from "fs-extra";
import crypto from "crypto";
import setBlocking from "set-blocking";
import yargs from "yargs";
import util from "util";
import winston from "winston";
@@ -27,12 +28,11 @@ import jwt from "jsonwebtoken";
// homebrew modules
import * as lib from "@clusterio/lib";
import { ConsoleTransport, levels, logger } from "@clusterio/lib";
const { ConsoleTransport, levels, logger } = lib;
import Controller from "./src/Controller.js";
import UserManager from "./src/UserManager.js";
import UserRecord from "./src/UserRecord.js";
import packageConfig from "@clusterio/controller/package.json" with { type: "json" };
import Controller from "./src/Controller";
import UserManager from "./src/UserManager";
import { version } from "./package.json";
// globals
let controller: Controller;
@@ -101,9 +101,6 @@ void new lib.Gauge(
"clusterio_controller_websocket_active_connections",
"How many WebSocket connections are currently open to the controller",
{ callback: function(gauge:lib.Gauge) {
if (!controller) {
return;
}
gauge.set(controller.wsServer.activeConnectors.size);
}}
);
@@ -112,9 +109,6 @@ void new lib.Gauge(
"clusterio_controller_active_hosts",
"How many hosts are currently connected to the controller",
{ callback: function(gauge:lib.Gauge) {
if (!controller) {
return;
}
gauge.set(controller.wsServer.hostConnections.size);
}}
);
@@ -124,9 +118,6 @@ void new lib.Gauge(
"How many clients are currently connected to this controller",
{
labels: ["type"], callback: async function(gauge:lib.Gauge) {
if (!controller) {
return;
}
gauge.labels("host").set(controller.wsServer.hostConnections.size);
gauge.labels("control").set(controller.wsServer.controlConnections.size);
},
@@ -142,22 +133,13 @@ async function handleBootstrapCommand(
let subCommand = args._[1];
const databaseDirectory = controllerConfig.get("controller.database_directory");
await UserManager.attemptMigrateLegacyUsersFile(
path.join(databaseDirectory, "users.json"),
path.join(databaseDirectory, "roles.json"),
);
const roles = await lib.SubscribableDatastore.fromProvider(new lib.JsonIdDatastoreProvider(
const roles = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
path.join(databaseDirectory, "roles.json"),
lib.Role.fromJSON.bind(lib.Role),
));
).bootstrap());
const users = await lib.SubscribableDatastore.fromProvider(new lib.JsonIdDatastoreProvider(
path.join(databaseDirectory, "users.json"),
UserRecord.fromJSON.bind(UserRecord),
));
const userManager = new UserManager(users, roles, controllerConfig);
const userManager = new UserManager(controllerConfig, roles);
await userManager.load(path.join(databaseDirectory, "users.json"));
if (subCommand === "create-admin") {
await controllerConfigLock.acquire(); // Also needed to write to the database files
@@ -168,12 +150,15 @@ async function handleBootstrapCommand(
return;
}
const user = userManager.getOrCreateUser(args.name);
user.roleIds.add(lib.Role.DefaultAdminRoleId);
user.isAdmin = true;
user.saveRecord();
let admin = userManager.getByName(args.name);
if (!admin) {
admin = userManager.createUser(args.name);
}
await userManager.records.save();
let adminRole = lib.ensureDefaultAdminRole(roles);
admin.roleIds.add(adminRole.id);
admin.isAdmin = true;
await userManager.save(path.join(controllerConfig.get("controller.database_directory"), "users.json"));
await controllerConfigLock.release();
} else if (subCommand === "generate-user-token") {
@@ -190,8 +175,7 @@ async function handleBootstrapCommand(
// eslint-disable-next-line no-console
console.log(jwt.sign(
{ aud: "host", host: args.id },
// Auth secret is never null after startup
Buffer.from(controllerConfig.get("controller.auth_secret")!, "base64")
Buffer.from(controllerConfig.get("controller.auth_secret"), "base64")
));
} else if (subCommand === "create-ctl-config") {
@@ -201,59 +185,25 @@ async function handleBootstrapCommand(
process.exitCode = 1;
return;
}
let ctlConfig = new lib.CtlConfig("control");
let controlConfig = new lib.ControlConfig("control");
ctlConfig.set("ctl.controller_url", Controller.calculateControllerUrl(controllerConfig));
ctlConfig.set(
"ctl.controller_token",
controlConfig.set("control.controller_url", Controller.calculateControllerUrl(controllerConfig));
controlConfig.set(
"control.controller_token",
userManager.signUserToken(admin),
);
let content = JSON.stringify(ctlConfig, null, "\t");
let content = JSON.stringify(controlConfig, null, "\t");
if (args.output === "-") {
// eslint-disable-next-line no-console
console.log(content);
} else {
logger.info(`Writing ${args.output}`);
await lib.safeOutputFile(args.output, content, { mode: 0o600 });
await lib.safeOutputFile(args.output, content);
}
}
}
async function handleCopyStaticCommand(
args: any,
pluginInfos: lib.PluginNodeEnvInfo[],
) {
await fs.cp(
path.join(import.meta.dirname, "..", "web", "static"),
args.target,
{
recursive: true,
mode: fs.constants.COPYFILE_FICLONE,
},
);
logger.info("Copied main web interface files");
for (const pluginInfo of pluginInfos) {
try {
await fs.cp(
pluginInfo.webStaticPath,
args.target,
{
recursive: true,
mode: fs.constants.COPYFILE_FICLONE,
},
);
} catch (err: any) {
if (err.code === "ENOENT") {
logger.info(`Skipped web interface files for ${pluginInfo.name}: ${err.message}`);
} else {
logger.error(`Unexpected error copying web interface files for ${pluginInfo.name}:\n${err.stack}`);
process.exitCode = 1;
}
}
logger.info(`Copied web interface files for ${pluginInfo.name}`);
}
}
export interface ControllerArgs {
[x: string]: unknown;
@@ -277,7 +227,7 @@ interface InitializeParameters {
async function initialize(): Promise<InitializeParameters> {
// argument parsing
const args = await yargs(process.argv.slice(2))
const args = await yargs
.scriptName("controller")
.usage("$0 <command> [options]")
.option("log-level", {
@@ -299,12 +249,6 @@ async function initialize(): Promise<InitializeParameters> {
default: "config-controller.json",
type: "string",
})
.option("create-config", {
nargs: 0,
describe: "Create config file if it does not exist",
default: false,
type: "boolean",
})
.option("plugin-list", {
nargs: 1,
describe: "File containing list of plugins available with their install path",
@@ -328,18 +272,11 @@ async function initialize(): Promise<InitializeParameters> {
.command("create-ctl-config <name>", "Create clusterioctl config for the given user", yargs => {
yargs.option("output", {
describe: "Path to output config (- for stdout)", type: "string",
nargs: 1, default: "config-ctl.json",
nargs: 1, default: "config-control.json",
});
})
.demandCommand(1, "You need to specify a command to run");
})
.command("copy-static [target]", "Copy static Web UI files to a folder", yargs => {
yargs.positional("target", {
describe: "Target directory to copy files to",
type: "string",
default: "static",
});
})
.command("run", "Run controller", yargs => {
yargs.option("can-restart", {
type: "boolean", nargs: 0, default: false,
@@ -355,7 +292,7 @@ async function initialize(): Promise<InitializeParameters> {
})
.demandCommand(1, "You need to specify a command to run")
.strict()
.parse()
.argv
;
// Combined log stream of the whole cluster.
@@ -392,7 +329,7 @@ async function initialize(): Promise<InitializeParameters> {
let command = args._[0];
let shouldRun = false;
if (command === "run") {
logger.info(`Starting Clusterio controller ${packageConfig.version}`);
logger.info(`Starting Clusterio controller ${version}`);
if (args.recovery) {
logger.warn("Controller recovery mode enabled. Some features will be disabled.");
}
@@ -400,8 +337,7 @@ async function initialize(): Promise<InitializeParameters> {
}
logger.info(`Loading available plugins from ${args.pluginList}`);
const skippedNpmPlugins = new Map<string, string>();
let pluginList = await lib.loadPluginList(args.pluginList, true, skippedNpmPlugins);
let pluginList = await lib.loadPluginList(args.pluginList);
// If the command is plugin management we don't try to load plugins
if (command === "plugin") {
@@ -416,21 +352,33 @@ async function initialize(): Promise<InitializeParameters> {
}
logger.info("Loading Plugin info");
const pluginInfos = await lib.loadPluginInfos(pluginList, skippedNpmPlugins);
const pluginInfos = await lib.loadPluginInfos(pluginList);
lib.registerPluginMessages(pluginInfos);
lib.registerPluginPermissions(pluginInfos);
lib.addPluginConfigFields(pluginInfos);
if (!args.recovery) {
await lib.loadPluginMigrations(pluginInfos);
const controllerConfigPath = args.config;
const controllerConfigLockPath = `${controllerConfigPath}.lock`;
if (args.bypassLockFile) {
await fs.unlink(controllerConfigLockPath).catch(() => {}); // ignore error, file might not exist
}
const [controllerConfig, controllerConfigLock] = await lib.loadConfigFromArgs(
args,
lib.ControllerConfig,
"controller",
);
let controllerConfig;
const controllerConfigLock = new lib.LockFile(controllerConfigLockPath);
logger.info(`Loading config from ${controllerConfigPath}`);
try {
controllerConfig = await lib.ControllerConfig.fromFile("controller", controllerConfigPath);
controllerConfig.set("controller.version", packageConfig.version); // Allows tracking last loaded version
} catch (err: any) {
if (err.code === "ENOENT") {
logger.info("Config not found, initializing new config");
controllerConfig = new lib.ControllerConfig("controller", undefined, controllerConfigPath);
} else {
throw new lib.StartupError(`Failed to load ${controllerConfigPath}: ${err.stack ?? err.message ?? err}`);
}
}
controllerConfig.set("controller.version", version); // Allows tracking last loaded version
if (!controllerConfig.get("controller.auth_secret")) {
logger.info("Generating new controller authentication secret");
@@ -444,8 +392,6 @@ async function initialize(): Promise<InitializeParameters> {
await lib.handleConfigCommand(args, controllerConfig, controllerConfigLock);
} else if (command === "bootstrap") {
await handleBootstrapCommand(args, controllerConfig, controllerConfigLock);
} else if (command === "copy-static") {
await handleCopyStaticCommand(args, pluginInfos);
} else if (shouldRun) {
await controllerConfigLock.acquire(); // Hold the lock until process exit
}
@@ -475,25 +421,50 @@ async function startup() {
return;
}
controller = await Controller.fromConfig(
controllerConfig,
pluginInfos,
controller = new Controller(
clusterLogger,
pluginInfos,
controllerConfig,
Boolean(args.canRestart),
Boolean(args.recovery),
...await Controller.bootstrap(controllerConfig)
);
// Refuse to start if there are no users loaded
if (args.checkUserCount && controller.users.records.size === 0) {
if (args.checkUserCount && controller.userManager.users.size === 0) {
logger.fatal(
"Cannot start controller, no users loaded.\n" +
"Try `npx clusteriocontroller bootstrap create-admin <username>`");
process.exit(1);
}
// Handle interrupts
process.on("SIGINT", lib.createShutdownGuard(logger, "interrupt", controller.stop.bind(controller)));
process.on("SIGTERM", lib.createShutdownGuard(logger, "termination", controller.stop.bind(controller)));
let secondSigint = false;
process.on("SIGINT", () => {
if (secondSigint) {
setBlocking(true);
logger.fatal("Caught second interrupt, terminating immediately");
process.exit(1);
}
secondSigint = true;
logger.info("Caught interrupt signal, shutting down");
controller.stop();
});
let secondSigterm = false;
process.on("SIGTERM", () => {
if (secondSigterm) {
setBlocking(true);
logger.fatal("Caught second termination, terminating immediately");
process.exit(1);
}
secondSigterm = true;
logger.info("Caught termination signal, shutting down");
controller.stop();
});
process.on("SIGHUP", () => {
logger.info("Terminal closed, shutting down");
controller.stop();
@@ -503,8 +474,16 @@ async function startup() {
}
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 => {
if (err instanceof lib.StartupError || err.code === "InstallationError") {
if (err instanceof lib.StartupError) {
logger.fatal(`
+-------------------------------+
| Unable to to start controller |
@@ -532,6 +511,6 @@ ${err.stack}`
});
}
if (import.meta.main) {
if (module === require.main) {
bootstrap();
}
+12 -18
View File
@@ -1,21 +1,15 @@
import { bootstrap } from "./controller.js";
export { default as Controller } from "./src/Controller.js";
export { default as ControllerRouter } from "./src/ControllerRouter.js";
export { default as ControlConnection } from "./src/ControlConnection.js";
export { default as HostConnection } from "./src/HostConnection.js";
export { default as InstanceRecord } from "./src/InstanceRecord.js";
export { default as InstanceManager } from "./src/InstanceManager.js";
export { default as HostRecord } from "./src/HostRecord.js";
export { BaseControllerPlugin, ControllerHooks, type ControllerPluginContext } from "./src/BaseControllerPlugin.js";
export { default as UserManager } from "./src/UserManager.js";
export { default as UserRecord } from "./src/UserRecord.js";
export { default as User } from "./src/User.js";
export { default as WsServer } from "./src/WsServer.js";
export {
default as PlayerProxyServer, type PlayerProxyRouteRequest, type PlayerProxyRoute,
} from "./src/PlayerProxyServer.js";
export * as proxyProtocol from "./src/proxy_protocol.js";
import { bootstrap } from "./controller";
export { default as Controller } from "./src/Controller";
export { default as ControllerUser } from "./src/ControllerUser";
export { default as ControllerRouter } from "./src/ControllerRouter";
export { default as ControlConnection } from "./src/ControlConnection";
export { default as HostConnection } from "./src/HostConnection";
export { default as InstanceInfo } from "./src/InstanceInfo";
export { default as HostInfo } from "./src/HostInfo";
export { default as BaseControllerPlugin } from "./src/BaseControllerPlugin";
export { default as UserManager } from "./src/UserManager";
export { default as WsServer } from "./src/WsServer";
if (import.meta.main) {
if (module === require.main) {
bootstrap();
}
+34 -42
View File
@@ -1,71 +1,63 @@
{
"name": "@clusterio/controller",
"version": "2.0.3",
"description": "Implementation of Clusterio controller",
"homepage": "https://github.com/clusterio/clusterio#readme",
"author": "Hornwitser <github@hornwitser.no>",
"author": "Hornwitser",
"license": "MIT",
"keywords": [
"clusterio",
"factorio"
],
"repository": {
"type": "git",
"url": "https://github.com/clusterio/clusterio"
},
"bugs": {
"url": "https://github.com/clusterio/clusterio/issues"
},
"publishConfig": {
"access": "public"
},
"type": "module",
"version": "2.0.0-alpha.22b",
"main": "dist/node/index.js",
"scripts": {
"prepare": "tsc --build && vite build",
"prepare": "tsc --build && webpack-cli --mode production",
"test": "echo \"Error: no test specified\" && exit 1"
},
"bin": {
"clusteriocontroller": "./dist/node/controller.js"
},
"keywords": [
"clusterio",
"factorio"
],
"engines": {
"node": ">=22"
"node": ">=20"
},
"dependencies": {
"@clusterio/lib": "workspace:^",
"@clusterio/lib": "workspace:*",
"@sinclair/typebox": "catalog:",
"busboy": "^1.6.0",
"compression": "^1.8.2",
"express": "catalog:",
"finalhandler": "^2.1.1",
"set-blocking": "catalog:",
"jsonwebtoken": "catalog:",
"semver": "catalog:",
"express": "catalog:",
"fs-extra": "catalog:",
"ws": "catalog:",
"jszip": "catalog:",
"winston": "catalog:",
"winston-daily-rotate-file": "catalog:",
"ws": "catalog:",
"yargs": "catalog:"
"busboy": "^1.6.0",
"compression": "^1.8.0",
"finalhandler": "^1.3.1",
"yargs": "^17.7.2"
},
"devDependencies": {
"@clusterio/web_ui": "workspace:^",
"@types/busboy": "^1.5.5",
"@types/compression": "^1.8.1",
"@types/express": "catalog:",
"@types/finalhandler": "catalog:",
"@clusterio/web_ui": "workspace:*",
"@types/jsonwebtoken": "catalog:",
"@types/set-blocking": "catalog:",
"@types/finalhandler": "catalog:",
"@types/express": "catalog:",
"@types/fs-extra": "catalog:",
"@types/node": "catalog:",
"@types/semver": "catalog:",
"@types/ws": "catalog:",
"@types/yargs": "catalog:",
"antd": "catalog:",
"@types/yargs": "^17.0.33",
"@types/compression": "^1.7.2",
"@types/busboy": "^1.5.0",
"@swc/core": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"react-router": "catalog:",
"react-router-dom": "catalog:",
"typescript": "catalog:",
"vite": "catalog:"
"webpack": "catalog:",
"webpack-cli": "catalog:",
"webpack-merge": "catalog:",
"webpack-dev-middleware": "^6.1.1"
},
"publishConfig": {
"access": "public"
}
}
+12 -24
View File
@@ -1,12 +1,12 @@
import path from "path";
import fs from "node:fs/promises";
import fs from "fs-extra";
import type Controller from "./Controller.js";
import type WsServerConnector from "./WsServerConnector.js";
import type Controller from "./Controller";
import type WsServerConnector from "./WsServerConnector";
import * as lib from "@clusterio/lib";
import { logger } from "@clusterio/lib";
import * as routes from "./routes.js";
const { logger } = lib;
import * as routes from "./routes";
/**
@@ -32,8 +32,6 @@ export default class BaseConnection extends lib.Link {
this.handle(lib.ModPackGetRequest, this.handleModPackGetRequest.bind(this));
this.handle(lib.ModPackGetDefaultRequest, this.handleModPackGetDefaultRequest.bind(this));
this.handle(lib.ModDownloadRequest, this.handleModDownloadRequest.bind(this));
this.handle(lib.FactorioVersionsRequest, this.handleFactorioVersionsRequest.bind(this));
this.handle(lib.LatestReleasesRequest, this.handleLatestReleasesRequest.bind(this));
this.handle(lib.SubscriptionRequest, this.handleSubscriptionRequest.bind(this));
this.connector.on("close", () => {
@@ -63,7 +61,7 @@ export default class BaseConnection extends lib.Link {
let { id } = request;
let modPack = this._controller.modPacks.get(id);
if (!modPack) {
throw new lib.NotFoundError(`Mod pack with ID ${id} does not exist`);
throw new lib.RequestError(`Mod pack with ID ${id} does not exist`);
}
return modPack;
}
@@ -71,23 +69,23 @@ export default class BaseConnection extends lib.Link {
async handleModPackGetDefaultRequest(): Promise<lib.ModPack> {
let id = this._controller.config.get("controller.default_mod_pack_id");
if (id === null) {
throw new lib.ConflictError("Default mod pack not set on controller");
throw new lib.RequestError("Default mod pack not set on controller");
}
let modPack = this._controller.modPacks.get(id);
if (!modPack) {
throw new lib.NotFoundError(`Default mod pack configured (${id}) does not exist`);
throw new lib.RequestError(`Default mod pack configured (${id}) does not exist`);
}
return modPack;
}
getMod(mod: { name: string, version: lib.SourceVersion, sha1?: string }) {
getMod(mod: { name: string, version: lib.FullVersion, sha1?: string }) {
let filename = lib.ModInfo.filename(mod.name, mod.version);
let modInfo = this._controller.modStore.files.get(filename);
if (!modInfo) {
throw new lib.NotFoundError(`Mod ${filename} does not exist on controller`);
throw new lib.RequestError(`Mod ${filename} does not exist on controller`);
}
if (mod.sha1 && mod.sha1 !== modInfo.sha1) {
throw new lib.ConflictError(`Mod ${filename} checksum does not match controller's checksum`);
throw new lib.RequestError(`Mod ${filename} checksum does not match controller's checksum`);
}
return modInfo;
}
@@ -95,12 +93,10 @@ export default class BaseConnection extends lib.Link {
async handleModDownloadRequest(request: lib.ModDownloadRequest) {
let mod = this.getMod(request);
let modPath = path.join(this._controller.config.get("controller.mods_directory"), mod.filename);
// Open source file before creating the stream in case it throws.
let source = (await fs.open(modPath)).createReadStream();
let stream = await routes.createProxyStream(this._controller.app);
stream.filename = mod.filename;
stream.source = source;
stream.source = fs.createReadStream(modPath);
stream.mime = "application/zip";
stream.size = String(mod.size);
@@ -110,12 +106,4 @@ export default class BaseConnection extends lib.Link {
async handleSubscriptionRequest(request: lib.SubscriptionRequest, src: lib.Address, dst: lib.Address) {
return await this._controller.subscriptions.handleRequest(this, request, src, dst);
}
async handleFactorioVersionsRequest(request: lib.FactorioVersionsRequest) {
return await this._controller.factorioVersions.get(request.maxAgeMs);
}
async handleLatestReleasesRequest(request: lib.LatestReleasesRequest) {
return await this._controller.latestReleases.get(request.maxAgeMs);
}
}
+50 -146
View File
@@ -1,37 +1,45 @@
import type Controller from "./Controller.js";
import type InstanceRecord from "./InstanceRecord.js";
import type ControlConnection from "./ControlConnection.js";
import type HostConnection from "./HostConnection.js";
import type { PlayerProxyRouteRequest } from "./PlayerProxyServer.js";
import * as lib from "@clusterio/lib";
export type ControllerPluginContext = lib.PluginLoadContext<{
controller: Controller;
metrics: any;
}>;
import type {
CollectorResult, Event, InstanceStatus, Logger,
ModPack, ModInfo, PlayerEvent, PluginNodeEnvInfo,
} from "@clusterio/lib";
import type Controller from "./Controller";
import type InstanceInfo from "./InstanceInfo";
import type ControlConnection from "./ControlConnection";
import type HostConnection from "./HostConnection";
/**
* Collection of controller plugin hooks
* Base class for controller plugins
*
* Controller plugins are subclasses of this class which get instantiated by
* the controller on startup when the plugin is enabled in the config.
* To be discovered the class must be exported under the name `ControllerPlugin`
* in the module specified by the `controllerEntrypoint` in the plugin's
* `plugin` export.
*/
export class ControllerHooks extends lib.AsyncHookCollection {
constructor(logger: lib.Logger) {
super(logger);
this.save = this.newHook();
this.metrics = this.newHook();
this.shutdown = this.newHook();
this.instanceStatusChanged = this.newHook();
this.instanceConfigFieldChanged = this.newHook();
this.controllerConfigFieldChanged = this.newHook();
this.controlConnectionEvent = this.newHook();
this.hostConnectionEvent = this.newHook();
this.prepareHostDisconnect = this.newHook();
this.modPacksUpdated = this.newHook();
this.modsUpdated = this.newHook();
this.rolesUpdated = this.newHook();
this.playerEvent = this.newHook();
this.playerProxyRoute = this.newHook();
export default class BaseControllerPlugin {
/**
* Logger for this plugin
*
* Instance of winston Logger for sending log messages from this
* plugin. Supported methods and their corresponding log levels are
* `error`, `warn`, `audit`, `info` and `verbose`.
*/
logger: Logger;
constructor(
public info: PluginNodeEnvInfo,
public controller: Controller,
public metrics: any,
logger: Logger
) {
this.logger = logger.child({ plugin: this.info.name }) as unknown as Logger;
}
/**
* Called immediately after the class is instantiated
*/
async init() { }
/**
* Called when the controller saves data in memory to disk
*
@@ -43,7 +51,7 @@ export class ControllerHooks extends lib.AsyncHookCollection {
* BaseControllerPlugin.onShutdown} have been invoked and all links have
* been disconnected.
*/
readonly save: lib.AsyncHook<[]>;
async onSaveData() { }
/**
* Called when the status of an instance changes
@@ -83,7 +91,7 @@ export class ControllerHooks extends lib.AsyncHookCollection {
* The instance that changed.
* @param prev - the previous status of the instance.
*/
readonly instanceStatusChanged: lib.AsyncHook<[instance: InstanceRecord, prev: lib.InstanceStatus | undefined]>;
async onInstanceStatusChanged(instance: InstanceInfo, prev?: InstanceStatus) { }
/**
* Called when the value of a controller config field changed.
@@ -95,7 +103,7 @@ export class ControllerHooks extends lib.AsyncHookCollection {
* @param curr - The current value of the field.
* @param prev - The previous value of the field.
*/
readonly controllerConfigFieldChanged: lib.AsyncHook<[field: string, curr: unknown, prev: unknown]>;
async onControllerConfigFieldChanged(field: string, curr: unknown, prev: unknown) { }
/**
* Called when the value of an instance config field changed.
@@ -109,8 +117,7 @@ export class ControllerHooks extends lib.AsyncHookCollection {
* @param curr - The current value of the field.
* @param prev - The previous value of the field.
*/
// eslint-disable-next-line @stylistic/max-len
readonly instanceConfigFieldChanged: lib.AsyncHook<[instance: InstanceRecord, field: string, curr: unknown, prev: unknown]>;
async onInstanceConfigFieldChanged(instance: InstanceInfo, field: string, curr: unknown, prev: unknown) { }
/**
* Called before collecting Prometheus metrics
@@ -127,12 +134,12 @@ export class ControllerHooks extends lib.AsyncHookCollection {
*
* @returns an async iterator of prometheus metric results or undefined.
*/
readonly metrics: lib.AsyncHook<[], AsyncIterable<lib.CollectorResult> | void>;
async onMetrics(): Promise<void | AsyncIterable<CollectorResult>> { }
/**
* Called when the controller is shutting down
*/
readonly shutdown: lib.AsyncHook<[]>;
async onShutdown() { }
/**
* Called when an event on a host connection happens
@@ -167,8 +174,7 @@ export class ControllerHooks extends lib.AsyncHookCollection {
* The connection the event occured on.
* @param event - one of connect, drop, resume and close
*/
// eslint-disable-next-line @stylistic/max-len
readonly hostConnectionEvent: lib.AsyncHook<[connection: HostConnection, event: "connect" | "drop" | "resume" | "close"]>;
onHostConnectionEvent(connection: HostConnection, event: "connect" | "drop" | "resume" | "close") { }
/**
* Called when an avent on a control connection happens
@@ -204,8 +210,7 @@ export class ControllerHooks extends lib.AsyncHookCollection {
* The connection the event occured on.
* @param event - one of connect, drop, resume, and close.
*/
// eslint-disable-next-line @stylistic/max-len
readonly controlConnectionEvent: lib.AsyncHook<[connection: ControlConnection, event: "connect" | "drop" | "resume" | "close"]>;
onControlConnectionEvent(connection: ControlConnection, event: "connect" | "drop" | "resume" | "close") { }
/**
* Called when a host is preparing to disconnect from the controller
@@ -219,7 +224,7 @@ export class ControllerHooks extends lib.AsyncHookCollection {
* @param connection -
* The connection to the host preparing to disconnect.
*/
readonly prepareHostDisconnect: lib.AsyncHook<[connection: HostConnection]>;
async onPrepareHostDisconnect(connection: HostConnection) { }
/**
* Called when one or more mod packs are updated
@@ -232,7 +237,7 @@ export class ControllerHooks extends lib.AsyncHookCollection {
*
* @param modPacks - Mod packs that updated.
*/
readonly modPacksUpdated: lib.AsyncHook<[modPacks: lib.ModPack[]]>;
async onModPacksUpdated(modPacks: ModPack[]) { }
/**
* Called when one or more mod stored on the controller are updated
@@ -244,18 +249,7 @@ export class ControllerHooks extends lib.AsyncHookCollection {
*
* @param mods - Mods that updated.
*/
readonly modsUpdated: lib.AsyncHook<[mods: lib.ModInfo[]]>;
/**
* Called when one or more roles stored on the controller are updated
*
* Invoked when one or more roles have been added, updated or deleted.
*
* If a role has been deleted its `.isDeleted` property will be true.
*
* @param roles - Roles that updated.
*/
readonly rolesUpdated: lib.AsyncHook<[roles: lib.Role[]]>;
async onModsUpdated(mods: ModInfo[]) { }
/**
* Called when a player joins or leaves an instance
@@ -267,97 +261,7 @@ export class ControllerHooks extends lib.AsyncHookCollection {
* The instance it occured on.
* @param event - Information about the event.
*/
readonly playerEvent: lib.AsyncHook<[instance: InstanceRecord, event: lib.PlayerEvent]>;
/**
* Called to pick the instance a player connecting through the player proxy goes to
*
* Invoked when a Factorio client has sent its name to the player proxy
* enabled with controller.player_proxy_port. Handlers return the ID of
* the instance to send the player to, or undefined to leave the choice
* to other plugins and the controller. If the instance is stopped the
* proxy starts it, which can take longer than the client is willing to
* wait.
*
* Factorio clients connect twice when joining a server they don't have a
* key for, so a handler must give the same answer for both attempts.
*
* @param request - Name, version and connection details of the player.
* @returns ID of instance to route the player to.
*/
readonly playerProxyRoute: lib.AsyncHook<[request: PlayerProxyRouteRequest], number | undefined>;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
export interface BaseControllerPlugin {
onSaveData?(): Promise<void>;
onMetrics?(): Promise<void | AsyncIterable<lib.CollectorResult>>;
onShutdown?(): Promise<void>;
onInstanceStatusChanged?(instance: InstanceRecord, prev?: lib.InstanceStatus): Promise<void>;
onControllerConfigFieldChanged?(field: string, curr: unknown, prev: unknown): Promise<void>;
onInstanceConfigFieldChanged?(instance: InstanceRecord, field: string, curr: unknown, prev: unknown): Promise<void>;
onHostConnectionEvent?(connection: HostConnection, event: "connect" | "drop" | "resume" | "close"): void;
onControlConnectionEvent?(connection: ControlConnection, event: "connect" | "drop" | "resume" | "close"): void;
onPrepareHostDisconnect?(connection: HostConnection): Promise<void>;
onModPacksUpdated?(modPacks: lib.ModPack[]): Promise<void>;
onModsUpdated?(mods: lib.ModInfo[]): Promise<void>;
onRolesUpdated?(roles: lib.Role[]): Promise<void>;
onPlayerEvent?(instance: InstanceRecord, event: lib.PlayerEvent): Promise<void>;
}
/**
* Base class for controller plugins
*
* Controller plugins are subclasses of this class which get instantiated by
* the controller on startup when the plugin is enabled in the config.
* To be discovered the class must be exported under the name `ControllerPlugin`
* in the module specified by the `controllerEntrypoint` in the plugin's
* `plugin` export.
*/
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
export class BaseControllerPlugin {
constructor(
public info: lib.PluginNodeEnvInfo,
public controller: Controller,
public metrics: any,
public logger: lib.Logger,
) {
const attach = <Args extends unknown[], Return>(
hook: lib.AsyncHook<Args, Return>,
fn?: lib.HookHandler<Args, Return>,
) => {
if (fn) {
hook.attach(info.name, fn.bind(this));
}
};
attach(controller.hooks.save, this.onSaveData);
attach(controller.hooks.instanceStatusChanged, this.onInstanceStatusChanged);
attach(controller.hooks.controllerConfigFieldChanged, this.onControllerConfigFieldChanged);
attach(controller.hooks.instanceConfigFieldChanged, this.onInstanceConfigFieldChanged);
attach(controller.hooks.metrics, this.onMetrics);
attach(controller.hooks.shutdown, this.onShutdown);
attach(controller.hooks.hostConnectionEvent, this.onHostConnectionEvent);
attach(controller.hooks.controlConnectionEvent, this.onControlConnectionEvent);
attach(controller.hooks.prepareHostDisconnect, this.onPrepareHostDisconnect);
attach(controller.hooks.modPacksUpdated, this.onModPacksUpdated);
attach(controller.hooks.modsUpdated, this.onModsUpdated);
attach(controller.hooks.rolesUpdated, this.onRolesUpdated);
attach(controller.hooks.playerEvent, this.onPlayerEvent);
}
static fromContext(context: ControllerPluginContext) {
return new this(context.plugin, context.controller, context.metrics, context.logger);
}
detachHooks() {
this.controller.hooks.detachAll(this.info.name);
}
/**
* Called immediately after the class is instantiated
*/
async init() {}
async onPlayerEvent(instance: InstanceInfo, event: PlayerEvent) { }
/**
* Broadcast event to all connected hosts
@@ -368,7 +272,7 @@ export class BaseControllerPlugin {
*
* @param event - Event to send
*/
broadcastEventToHosts<T>(event: lib.Event<T>) {
broadcastEventToHosts<T>(event: Event<T>) {
for (let hostConnection of this.controller.wsServer.hostConnections.values()) {
if (
!hostConnection.connector.closing
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+6 -6
View File
@@ -1,10 +1,10 @@
import type Controller from "./Controller.js";
import type BaseConnection from "./BaseConnection.js";
import type Controller from "./Controller";
import type BaseConnection from "./BaseConnection";
import * as lib from "@clusterio/lib";
import { logger } from "@clusterio/lib";
import HostConnection from "./HostConnection.js";
import ControlConnection from "./ControlConnection.js";
import HostConnection from "./HostConnection";
import ControlConnection from "./ControlConnection";
const { logger } = lib;
export default class ControllerRouter {
constructor(
@@ -74,7 +74,7 @@ export default class ControllerRouter {
return false;
}
origin.connector.sendResponseError(
new lib.ResponseError(msg || "Unroutable destination", "UnavailableError"), message.src
new lib.ResponseError(msg || "Unroutable destination"), message.src
);
return true;
}
+96
View File
@@ -0,0 +1,96 @@
import { Static, Type } from "@sinclair/typebox";
import {
IControllerUser, PermissionError, PlayerStats,
Role, SubscribableDatastore, User, permissions,
} from "@clusterio/lib";
export default class ControllerUser extends User implements IControllerUser {
constructor(
private _controllerRoles: SubscribableDatastore<Role>,
/** Unix time in seconds the user token must be issued after to be valid. */
public tokenValidAfter = 0,
...args: ConstructorParameters<typeof User>
) {
super(...args);
}
static jsonSchema = Type.Object({
...User.jsonSchema.properties,
token_valid_after: Type.Optional(Type.Number()),
});
static fromJSON(json: Static<typeof this.jsonSchema>, controllerRoles: SubscribableDatastore<Role>) {
const roleIds = new Set<number>(json.roles ?? []);
const instanceStats = new Map(
(json.instance_stats ?? []).map(
([id, stats]) => [id, PlayerStats.fromJSON(stats)]
)
);
const playerStats = User._calculatePlayerStats(instanceStats);
return new this(
controllerRoles,
json.token_valid_after,
json.name,
roleIds,
new Set(json.instances),
json.is_admin,
json.is_banned,
json.is_whitelisted,
json.ban_reason,
json.updated_at_ms,
json.is_deleted,
playerStats,
instanceStats,
);
}
toJSON(controller?: boolean): Static<typeof ControllerUser.jsonSchema> {
if (!controller) {
return super.toJSON();
}
const json: Static<typeof ControllerUser.jsonSchema> = super.toJSON();
if (this.tokenValidAfter) {
json.token_valid_after = this.tokenValidAfter;
}
return json;
}
get roles(): ReadonlySet<Readonly<Role>> {
return new Set([...this.roleIds].map(
id => this._controllerRoles.get(id)
).filter(
(r): r is Role => Boolean(r)
));
}
/**
* Invalidate current tokens for the user
*
* Sets the tokenValidAfter property to the current time, which causes
* all currently issued tokens for the user to become invalid.
*/
invalidateToken() {
this.tokenValidAfter = Math.floor(Date.now() / 1000);
}
checkPermission(permission: string) {
if (!permissions.has(permission)) {
throw new Error(`permission ${permission} does not exist`);
}
for (let roleId of this.roleIds) {
let role = this._controllerRoles.get(roleId);
if (!role) {
continue;
}
if (role.permissions.has("core.admin") || role.permissions.has(permission)) {
return;
}
}
throw new PermissionError("Permission denied");
}
}
+71 -117
View File
@@ -1,17 +1,14 @@
import type Controller from "./Controller.js";
import type WsServerConnector from "./WsServerConnector.js";
import type Controller from "./Controller";
import type WsServerConnector from "./WsServerConnector";
import * as lib from "@clusterio/lib";
import { logger } from "@clusterio/lib";
const { logger } = lib;
import BaseConnection from "./BaseConnection.js";
import InstanceRecord from "./InstanceRecord.js";
import HostRecord from "./HostRecord.js";
import BaseConnection from "./BaseConnection";
import InstanceInfo from "./InstanceInfo";
import HostInfo from "./HostInfo";
// How long log message warnings are suppressed for after one is logged.
const logMessageWarnIntervalMs = 5 * 60 * 1000;
/**
* Represents the connection to a host
*
@@ -19,10 +16,8 @@ const logMessageWarnIntervalMs = 5 * 60 * 1000;
* @alias module:controller/src/HostConnection
*/
export default class HostConnection extends BaseConnection {
private info: HostRecord;
private info: HostInfo;
plugins: Map<string, string>;
// Shared by all log message warnings, a misbehaving host could log in a loop.
private _lastLogMessageWarnMs = Number.NEGATIVE_INFINITY;
constructor(
registerData: lib.RegisterHostData,
@@ -35,7 +30,7 @@ export default class HostConnection extends BaseConnection {
this.plugins = new Map(Object.entries(registerData.plugins));
const previousHostInfo = this._controller.hosts.get(registerData.id);
this.info = new HostRecord(
this.info = new HostInfo(
registerData.id,
previousHostInfo?.name ?? "",
registerData.version,
@@ -52,12 +47,17 @@ export default class HostConnection extends BaseConnection {
this._checkPluginVersions();
for (let event of ["connect", "drop", "resume", "close"] as const) {
this.connector.on(event, () => this._controller.hooks.hostConnectionEvent.invoke(this, event));
this.connector.on(event, () => {
for (let plugin of this._controller.plugins.values()) {
plugin.onHostConnectionEvent(this, event);
}
});
}
this.connector.on("close", () => {
// Update status to unknown for instances on this host.
const instances: InstanceRecord[] = [];
const instances: InstanceInfo[] = [];
for (let instance of this._controller.instances.valuesMutable()) {
if (instance.config.get("instance.assigned_host") !== this.id) {
continue;
@@ -66,9 +66,9 @@ export default class HostConnection extends BaseConnection {
instances.push(instance);
let prev = instance.status;
instance.status = "unknown";
this._controller.hooks.instanceStatusChanged.invoke(instance, prev);
lib.invokeHook(this._controller.plugins, "onInstanceStatusChanged", instance, prev);
}
this._controller.instances.records.setMany(instances);
this._controller.instances.setMany(instances);
this.info.connected = false;
this._controller.hosts.set(this.info);
@@ -143,7 +143,7 @@ export default class HostConnection extends BaseConnection {
}
async prepareDisconnect() {
await this._controller.hooks.prepareHostDisconnect.invoke(this);
await lib.invokeHook(this._controller.plugins, "onPrepareHostDisconnect", this);
return await super.prepareDisconnect();
}
@@ -207,16 +207,11 @@ export default class HostConnection extends BaseConnection {
let prev = instance.status;
instance.status = request.status;
// If the instance has transitioned to running then updated the started at time
if (request.status === "running" && prev === "starting") {
instance.startedAtMs = Date.now();
}
instance.gamePort = request.gamePort;
instance.factorioVersion = request.factorioVersion;
this._controller.instances.records.set(instance);
this._controller.instances.set(instance);
logger.verbose(`Instance ${instance.config.get("instance.name")} State: ${instance.status}`);
await this._controller.hooks.instanceStatusChanged.invoke(instance, prev);
await lib.invokeHook(this._controller.plugins, "onInstanceStatusChanged", instance, prev);
}
async handleInstancesUpdateRequest(request: lib.InstancesUpdateRequest) {
@@ -230,7 +225,7 @@ export default class HostConnection extends BaseConnection {
}
// Assign instances the host has but controller does not
const instanceUpdates: InstanceRecord[] = [];
const instanceUpdates: InstanceInfo[] = [];
for (let instanceData of request.instances) {
const instanceConfig = new lib.InstanceConfig("controller");
instanceConfig.update(instanceData.config, false, "host");
@@ -251,41 +246,41 @@ export default class HostConnection extends BaseConnection {
controllerInstance.status = instanceData.status;
controllerInstance.gamePort = instanceData.gamePort;
controllerInstance.factorioVersion = instanceData.factorioVersion;
if (instanceData.status === "running" && prev === "starting") {
controllerInstance.startedAtMs = Date.now();
}
instanceUpdates.push(controllerInstance);
logger.verbose(`Instance ${instanceConfig.get("instance.name")} State: ${instanceData.status}`);
await this._controller.hooks.instanceStatusChanged.invoke(controllerInstance, prev);
await lib.invokeHook(
this._controller.plugins, "onInstanceStatusChanged", controllerInstance, prev
);
}
continue;
}
instanceConfig.set("instance.assigned_host", this.id);
const newInstance = await this._controller.instances.createInstance(instanceConfig, true);
newInstance.factorioVersion = instanceData.factorioVersion;
newInstance.gamePort = instanceData.gamePort;
newInstance.status = instanceData.status;
let newInstance = new InstanceInfo(
instanceConfig,
instanceData.status,
instanceData.gamePort,
instanceData.factorioVersion,
Date.now(),
);
instanceUpdates.push(newInstance);
this._controller.addInstanceHooks(newInstance);
await this.send(
new lib.InstanceAssignInternalRequest(
instanceConfig.get("instance.id"), instanceConfig.toRemote("host")
)
);
await lib.invokeHook(this._controller.plugins, "onInstanceStatusChanged", newInstance);
}
// We suppress the changes made by createInstance so we must manually make them here
this._controller.instances.records.setMany(instanceUpdates);
await Promise.all(instanceUpdates.flatMap(newInstance => [
this._controller.hooks.instanceStatusChanged.invoke(newInstance, undefined),
this.send(
new lib.InstanceAssignInternalRequest(
newInstance.config.get("instance.id"), newInstance.config.toRemote("host")
)
),
]));
this._controller.instances.setMany(instanceUpdates);
// Push lists to make sure they are in sync.
let adminlist: Set<string> = new Set();
let banlist: Map<string, string> = new Map();
let whitelist: Set<string> = new Set();
for (let user of this._controller.users.values()) {
for (let user of this._controller.userManager.users.values()) {
if (user.isAdmin) {
adminlist.add(user.id);
}
@@ -300,37 +295,7 @@ export default class HostConnection extends BaseConnection {
await this.send(new lib.SyncUserListsEvent(adminlist, banlist, whitelist));
}
// A host may only report on instances assigned to it. Otherwise any host
// could speak for an instance on a different host. See
// handleInstanceStatusChangedEvent.
private _assignedInstance(instanceId: number) {
const instance = this._controller.instances.get(instanceId);
return instance?.config.get("instance.assigned_host") === this.id ? instance : undefined;
}
// Host connections live for days, so the suppression expires instead of
// latching for the life of the connection.
private _warnLogMessage(message: string) {
const now = Date.now();
if (now - this._lastLogMessageWarnMs < logMessageWarnIntervalMs) {
return;
}
this._lastLogMessageWarnMs = now;
logger.warn(message);
}
async handleInstanceSaveDetailsUpdatesEvent(event: lib.InstanceSaveDetailsUpdatesEvent) {
// Otherwise any host could inject save records for, or wipe the save
// list of, an instance on a different host.
if (event.instanceId !== undefined && !this._assignedInstance(event.instanceId)) {
logger.warn(`Got bogus save updates for instance id ${event.instanceId}`);
return;
}
if (event.updates.some(save => !this._assignedInstance(save.instanceId))) {
logger.warn(`Got bogus save updates from host id ${this.id}`);
return;
}
const updates: lib.SaveDetails[] = [];
const deletes: lib.SaveDetails[] = [];
for (const save of event.updates) {
@@ -355,33 +320,8 @@ export default class HostConnection extends BaseConnection {
}
async handleLogMessageEvent(event: lib.LogMessageEvent) {
// Fields beyond level and message survive schema validation, so the
// instance a log line claims to come from is whatever the host sent.
const info: typeof event.info & { instance_id?: number, instance_name?: string } = { ...event.info };
if (info.instance_id !== undefined) {
const instance = this._assignedInstance(info.instance_id);
if (!instance) {
// Demote to a host level line instead of dropping it. The host did
// log this, it just does not get to pin it on someone else's
// instance, and a host racing a reassignment still gets heard.
this._warnLogMessage(`Got bogus log message for instance id ${info.instance_id}`);
delete info.instance_id;
delete info.instance_name;
} else {
// The name the host sent may be stale or made up, ours is authoritative.
const name = instance.config.get("instance.name");
if (info.instance_name !== name) {
this._warnLogMessage(
`Got log message for instance id ${info.instance_id} named ` +
`${info.instance_name} which is named ${name} on the controller`
);
info.instance_name = name;
}
}
}
this._controller.clusterLogger.log({
...info,
...event.info,
host_id: this.id,
host_name: this.info.name,
});
@@ -389,21 +329,24 @@ export default class HostConnection extends BaseConnection {
async handleInstancePlayerUpdateEvent(event: lib.InstancePlayerUpdateEvent, src: lib.Address) {
let instanceId = src.id;
const user = this._controller.users.getOrCreateUser(event.name);
let user = this._controller.userManager.getByName(event.name);
if (!user) {
user = this._controller.userManager.createUser(event.name);
}
if (event.type === "join") {
user.notifyJoin(instanceId);
this._controller.userManager.notifyJoin(user, instanceId);
} else if (event.type === "leave") {
user.notifyLeave(instanceId);
this._controller.userManager.notifyLeave(user, instanceId);
}
user.instanceStats.set(instanceId, event.stats);
user.recalculatePlayerStats();
user.saveRecord();
this._controller.usersUpdated([user]);
let instance = this._controller.instances.get(instanceId)!;
await this._controller.hooks.playerEvent.invoke(instance, {
await lib.invokeHook(this._controller.plugins, "onPlayerEvent", instance, {
type: event.type,
name: event.name,
reason: event.reason,
@@ -416,11 +359,15 @@ export default class HostConnection extends BaseConnection {
return;
}
const user = this._controller.users.getOrCreateUser(event.name);
user.set("isAdmin", event.admin);
let user = this._controller.userManager.getByName(event.name);
if (!user) {
user = this._controller.userManager.createUser(event.name);
}
user.isAdmin = event.admin;
this._controller.usersUpdated([user]);
const instance = this._controller.instances.get(src.id)!;
await this._controller.hooks.playerEvent.invoke(instance, {
await lib.invokeHook(this._controller.plugins, "onPlayerEvent", instance, {
type: event.admin ? "promote" : "demote" as lib.PlayerEvent["type"],
name: event.name,
stats: user.instanceStats.get(src.id)!,
@@ -432,17 +379,20 @@ export default class HostConnection extends BaseConnection {
return;
}
const user = this._controller.users.getOrCreateUser(event.name);
let user = this._controller.userManager.getByName(event.name);
if (!user) {
user = this._controller.userManager.createUser(event.name);
}
user.isBanned = event.banned;
user.banReason = event.reason;
user.saveRecord();
this._controller.usersUpdated([user]);
const instance = this._controller.instances.get(src.id)!;
await this._controller.hooks.playerEvent.invoke(instance, {
await lib.invokeHook(this._controller.plugins, "onPlayerEvent", instance, {
type: event.banned ? "ban" : "unban" as lib.PlayerEvent["type"],
name: event.name,
stats: user.instanceStats.get(src.id)!,
reason: event.reason,
resaon: event.reason,
});
}
@@ -451,11 +401,15 @@ export default class HostConnection extends BaseConnection {
return;
}
const user = this._controller.users.getOrCreateUser(event.name);
user.set("isWhitelisted", event.whitelisted);
let user = this._controller.userManager.getByName(event.name);
if (!user) {
user = this._controller.userManager.createUser(event.name);
}
user.isWhitelisted = event.whitelisted;
this._controller.usersUpdated([user]);
const instance = this._controller.instances.get(src.id)!;
await this._controller.hooks.playerEvent.invoke(instance, {
await lib.invokeHook(this._controller.plugins, "onPlayerEvent", instance, {
type: event.whitelisted ? "whitelisted" : "unwhitelisted" as lib.PlayerEvent["type"],
name: event.name,
stats: user.instanceStats.get(src.id)!,
@@ -1,8 +1,10 @@
import { Type, Static } from "@sinclair/typebox";
import { HostDetails } from "@clusterio/lib";
/** Underlying data class for hosts on the controller */
export default class HostRecord {
/**
* Info about a host known to the controller.
*/
export default class HostInfo {
constructor(
/** Id of this host */
public id: number,
@@ -31,12 +33,12 @@ export default class HostRecord {
"name": Type.String(),
"version": Type.String(),
"plugins": Type.Record(Type.String(), Type.String()),
"connected": Type.Optional(Type.Boolean()),
"remote_address": Type.Optional(Type.String()),
"public_address": Type.Optional(Type.String()),
"token_valid_after": Type.Optional(Type.Number()),
"updated_at_ms": Type.Optional(Type.Number()),
"is_deleted": Type.Optional(Type.Boolean()),
"connected": Type.Boolean(),
"remote_address": Type.String(),
"public_address": Type.String(),
"token_valid_after": Type.Number(),
"updated_at_ms": Type.Number(),
"is_deleted": Type.Boolean(),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
@@ -54,50 +56,19 @@ export default class HostRecord {
);
}
toJSON() {
const json = {
toJSON(): Static<typeof HostInfo.jsonSchema> {
return {
id: this.id,
name: this.name,
version: this.version,
plugins: Object.fromEntries(this.plugins),
} as Static<typeof HostRecord.jsonSchema>;
if (this.connected !== false) {
json.connected = this.connected;
}
if (this.remoteAddress !== "") {
json.remote_address = this.remoteAddress;
}
if (this.publicAddress !== "") {
json.public_address = this.publicAddress;
}
if (this.tokenValidAfter !== 0) {
json.token_valid_after = this.tokenValidAfter;
}
if (this.updatedAtMs !== 0) {
json.updated_at_ms = this.updatedAtMs;
}
if (this.isDeleted !== false) {
json.is_deleted = this.isDeleted;
}
return json;
}
static fromHostDetails(details: HostDetails, plugins: Map<string, string>) {
return new HostRecord(
details.id,
details.name,
details.version,
plugins,
details.connected,
details.remoteAddress,
details.publicAddress,
details.tokenValidAfter,
details.updatedAtMs,
details.isDeleted,
);
connected: this.connected,
remote_address: this.remoteAddress,
public_address: this.publicAddress,
token_valid_after: this.tokenValidAfter,
updated_at_ms: this.updatedAtMs,
is_deleted: this.isDeleted,
};
}
toHostDetails() {
@@ -114,3 +85,4 @@ export default class HostRecord {
);
}
}
@@ -1,22 +1,52 @@
import * as lib from "@clusterio/lib";
import { Static, Type } from "@sinclair/typebox";
/** Underlying data class for instances on the controller */
export default class InstanceRecord {
/**
* Runtime status of an instance on the controller
* @alias module:controller/src/InstanceInfo
*/
export default class InstanceInfo {
constructor(
public config: lib.InstanceConfig,
public status: lib.InstanceStatus,
public gamePort?: number,
public factorioVersion?: lib.TargetVersion,
public startedAtMs = 0,
public updatedAtMs = 0,
) {
this.config = config;
this.status = status;
}
get id(): number {
return this.config.get("instance.id");
static jsonSchema = Type.Object({
"config": lib.InstanceConfig.jsonSchema,
"status": lib.InstanceStatus,
"gamePort": Type.Optional(Type.Number()),
"updatedAtMs": Type.Optional(Type.Number()),
"factorioVersion": Type.Optional(lib.TargetVersionSchema),
});
static fromJSON(json: Static<typeof this.jsonSchema>, location: lib.ConfigLocation) {
return new this(
lib.InstanceConfig.fromJSON(json.config, location),
json.status,
json.gamePort,
json.factorioVersion,
json.updatedAtMs,
);
}
toInstanceDetails() {
return new lib.InstanceDetails(
this.config.get("instance.name"),
this.id,
this.config.get("instance.assigned_host") ?? undefined,
this.gamePort,
this.status,
this.factorioVersion,
this.updatedAtMs,
this.config.get("instance.exclude_from_start_all"),
);
}
get isDeleted() {
@@ -30,70 +60,8 @@ export default class InstanceRecord {
this.status = "deleted";
}
static jsonSchema = Type.Object({
"config": lib.InstanceConfig.jsonSchema,
"status": lib.InstanceStatus,
"gamePort": Type.Optional(Type.Number()),
"startedAtMs": Type.Optional(Type.Number()),
"updatedAtMs": Type.Optional(Type.Number()),
"factorioVersion": Type.Optional(lib.TargetVersionSchema),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(
lib.InstanceConfig.fromJSON(json.config, "controller"),
json.status,
json.gamePort,
json.factorioVersion,
json.startedAtMs,
json.updatedAtMs,
);
}
toJSON(): Static<typeof InstanceRecord.jsonSchema> {
const json: any = {
config: this.config.toJSON(),
status: this.status,
};
if (this.gamePort !== undefined) {
json.gamePort = this.gamePort;
}
if (this.factorioVersion !== undefined) {
json.factorioVersion = this.factorioVersion;
}
if (this.startedAtMs !== 0) {
json.startedAtMs = this.startedAtMs;
}
if (this.updatedAtMs !== 0) {
json.updatedAtMs = this.updatedAtMs;
}
return json;
}
toInstanceDetails() {
return new lib.InstanceDetails(
this.config.get("instance.name"),
this.id,
this.config.get("instance.assigned_host") ?? undefined,
this.gamePort,
this.status,
this.factorioVersion,
this.startedAtMs,
this.updatedAtMs,
this.config.get("instance.exclude_from_start_all"),
);
}
static fromInstanceDetails(details: lib.InstanceDetails, config: lib.InstanceConfig) {
return new InstanceRecord(
config,
details.status,
details.gamePort,
details.factorioVersion,
details.startedAtMs,
details.updatedAtMs,
);
/** Shorthand for `instance.config.get("instance.id")` */
get id():number {
return this.config.get("instance.id");
}
}
-270
View File
@@ -1,270 +0,0 @@
import * as lib from "@clusterio/lib";
import type Controller from "./Controller.js";
import type HostConnection from "./HostConnection.js";
import InstanceRecord from "./InstanceRecord.js";
export default class InstanceManager {
private static readonly DefaultFactorioSettings = {
tags: ["clusterio"],
max_players: 0,
visibility: { public: true, lan: true },
game_password: "",
require_user_verification: true,
max_upload_in_kilobytes_per_second: 0,
max_upload_slots: 5,
ignore_player_limit_for_returning_players: false,
allow_commands: "admins-only",
autosave_interval: 10,
autosave_slots: 5,
afk_autokick_interval: 0,
auto_pause: false,
only_admins_can_pause_the_game: true,
autosave_only_on_server: true,
non_blocking_saving: false,
} as const;
constructor(
public readonly records: lib.SubscribableDatastore<InstanceRecord>,
private readonly _controller: Controller,
) {
for (const instance of records.values()) {
this.addInstanceHooks(instance);
}
}
/* ------------------------------------------------------------------------- */
/* Basic Accessors */
/* ------------------------------------------------------------------------- */
has(id: InstanceRecord["id"]): boolean {
return this.records.has(id);
}
getMutable(id: InstanceRecord["id"]): InstanceRecord | undefined {
return this.records.get(id);
}
get(id: InstanceRecord["id"]): Readonly<InstanceRecord> | undefined {
return this.getMutable(id);
}
valuesMutable(): IterableIterator<InstanceRecord> {
return this.records.values();
}
values(): IterableIterator<Readonly<InstanceRecord>> {
return this.valuesMutable();
}
/**
* Get instance by ID for a request.
*
* Convenience wrapper around {@link getMutable} that throws
* if the instance does not exist.
*
* @param instanceId - ID of instance to get.
* @returns The instance record.
* @throws {module:lib.NotFoundError} if the instance does not exist.
*/
getForRequest(instanceId: number): InstanceRecord {
const instance = this.records.get(instanceId);
if (!instance) {
throw new lib.NotFoundError(`Instance with ID ${instanceId} does not exist`);
}
return instance;
}
/* ------------------------------------------------------------------------- */
/* Internal Helpers */
/* ------------------------------------------------------------------------- */
private async _notifyStatusChanged(
instance: InstanceRecord,
prev?: lib.InstanceStatus,
): Promise<void> {
await this._controller.hooks.instanceStatusChanged.invoke(instance, prev);
}
private async _notifyConfigFieldChanged(
instance: InstanceRecord,
field: string,
curr: unknown,
prev: unknown,
): Promise<void> {
await this._controller.hooks.instanceConfigFieldChanged.invoke(instance, field, curr, prev);
}
/**
* Attach change listeners to an instance record.
*
* Hooks into configuration field changes and ensures the datastore
* is updated and plugins are notified when a config field changes.
*
* @param instance - The instance record to attach hooks to.
* @internal
*/
addInstanceHooks(instance: InstanceRecord): void {
instance.config.on("fieldChanged", (field: string, curr: unknown, prev: unknown) => {
this.records.set(instance);
this._notifyConfigFieldChanged(instance, field, curr, prev);
});
}
/* ------------------------------------------------------------------------- */
/* Lifecycle */
/* ------------------------------------------------------------------------- */
/**
* Create a new instance.
*
* @example
* const config = new lib.InstanceConfig("controller", { "instance.name": "My instance" });
* const instance = await controller.instances.createInstance(config);
*
* @param instanceConfig - Config to base the newly created instance on.
* @param _suppressChanges - When true, side effects are suppressed, caller takes ownership.
* @returns The created instance record.
* @throws {module:lib.ConflictError} if an instance with the same ID already exists.
*/
async createInstance(
instanceConfig: lib.InstanceConfig,
_suppressChanges?: boolean,
): Promise<InstanceRecord> {
const instanceId = instanceConfig.get("instance.id");
if (this.records.has(instanceId)) {
throw new lib.ConflictError(`Instance with ID ${instanceId} already exists`);
}
const controllerName = this._controller.config.get("controller.name");
instanceConfig.set("factorio.settings", {
name: `${controllerName} - ${instanceConfig.get("instance.name")}`,
description: `Clusterio instance for ${controllerName}`,
...InstanceManager.DefaultFactorioSettings,
...instanceConfig.get("factorio.settings"),
});
const instance = new InstanceRecord(instanceConfig, "unassigned");
this.addInstanceHooks(instance);
if (!_suppressChanges) {
this.records.set(instance);
await this._notifyStatusChanged(instance);
}
return instance;
}
/**
* Change the assigned host of an instance.
*
* Unassigns the instance from the currently assigned host (if connected)
* and assigns it to the provided host.
*
* This is the only supported way of modifying the
* `instance.assigned_host` configuration entry.
*
* Note:
* - This does NOT transfer any files or saves between hosts.
* - If the instance is already assigned to the specified host, no action is taken.
*
* @param instanceId - ID of instance to assign.
* @param hostId - ID of host to assign the instance to. If undefined,
* the instance will be unassigned.
*
* @throws {module:lib.NotFoundError} if the instance does not exist.
* @throws {module:lib.UnavailableError} if the target host is not connected.
*/
async assignInstance(
instanceId: number,
hostId: number | null,
): Promise<void> {
const instance = this.getForRequest(instanceId);
const hostConnections = this._controller.wsServer.hostConnections;
const currentAssignedHost = instance.config.get("instance.assigned_host");
if (currentAssignedHost === hostId) {
return;
}
let newHostConnection: HostConnection | undefined;
if (hostId !== null) {
newHostConnection = hostConnections.get(hostId);
if (!newHostConnection) {
throw new lib.UnavailableError("Target host is not connected to the controller");
}
}
if (currentAssignedHost !== null) {
const oldHostConnection = hostConnections.get(currentAssignedHost);
if (oldHostConnection && !oldHostConnection.connector.closing) {
await oldHostConnection.send(
new lib.InstanceUnassignInternalRequest(instanceId),
);
}
}
this._controller.clearSavesOfInstance(instanceId);
instance.config.set("instance.assigned_host", hostId);
if (newHostConnection) {
try {
await newHostConnection.send(
new lib.InstanceAssignInternalRequest(instanceId, instance.config.toRemote("host")),
);
} catch (err) {
// Leave unassigned so the assign can be retried
instance.config.set("instance.assigned_host", null);
instance.status = "unassigned";
this.records.set(instance);
throw err;
}
} else {
instance.status = "unassigned";
}
this.records.set(instance);
}
/**
* Unassign an instance from its current host.
*
* Convenience wrapper around {@link assignInstance} that removes
* the assigned host.
*
* @param instanceId - ID of instance to unassign.
*/
async unassignInstance(instanceId: number): Promise<void> {
return this.assignInstance(instanceId, null);
}
/**
* Delete an instance permanently.
*
* Sends a delete request to the assigned host (if any),
* removes the instance from the datastore,
* clears associated saves and user statistics.
*
* @param instanceId - ID of instance to delete.
*
* @throws {module:lib.NotFoundError} if the instance does not exist.
*/
async deleteInstance(instanceId: number): Promise<void> {
const instance = this.getForRequest(instanceId);
const hostId = instance.config.get("instance.assigned_host");
if (hostId !== null) {
await this._controller.sendTo(
{ hostId },
new lib.InstanceDeleteInternalRequest(instanceId),
);
}
const prevStatus = instance.status;
this.records.delete(instance);
this._controller.clearSavesOfInstance(instanceId);
this._controller.users.clearStatsOfInstance(instanceId);
await this._notifyStatusChanged(instance, prevStatus);
}
}
@@ -1,711 +0,0 @@
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);
}
}
-146
View File
@@ -1,146 +0,0 @@
import {
IUser, Role, SubscribableDatastore,
PermissionError, PermissionName, permissions,
} from "@clusterio/lib";
import UserRecord from "./UserRecord.js";
import { Static } from "@sinclair/typebox";
/**
* Represents a user as viewed from the controller.
*
* All methods are safe to use when readonly.
* Only use mutable when needing to change multiple values.
*/
export default class User extends UserRecord implements IUser {
constructor(
private _controllerUsers: SubscribableDatastore<UserRecord>,
private _controllerRoles: SubscribableDatastore<Role>,
...args: ConstructorParameters<typeof UserRecord>
) {
super(...args);
}
static fromJSON(
json: Static<typeof this.jsonSchema>,
_controllerUsers: SubscribableDatastore<UserRecord>,
_controllerRoles: SubscribableDatastore<Role>,
) {
return this.fromUserRecord(
UserRecord.fromJSON(json),
_controllerUsers,
_controllerRoles,
);
}
static fromUserRecord(
userRecord: UserRecord,
_controllerUsers: SubscribableDatastore<UserRecord>,
_controllerRoles: SubscribableDatastore<Role>,
): User {
return new this(
_controllerUsers,
_controllerRoles,
userRecord.tokenValidAfter,
userRecord.name,
userRecord.roleIds,
userRecord.instances,
userRecord.isAdmin,
userRecord.isBanned,
userRecord.isWhitelisted,
userRecord.banReason,
userRecord.updatedAtMs,
userRecord.isDeleted,
userRecord.instanceStats,
);
}
/** Roles this user has */
get roles(): ReadonlySet<Readonly<Role>> {
return new Set([...this.roleIds].map(
id => this._controllerRoles.get(id)
).filter(
(r): r is Role => Boolean(r)
));
}
/** Save the underlying user record */
saveRecord() {
this._controllerUsers.set(this);
}
/** Set a property on the underlying record, then save it. */
set<K extends keyof this>(key: K, value: this[K]) {
this[key] = value;
this.saveRecord();
}
/**
* Invalidate current tokens for the user
*
* Sets the tokenValidAfter property to the current time, which causes
* all currently issued tokens for the user to become invalid.
*/
invalidateToken() {
this.tokenValidAfter = Math.floor(Date.now() / 1000);
this.saveRecord();
}
/** Add a role to this user */
addRole(roleId: number) {
const role = this._controllerRoles.get(roleId);
if (!role) {
throw new Error(`role ${roleId} does not exist`);
}
this.roleIds.add(roleId);
this.saveRecord();
}
/** Remove a role from user */
removeRole(roleId: number) {
if (!this.roleIds.delete(roleId)) {
return false;
}
this.saveRecord();
return true;
}
/** Set a user as being online on a specific instance */
notifyJoin(instanceId: number) {
this.instances.add(instanceId);
this.saveRecord();
}
/** Set a user as being offline on a specific instance */
notifyLeave(instanceId: number) {
this.instances.delete(instanceId);
this.saveRecord();
}
/** Clear the stats of a player on a specific instance */
clearInstanceStats(instanceId: number) {
this.instances.delete(instanceId);
this.instanceStats.delete(instanceId);
this.recalculatePlayerStats();
this.saveRecord();
}
checkPermission(permission: PermissionName) {
if (!permissions.has(permission)) {
throw new Error(`permission ${permission} does not exist`);
}
for (const roleId of this.roleIds) {
const role = this._controllerRoles.get(roleId);
if (!role) {
continue;
}
if (role.permissions.has("core.admin") || role.permissions.has(permission)) {
return;
}
}
throw new PermissionError("Permission denied");
}
}
+77 -110
View File
@@ -1,47 +1,67 @@
import fs from "node:fs/promises";
import fs from "fs-extra";
import jwt from "jsonwebtoken";
import * as lib from "@clusterio/lib";
import User from "./User.js";
import UserRecord from "./UserRecord.js";
import ControllerUser from "./ControllerUser";
/**
* Access layer for users on the controller.
*
* In performance critical paths the raw records can be accessed directly.
* Otherwise this class will wrap data in a view class with a set and save method.
*
* Manages users and roles
* @alias module:controller/src/UserManager
*/
export default class UserManager {
users: Map<ControllerUser["id"], ControllerUser> = new Map();
dirty = false;
/**
* Set of users currently online in the cluster.
*/
onlineUsers = new Set<ControllerUser>();
constructor(
public records: lib.SubscribableDatastore<UserRecord>,
private _controllerRoles: lib.SubscribableDatastore<lib.Role>,
private _controllerConfig: lib.ControllerConfig,
private _config: lib.ControllerConfig,
private _roles: lib.SubscribableDatastore<lib.Role>,
) {
}
/**
* Prior to 2.0.0-alpha.22 users and roles were stored in a single file.
* This migration separates them so they can be loaded by datastore.
*
* @param usersFilePath The src and dst file path for the users json file
* @param rolesFilePath The dst for the roles json file
*/
static async attemptMigrateLegacyUsersFile(usersFilePath: string, rolesFilePath: string) {
getByName(name: string) {
return this.users.get(name.toLowerCase());
}
async load(filePath: string): Promise<void> {
try {
const combinedJson = JSON.parse(await fs.readFile(usersFilePath, { encoding: "utf8" }));
if (!("roles" in combinedJson)) {
return; // Files are already split, no action required
let content = JSON.parse(await fs.readFile(filePath, { encoding: "utf8" }));
if ("roles" in content) { // Split file format after 2.0.0-alpha.22
this._roles.setMany(content.roles.map((serializedRole: any) => lib.Role.fromJSON(serializedRole)));
await this._roles.save(); // Force a save to prevent data loss
content = content.users;
}
if (!("users" in combinedJson)) {
throw new Error("Legacy users json does not contain users property");
let duplicates = 0;
for (const serializedUser of content) {
const user = ControllerUser.fromJSON(serializedUser, this._roles);
const existingUser = this.users.get(user.id);
if (existingUser) {
// Required migration to all lowercase ids in alpha 19
duplicates += 1;
// We assume the user with the lower online time is the duplicate
if (user.playerStats.onlineTimeMs <= existingUser.playerStats.onlineTimeMs) {
existingUser.merge(user);
continue; // Skip users.set
}
user.merge(existingUser);
}
this.users.set(user.id, user);
}
if (duplicates) {
const backupPath = `${filePath}.${Date.now()}.bak`;
lib.logger.warn(
`A total of ${duplicates} users were merged, a backup was written to: ${backupPath}`
);
await lib.safeOutputFile(backupPath, JSON.stringify(content, null, "\t"));
}
// We intentionally write the roles first because if it fails then the old user file will still contain them
await lib.safeOutputFile(rolesFilePath, JSON.stringify(combinedJson.roles));
await lib.safeOutputFile(usersFilePath, JSON.stringify(combinedJson.users));
} catch (err: any) {
if (err.code !== "ENOENT") {
throw err;
@@ -49,119 +69,66 @@ export default class UserManager {
}
}
/* ------------------------------------------------------------------------- */
/* Basic Accessors */
/* ------------------------------------------------------------------------- */
has(id: UserRecord["id"]) {
return this.records.has(id);
}
getMutable(id: UserRecord["id"]) {
const userRecord = this.records.getMutable(id);
if (!userRecord) {
return undefined;
}
return User.fromUserRecord(
userRecord,
this.records,
this._controllerRoles,
async save(filePath: string): Promise<void> {
this.dirty = false;
await lib.safeOutputFile(filePath,
JSON.stringify([...this.users.values()].map(user => user.toJSON(true)), null, "\t")
);
}
get(id: UserRecord["id"]): Readonly<User> | undefined {
return this.getMutable(id);
}
hasName(name: UserRecord["name"]) {
return this.has(name.toLowerCase());
}
getByNameMutable(name: UserRecord["name"]) {
return this.getMutable(name.toLowerCase());
}
getByName(name: UserRecord["name"]) {
return this.get(name.toLowerCase());
}
* valuesMutable(): IterableIterator<User> {
const roles = this._controllerRoles;
const users = this.records;
for (const record of users.values()) {
yield User.fromUserRecord(record, users, roles);
}
}
values(): IterableIterator<Readonly<User>> {
return this.valuesMutable();
}
/* ------------------------------------------------------------------------- */
/* Lifecycle */
/* ------------------------------------------------------------------------- */
/**
* Creates a new user and add it to the user database.
* @param name - Name of the user to create.
* @returns The created user.
*/
createUser(name: string): User {
createUser(name: string): ControllerUser {
if (this.getByName(name)) {
throw new Error(`User '${name}' already exists`);
}
let roles = new Set<number>();
let defaultRoleId = this._controllerConfig.get("controller.default_role_id");
if (defaultRoleId !== null && this._controllerRoles.has(defaultRoleId)) {
let defaultRoleId = this._config.get("controller.default_role_id");
if (defaultRoleId !== null && this._roles.has(defaultRoleId)) {
roles.add(defaultRoleId);
}
// Reject tokens issued before the user existed, e.g. for a previous user by the same name
const tokenValidAfter = Math.floor(Date.now() / 1000);
const user = new User(this.records, this._controllerRoles, tokenValidAfter, name, roles);
this.records.set(user);
let user = new ControllerUser(this._roles, 0, name, roles);
this.users.set(user.id, user);
this.dirty = true;
return user;
}
/** Get or create a user with a given name. */
getOrCreateUser(name: string): User {
return this.getByNameMutable(name) ?? this.createUser(name);
}
/**
* Deletes an existing user
*
* @param user - user to delete
*/
deleteUser(user: UserRecord) {
return this.records.delete(user);
}
/* ------------------------------------------------------------------------- */
/* Helpers */
/* ------------------------------------------------------------------------- */
/**
* Sign access token for the given user
*
* @param user - user to sign token for
* @returns JWT access token for the user.
*/
signUserToken(user: UserRecord): string {
signUserToken(user: ControllerUser): string {
return jwt.sign(
{ aud: "user", user: user.id },
// auth secret is never null after startup
Buffer.from(this._controllerConfig.get("controller.auth_secret")!, "base64")
Buffer.from(this._config.get("controller.auth_secret"), "base64")
);
}
/** Clear all player stats for specific instance */
clearStatsOfInstance(instanceId: number) {
for (const user of this.values()) {
user.clearInstanceStats(instanceId);
notifyJoin(user: ControllerUser, instance_id: number) {
user.instances.add(instance_id);
this.onlineUsers.add(user);
}
notifyLeave(user: ControllerUser, instance_id: number) {
user.instances.delete(instance_id);
if (!user.instances.size) {
this.onlineUsers.delete(user);
}
}
clearStatsOfInstance(instanceId: number) {
for (const user of this.users.values()) {
this.notifyLeave(user, instanceId);
user.instanceStats.delete(instanceId);
user.recalculatePlayerStats();
}
this.dirty = true;
}
}
-102
View File
@@ -1,102 +0,0 @@
import { UserDetails } from "@clusterio/lib";
import { Type, Static } from "@sinclair/typebox";
/** Underlying data class for users on the controller */
export default class UserRecord extends UserDetails {
constructor(
/** Unix time in seconds the user token must be issued after to be valid. */
public tokenValidAfter = 0,
...args: ConstructorParameters<typeof UserDetails>
) {
super(...args);
}
static jsonSchema = Type.Object({
token_valid_after: Type.Optional(Type.Number()),
...UserDetails.jsonSchema.properties,
});
// _a and _b are a ts workaround for allowing User to have extra arguments
static fromJSON(json: Static<typeof this.jsonSchema>, _a?: any, _b?: any) {
return this.fromUserDetails(
UserDetails.fromJSON(json),
json.token_valid_after,
);
}
toJSON() {
const json = super.toJSON() as Static<typeof UserRecord.jsonSchema>;
if (this.tokenValidAfter > 0) {
json.token_valid_after = this.tokenValidAfter;
}
return json;
}
static fromUserDetails(
userDetails: UserDetails,
tokenValidAfter = 0,
) {
return new this(
tokenValidAfter,
userDetails.name,
userDetails.roleIds,
userDetails.instances,
userDetails.isAdmin,
userDetails.isBanned,
userDetails.isWhitelisted,
userDetails.banReason,
userDetails.updatedAtMs,
userDetails.isDeleted,
userDetails.instanceStats,
);
}
toUserDetails() {
return new UserDetails(
this.name,
this.roleIds,
this.instances,
this.isAdmin,
this.isBanned,
this.isWhitelisted,
this.banReason,
this.updatedAtMs,
this.isDeleted,
this.instanceStats,
);
}
/**
* Merge another user into this one, must have matching ids, user is not deleted
* @param otherUser - User who's details are merged from
*/
merge(otherUser: UserDetails) {
if (this.id !== otherUser.id) {
throw new Error("Cannot merge users with different ids");
}
// Merge properties
this.roleIds = new Set([...this.roleIds, ...otherUser.roleIds]);
this.instances = new Set([...this.instances, ...otherUser.instances]);
this.isAdmin = this.isAdmin && otherUser.isAdmin; // More secure to use && rather ||
this.isBanned = this.isBanned || otherUser.isBanned;
this.isWhitelisted = this.isWhitelisted || otherUser.isWhitelisted;
this.banReason = this.updatedAtMs > otherUser.updatedAtMs ? this.banReason : otherUser.banReason;
this.updatedAtMs = Date.now();
// Merge instance stats
const thisInstanceStats = this.instanceStats;
for (const [instanceId, instanceStats] of otherUser.instanceStats.entries()) {
if (thisInstanceStats.has(instanceId)) {
thisInstanceStats.get(instanceId)!.merge(instanceStats);
} else {
thisInstanceStats.set(instanceId, instanceStats);
}
}
// Player stats must be recalculated after modifying instance stats
this.recalculatePlayerStats();
}
}
+38 -29
View File
@@ -1,18 +1,18 @@
import type { IncomingMessage } from "http";
import type { Duplex } from "stream";
import type Controller from "./Controller.js";
import type Controller from "./Controller";
import jwt from "jsonwebtoken";
import WebSocket, { WebSocketServer } from "ws";
import * as lib from "@clusterio/lib";
import { logger } from "@clusterio/lib";
const { logger } = lib;
import ControlConnection from "./ControlConnection.js";
import HostConnection from "./HostConnection.js";
import WsServerConnector from "./WsServerConnector.js";
import ControlConnection from "./ControlConnection";
import HostConnection from "./HostConnection";
import WsServerConnector from "./WsServerConnector";
import packageConfig from "@clusterio/controller/package.json" with { type: "json" };
import { version as packageVersion } from "../package.json";
const wsMessageCounter = new lib.Counter(
"clusterio_controller_websocket_message_total",
@@ -45,12 +45,12 @@ export default class WsServer {
sessionAud: string = `session-${Date.now()}`;
nextSessionId: number = 1;
nextControlId: number = 1;
wss: WebSocketServer;
wss: WebSocket.Server;
constructor(controller: Controller) {
this.controller = controller;
this.wss = new WebSocketServer({
this.wss = new WebSocket.Server({
noServer: true,
path: "/api/socket",
});
@@ -116,13 +116,6 @@ export default class WsServer {
handleConnection(socket: lib.WebSocketClusterio, req: IncomingMessage) {
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
wsConnectionsCounter.inc();
socket.on("message", (message: WebSocket.RawData) => {
@@ -142,11 +135,11 @@ export default class WsServer {
// Start connection handshake.
let loadedPlugins: {[key:string]: string} = {};
for (const plugin of this.controller.loadedPlugins) {
loadedPlugins[plugin.name] = plugin.version;
for (let [name, plugin] of this.controller.plugins) {
loadedPlugins[name] = plugin.info.version;
}
socket.send(JSON.stringify(new lib.MessageHello(new lib.HelloData(packageConfig.version, loadedPlugins))));
socket.send(JSON.stringify(new lib.MessageHello(new lib.HelloData(packageVersion, loadedPlugins))));
this.attachHandler(socket, req);
}
@@ -230,11 +223,13 @@ ${err.stack}`
createSession(dst: lib.Address): [ WsServerConnector, string ] {
let sessionId = this.nextSessionId;
this.nextSessionId += 1;
let sessionToken = jwt.sign({ aud: this.sessionAud, sid: sessionId }, this.controller.authSecret);
let sessionToken = jwt.sign(
{ aud: this.sessionAud, sid: sessionId },
Buffer.from(this.controller.config.get("controller.auth_secret"), "base64"),
);
let sessionTimeout = this.controller.config.get("controller.session_timeout");
let heartbeatInterval = this.controller.config.get("controller.heartbeat_interval");
let maxSendBufferSize = this.controller.config.get("controller.max_send_buffer_size") * 1024 * 1024;
let connector = new WsServerConnector(dst, sessionId, sessionTimeout, heartbeatInterval, maxSendBufferSize);
let connector = new WsServerConnector(dst, sessionId, sessionTimeout, heartbeatInterval);
this.activeConnectors.set(sessionId, connector);
connector.on("close", () => {
this.activeConnectors.delete(sessionId);
@@ -252,8 +247,13 @@ ${err.stack}`
}
try {
// migrate: allow pre-rename tokens issued to hosts before alpha-14
const tokenPayload = jwt.verify(data.token, this.controller.authSecret, { audience: ["host", "slave"] });
let tokenPayload = jwt.verify(
data.token,
Buffer.from(this.controller.config.get("controller.auth_secret"), "base64"),
// migrate: allow pre-rename tokens issued to hosts before alpha-14
{ audience: ["host", "slave"] }
);
if (typeof tokenPayload === "string") {
throw new Error("unexpected JsonWebToken type");
}
@@ -295,11 +295,10 @@ ${err.stack}`
logger.info(
`WsServer | registered host ${data.id} using version ${data.version}`
);
if (data.version !== packageConfig.version) {
if (data.version !== packageVersion) {
logger.warn(
`Host ${data.id} connected using version ${data.version} which does not match the ` +
`version of the controller is currently running (${packageConfig.version}). ` +
"It may not work as expected."
`version of the controller is currently running (${packageVersion}). It may not work as expected.`
);
}
@@ -325,12 +324,17 @@ ${err.stack}`
async registerControl(data: lib.RegisterControlData, socket: WebSocket, req: IncomingMessage) {
let user;
try {
const tokenPayload = jwt.verify(data.token, this.controller.authSecret, { audience: "user" });
let tokenPayload = jwt.verify(
data.token,
Buffer.from(this.controller.config.get("controller.auth_secret"), "base64"),
{ audience: "user" }
);
if (typeof tokenPayload === "string") {
throw new Error("unexpected JsonWebToken type");
}
user = this.controller.users.getByNameMutable(tokenPayload.user);
user = this.controller.userManager.getByName(tokenPayload.user);
if (!user) {
throw new Error("invalid user");
}
@@ -372,7 +376,12 @@ ${err.stack}`
resumeSession(data: lib.ResumeData, socket: WebSocket, req: IncomingMessage) {
let connector;
try {
const payload = jwt.verify(data.sessionToken, this.controller.authSecret, { audience: this.sessionAud });
let payload = jwt.verify(
data.sessionToken,
Buffer.from(this.controller.config.get("controller.auth_secret"), "base64"),
{ audience: this.sessionAud }
);
if (typeof payload === "string") {
throw new Error("unexpected JsonWebToken type");
}
+11 -11
View File
@@ -1,10 +1,10 @@
import { type WebSocket } from "ws";
import type WebSocket from "ws";
import events from "events";
import assert from "assert/strict";
import * as lib from "@clusterio/lib";
import { logger } from "@clusterio/lib";
const { logger } = lib;
/**
@@ -21,10 +21,8 @@ export default class WsServerConnector extends lib.WebSocketBaseConnector {
private _sessionId: number,
private _sessionTimeout: number,
public _heartbeatInterval: number,
maxSendBufferSize = 0,
) {
super(new lib.Address(lib.Address.controller, 0), dst);
this.maxSendBufferSize = maxSendBufferSize;
// The following states are used in the server connector
// closed: Connection is closed
@@ -51,7 +49,7 @@ export default class WsServerConnector extends lib.WebSocketBaseConnector {
* @param account - account data to provide to control connection
*/
ready(
socket: WebSocket,
socket: WebSocket.WebSocket,
src: lib.Address,
sessionToken: string,
account: lib.AccountDetails | undefined
@@ -82,7 +80,7 @@ export default class WsServerConnector extends lib.WebSocketBaseConnector {
* @param lastSeq - The last message the client received.
*/
continue(
socket: WebSocket,
socket: WebSocket.WebSocket,
lastSeq: number
) {
@@ -108,17 +106,14 @@ export default class WsServerConnector extends lib.WebSocketBaseConnector {
this._state = "connected";
this._attachSocketHandlers();
this._dropSendBufferSeq(lastSeq);
logger.verbose(
`Connector | resuming session with ${this.dst}, resending ${this._sendBuffer.length} buffered messages`
);
for (let message of this._sendBuffer) {
this._socket.send(message.text);
this._socket.send(JSON.stringify(message));
}
this.emit("resume");
}
_timedOut() {
logger.verbose(`Connector | Connection to ${this.dst} timed out`);
logger.verbose("Connector | Connection timed out");
this._close();
}
@@ -144,6 +139,11 @@ 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", () => {
logger.verbose("Connector | Open");
});
-350
View File
@@ -1,350 +0,0 @@
/**
* 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;
}
+78 -100
View File
@@ -1,17 +1,18 @@
import type { Application, Request, Response } from "express";
import Controller from "./Controller.js";
import type Controller from "./Controller";
import busboy from "busboy";
import crypto from "crypto";
import events from "events";
import fs from "node:fs/promises";
import fs from "fs-extra";
import JSZip from "jszip";
import jwt from "jsonwebtoken";
import path from "path";
import nodeStream from "stream";
import util from "util";
import * as lib from "@clusterio/lib";
import { logger } from "@clusterio/lib";
const { logger } = lib;
const finished = util.promisify(nodeStream.finished);
@@ -20,12 +21,9 @@ declare global {
namespace Express {
export interface Locals {
controller: Controller,
webBundle: { main: string, imports: Record<string, string> },
devServer?: import("vite").ViteDevServer,
staticRoot: string | null,
devPlugins?: Map<string, string>,
streams: Map<string, ProxyStream>,
pendingMetrics?: Promise<string>,
mainBundle: string,
devPlugins: Map<string, number>,
streams: Map<string, ProxyStream>
}
}
}
@@ -45,10 +43,12 @@ function mergeSamples(destinationResult: lib.CollectorResultSerialized, sourceRe
}
}
// Collect metrics from the controller, its plugins and all connected hosts
async function gatherMetrics(controller: Controller) {
// Prometheus polling endpoint
async function getMetrics(req: Request, res: Response, next: any) {
const controller: Controller = req.app.locals.controller;
let results: lib.CollectorResult[] = [];
let pluginResults = await controller.hooks.metrics.collect();
let pluginResults = await lib.invokeHook(controller.plugins, "onMetrics");
for (let metricIterator of pluginResults) {
for await (let metric of metricIterator) {
results.push(metric);
@@ -98,63 +98,36 @@ async function gatherMetrics(controller: Controller) {
results.push(lib.deserializeResult(result));
}
return await lib.exposition(results);
}
// Prometheus polling endpoint
async function getMetrics(req: Request, res: Response) {
try {
res.locals.user.checkPermission("core.controller.metrics");
} catch (err: any) {
res.status(403).json({ request_errors: [err.message] });
return;
}
// Concurrent scrapes share one gather so hosts see at most one
// metrics request at a time regardless of how many clients poll.
const locals = req.app.locals;
if (!locals.pendingMetrics) {
locals.pendingMetrics = gatherMetrics(locals.controller).finally(() => {
locals.pendingMetrics = undefined;
});
}
const text = await locals.pendingMetrics;
let text = await lib.exposition(results);
res.set("Content-Type", lib.expositionContentType);
res.send(text);
}
/**
* Removes the static/ prefix from bundle paths in manifest.json so they can be
* resolved relative to staticRoot.
*/
export function stripStaticPrefix(bundlePath: string) {
return bundlePath.replace(/^static\//, "");
}
function getPlugins(req: Request, res: Response) {
let plugins: lib.PluginWebApi[] = [];
for (let pluginInfo of req.app.locals.controller.pluginInfos) {
let name = pluginInfo.name;
const loaded = req.app.locals.controller.loadedPlugins.has(pluginInfo);
const enabled = loaded && req.app.locals.controller.config.get(`${pluginInfo.name}.load_plugin`);
let loaded = req.app.locals.controller.plugins.has(name);
let enabled = loaded && req.app.locals.controller.config.get(`${pluginInfo.name}.load_plugin`);
// Note: Cast through unknown is needed because load_plugin is
// defined at runtime and no other fields in the controller config
// currently have the boolean type.
let web: { main: any, error: string|undefined } = { main: undefined, error: undefined};
let web: { main:any, error:string|undefined } = { main: undefined, error: undefined};
let devPlugins = req.app.locals.devPlugins;
if (devPlugins && devPlugins.has(name)) {
web.main = devPlugins.get(name);
let stats = res.locals.webpack.devMiddleware.stats.stats[devPlugins.get(name)!];
web.main = stats.toJson().assetsByChunkName[name];
} else if (pluginInfo.manifest) {
web.main = pluginInfo.manifest.main;
web.main = pluginInfo.manifest[`${pluginInfo.name}.js`];
if (!web.main) {
web.error = "Incompatible dist/web/manifest.json, the plugin must be rebuilt with Vite";
web.error = `Missing ${pluginInfo.name}.js entry in manifest.json`;
}
} else if (lib.pluginNeedsWebBuild(pluginInfo)) {
} else {
web.error = "Missing dist/web/manifest.json";
}
if (web.main) {
web.main = stripStaticPrefix(web.main);
if (web.main === "remoteEntry.js") {
web.error = "Incompatible old remoteEntry.js entrypoint.";
}
plugins.push({ name, version: pluginInfo.version, enabled, loaded, web, npmPackage: pluginInfo.npmPackage });
}
@@ -167,28 +140,20 @@ function getClusterName(req: Request, res: Response) {
res.json({ name: clusterName });
}
// Reads the access token from X-Access-Token or Authorization: Bearer
function getAccessToken(req: Request) {
const token = req.header("x-access-token");
if (token) {
return token;
}
const authorization = req.header("authorization");
if (authorization && /^Bearer /i.test(authorization)) {
return authorization.slice("Bearer ".length).trim();
}
return undefined;
}
function validateHostToken(req: Request, res: Response, next: any) {
let token = getAccessToken(req);
let token = req.header("x-access-token");
if (!token) {
res.sendStatus(401);
return;
}
try {
const tokenPayload = jwt.verify(token, req.app.locals.controller.authSecret, { audience: "host" });
const tokenPayload = jwt.verify(
token,
Buffer.from(req.app.locals.controller.config.get("controller.auth_secret"), "base64"),
{ audience: "host" }
);
if (typeof tokenPayload === "string") {
throw new Error("unexpected JsonWebToken type");
}
@@ -208,18 +173,23 @@ function validateHostToken(req: Request, res: Response, next: any) {
}
function validateUserToken(req: Request, res: Response, next: any) {
let token = getAccessToken(req);
let token = req.header("x-access-token");
if (!token) {
res.sendStatus(401);
return;
}
try {
const tokenPayload = jwt.verify(token, req.app.locals.controller.authSecret, { audience: "user" });
let tokenPayload = jwt.verify(
token,
Buffer.from(req.app.locals.controller.config.get("controller.auth_secret"), "base64"),
{ audience: "user" }
);
if (typeof tokenPayload === "string") {
throw new Error("unexpected JsonWebToken type");
}
let user = req.app.locals.controller.users.getByName(tokenPayload.user);
let user = req.app.locals.controller.userManager.getByName(tokenPayload.user);
if (!user) {
throw new Error("invalid user");
}
@@ -262,38 +232,38 @@ async function uploadExport(req: Request, res: Response) {
for await (let chunk of req) {
data.push(chunk);
}
let zip = await lib.ZipArchive.fromBuffer(Buffer.concat(data));
let buffer: Buffer | null = Buffer.concat(data);
let zip = await JSZip.loadAsync(buffer);
buffer = null;
// This is hardcoded to prevent path expansion attacks
let exportFiles = [
"export/settings.json",
"export/prototypes.json",
"export/item-spritesheet.png",
"export/item-metadata.json",
"export/locale.json",
"export/defines.json",
"export/spritesheet.png",
"export/metadata.json",
];
let assets: any = {};
let settingPrototypes: lib.ExportSettings = {};
let settingPrototypes = {};
for (let filePath of exportFiles) {
let file = zip.file(filePath);
if (!file) {
continue;
}
let content = await zip.readEntry(file);
if (filePath === "export/settings.json") {
settingPrototypes = JSON.parse(content.toString("utf8"));
settingPrototypes = JSON.parse(await file.async("text"));
}
let { name, ext } = path.posix.parse(filePath);
let hash = await lib.hashStream(await zip.openStream(file));
let hash = await lib.hashStream(file.nodeStream());
assets[name] = `${name}.${hash}${ext}`;
await lib.safeOutputFile(path.join("static", `${name}.${hash}${ext}`), content);
await lib.safeOutputFile(path.join("static", `${name}.${hash}${ext}`), await file.async("nodebuffer"));
}
modPack.exportManifest = new lib.ExportManifest(assets, new Date().toISOString());
modPack.exportManifest = new lib.ExportManifest(assets);
modPack.fillDefaultSettings(settingPrototypes, logger);
res.app.locals.controller.modPacks.set(modPack);
@@ -305,7 +275,7 @@ type ProxyStreamEvents = {
"close": [],
"timeout": [],
"source": [],
};
}
export interface ProxyStream {
id: string;
@@ -344,7 +314,7 @@ export async function createProxyStream(app: Application): Promise<ProxyStream>
return stream;
}
async function putStream(req: Request<{ id: string }>, res: Response) {
async function putStream(req: Request, res: Response) {
const stream = req.app.locals.streams.get(req.params.id);
if (!stream || stream.source) {
res.sendStatus(404);
@@ -383,7 +353,7 @@ function startStream(res: Response, stream: ProxyStream) {
clearTimeout(stream.timeout);
}
async function getStream(req: Request<{ id: string }>, res: Response) {
async function getStream(req: Request, res: Response) {
const stream = req.app.locals.streams.get(req.params.id);
if (!stream || stream.flowing) {
res.sendStatus(404);
@@ -545,7 +515,7 @@ function checkModName(name: string) {
try {
lib.checkFilename(name);
} catch (err: any) {
throw new lib.ValidationError(`Mod name ${err.message}`);
throw new lib.RequestError(`Mod name ${err.message}`);
}
}
@@ -582,16 +552,30 @@ async function uploadMod(req: Request, res: Response) {
}
const modsDirectory = req.app.locals.controller.config.get("controller.mods_directory");
const filePath = path.join(modsDirectory, filename);
const [tempFilePath, writeStream] = await lib.createTempWriteStream(filePath);
let tempFilename = filename.replace(/(\.zip)?$/, ".tmp.zip");
let writeStream;
try {
while (true) {
try {
writeStream = fs.createWriteStream(path.join(modsDirectory, tempFilename), { flags: "wx" });
await events.once(writeStream, "open");
break;
} catch (err: any) {
if (err.code === "EEXIST") {
tempFilename = await lib.findUnusedName(modsDirectory, tempFilename, ".tmp.zip");
} else {
throw err;
}
}
}
stream.pipe(writeStream);
await finished(writeStream);
const modInfo = await lib.ModInfo.fromModFile(tempFilePath);
const modInfo = await lib.ModInfo.fromModFile(path.join(modsDirectory, tempFilename));
checkModName(modInfo.filename);
await fs.rename(tempFilePath, path.join(modsDirectory, modInfo.filename));
await fs.rename(path.join(modsDirectory, tempFilename), path.join(modsDirectory, modInfo.filename));
req.app.locals.controller.modStore.addMod(modInfo);
mods.push(modInfo.toJSON());
@@ -601,13 +585,15 @@ async function uploadMod(req: Request, res: Response) {
stream.resume();
// Attempt to clean up.
writeStream.destroy();
if (writeStream) {
writeStream.destroy();
}
try {
await fs.unlink(tempFilePath);
await fs.unlink(path.join(modsDirectory, tempFilename));
} catch (unlinkErr: any) {
if (unlinkErr.code !== "ENOENT") {
logger.error(`Error removing ${tempFilePath}: ${unlinkErr.message}`);
logger.error(`Error removing ${tempFilename}: ${unlinkErr.message}`);
}
}
}
@@ -680,22 +666,15 @@ async function uploadMod(req: Request, res: Response) {
export function addRouteHandlers(app: Application) {
app.get("/metrics",
validateUserToken,
(req:Request, res:Response, next:any) => getMetrics(req, res).catch(next)
);
app.get("/metrics", (req:Request, res:Response, next:any) => getMetrics(req, res, next).catch(next));
app.get("/api/plugins", getPlugins);
app.get("/api/cluster-name", getClusterName);
app.put("/api/upload-export",
validateHostToken,
(req:Request, res:Response, next:any) => uploadExport(req, res).catch(next)
);
app.put("/api/stream/:id",
(req:Request<{ id: string }>, res:Response, next:any) => putStream(req, res).catch(next)
);
app.get("/api/stream/:id",
(req:Request<{ id: string }>, res:Response, next:any) => getStream(req, res).catch(next)
);
app.put("/api/stream/:id", (req:Request, res:Response, next:any) => putStream(req, res).catch(next));
app.get("/api/stream/:id", (req:Request, res:Response, next:any) => getStream(req, res).catch(next));
app.post("/api/upload-save",
validateUserToken,
(req:Request, res:Response, next:any) => uploadSave(req, res).catch(next)
@@ -716,7 +695,6 @@ export const webRoutes = [
"/instances/:id/view",
"/mods",
"/mods/mod-packs/:id/view",
"/mods/mod-packs/:id/icon-reference",
"/users",
"/users/:id/view",
"/roles",
-6
View File
@@ -1,6 +0,0 @@
import { defineConfig } from "vite";
import { clusterioApp } from "@clusterio/web_ui/vite.mjs";
export default defineConfig({
plugins: [clusterioApp({ entry: "web/index.tsx" })],
});
+4 -4
View File
@@ -5,7 +5,6 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#2a2a2a" />
<title>__CLUSTER_NAME__</title>
<script type="importmap">__IMPORT_MAP__</script>
<style>
html {
color: #fff;
@@ -27,9 +26,10 @@
<div id="root">
<div id="bootstrap-msg">Loading web interface...</div>
</div>
<script id="script-bundle" src="__STATIC_ROOT____MAIN_BUNDLE__" defer></script>
<script>
webRoot = new URL("__WEB_ROOT__", document.location).pathname;
staticRoot = new URL("__STATIC_ROOT__", document.location).href;
staticRoot = new URL("__STATIC_ROOT__", document.location).pathname;
function scriptError(err) {
console.error(err);
let bootstrapMsg = document.getElementById("bootstrap-msg");
@@ -41,9 +41,9 @@
bootstrapMsg.textContent = `${errorMsg}\nSee web console for details.`;
}
}
const bundleScript = document.getElementById("script-bundle");
bundleScript.addEventListener("error", () => scriptError("Main bundle failed to load."));
window.addEventListener("error", event => scriptError(event.error));
</script>
<script id="script-bundle" type="module" src="__STATIC_ROOT____MAIN_BUNDLE__"
onerror="scriptError('Main bundle failed to load.')"></script>
</body>
</html>
-1
View File
@@ -1,4 +1,3 @@
// eslint-disable-next-line no-implicit-globals
declare function scriptError(err: any): void;
import("@clusterio/web_ui")
.then(webUi => webUi.bootstrap())
+31
View File
@@ -0,0 +1,31 @@
"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 },
},
}),
],
});
+1 -2
View File
@@ -2,5 +2,4 @@
Installer for Clusterio.
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.
See [the Clusterio README.md](https://github.com/clusterio/clusterio#readme) the for installation guide.
+159 -73
View File
@@ -1,19 +1,21 @@
#!/usr/bin/env node
import child_process from "node:child_process";
import fs from "node:fs/promises";
import inquirer from "inquirer";
import os from "node:os";
import path from "node:path";
import stream from "node:stream";
import util from "node:util";
import yargs from "yargs";
"use strict";
const child_process = require("child_process");
const fs = require("fs-extra");
const inquirer = require("inquirer");
const os = require("os");
const path = require("path");
const stream = require("stream");
const util = require("util");
const yargs = require("yargs");
import { levels, logger, setLogLevel } from "./logging.js";
import { copyPluginTemplates } from "./template.js";
const { levels, logger, setLogLevel } = require("./logging");
const { copyPluginTemplates } = require("./template");
let dev = false;
const scriptExt = process.platform === "win32" ? ".cmd" : "";
import { escapeArg } from "./escape_arg.js";
const finished = util.promisify(stream.finished);
const { escapeArg } = require("./escape_arg");
// I hate this, there is a bug with rl.close on windows that was meant to have been fixed in Node14.4
// See nodejs/node#21771 and nodejs/node#30701 for the apparent fix that was applied
@@ -30,12 +32,15 @@ if (process.platform === "win32") {
const availablePlugins = [
{ name: "Global Chat", value: "@clusterio/plugin-global_chat" },
{ name: "Lobby", value: "@clusterio/plugin-lobby" },
{ name: "Inventory Sync", value: "@clusterio/plugin-inventory_sync" },
{ 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: "Statistics Exporter", value: "@clusterio/plugin-statistics_exporter" },
{ 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 = {
@@ -92,33 +97,21 @@ class LineSplitter extends stream.Transform {
class InstallError extends Error { }
async function pathExists(filePath) {
try {
await fs.access(filePath);
} catch (err) {
if (err.code === "ENOENT" || err.code === "ENOTDIR") {
return false;
}
}
return true;
}
async function safeOutputFile(file, data, options={}) {
let { dir, name, ext } = path.parse(file);
let temporary = path.join(dir, `${name}.tmp${ext}`);
if (dir) {
await fs.mkdir(dir, { recursive: true });
}
await fs.writeFile(temporary, data, { ...options, flush: true });
await fs.outputFile(temporary, data, options);
await fs.rename(temporary, file);
}
async function execFile(cmd, args) {
const command = [cmd, ...args.map(escapeArg)].join(" ");
logger.verbose(`executing ${command}`);
const escaped = args.map(escapeArg);
logger.verbose(`executing ${cmd} ${escaped.join(" ")}`);
return new Promise((resolve, reject) => {
let child = child_process.execFile(
command,
cmd,
escaped,
{
shell: true,
@@ -143,21 +136,21 @@ async function execFile(cmd, args) {
async function execController(args) {
if (dev) {
return await execFile("node", [path.join(import.meta.dirname, "..", "controller"), ...args]);
return await execFile("node", [path.join(__dirname, "..", "controller"), ...args]);
}
return await execFile(path.join("node_modules", ".bin", `clusteriocontroller${scriptExt}`), args);
}
async function execHost(args) {
if (dev) {
return await execFile("node", [path.join(import.meta.dirname, "..", "host"), ...args]);
return await execFile("node", [path.join(__dirname, "..", "host"), ...args]);
}
return await execFile(path.join("node_modules", ".bin", `clusteriohost${scriptExt}`), args);
}
async function execCtl(args) {
if (dev) {
return await execFile("node", [path.join(import.meta.dirname, "..", "ctl"), ...args]);
return await execFile("node", [path.join(__dirname, "..", "ctl"), ...args]);
}
return await execFile(path.join("node_modules", ".bin", `clusterioctl${scriptExt}`), args);
}
@@ -228,10 +221,10 @@ async function migrateRename(args) {
}
async function migrateConfig(source, destination) {
if (await pathExists(source) && !await pathExists(destination)) {
if (await fs.pathExists(source) && !await fs.pathExists(destination)) {
await safeOutputFile(destination, JSON.stringify(
renameConfig(JSON.parse(await fs.readFile(source))), null, "\t"
), { mode: 0o600 });
));
logger.info(`Migrated ${source} to ${destination}`);
}
}
@@ -240,7 +233,7 @@ async function migrateRename(args) {
await migrateConfig("config-slave.json", "config-host.json");
let instancesFile = path.join("database", "instances.json");
if (await pathExists(instancesFile)) {
if (await fs.pathExists(instancesFile)) {
await safeOutputFile(instancesFile, JSON.stringify(
JSON.parse(await fs.readFile(instancesFile)).map(renameConfig), null, "\t"
));
@@ -248,7 +241,7 @@ async function migrateRename(args) {
}
let usersFile = path.join("database", "users.json");
if (await pathExists(usersFile)) {
if (await fs.pathExists(usersFile)) {
let users = JSON.parse(await fs.readFile(usersFile));
for (let role of users["roles"]) {
role["permissions"] = role["permissions"].map(rename);
@@ -265,7 +258,7 @@ async function migrateRename(args) {
async function migrateLog(inputFile, outputFile) {
let lineStream = new LineSplitter({ readableObjectMode: true });
let fileStream = (await fs.open(inputFile)).createReadStream();
let fileStream = fs.createReadStream(inputFile);
fileStream.pipe(lineStream);
let lines = [];
@@ -285,7 +278,7 @@ async function migrateRename(args) {
}
async function migrateLogsDir(inputLogs, outputLogs) {
if (await pathExists(inputLogs)) {
if (await fs.pathExists(inputLogs)) {
logger.info(`Migrating ${inputLogs} to ${outputLogs}`);
for (let logFile of await fs.readdir(inputLogs)) {
if (!logFile.endsWith(".log")) {
@@ -293,7 +286,7 @@ async function migrateRename(args) {
}
let inputFile = path.join(inputLogs, logFile);
let outputFile = path.join(outputLogs, rename(logFile));
if (!await pathExists(outputFile)) {
if (!await fs.pathExists(outputFile)) {
await migrateLog(inputFile, outputFile);
logger.verbose(`Migrated ${inputFile} to ${outputFile}`);
}
@@ -304,16 +297,16 @@ async function migrateRename(args) {
await migrateLogsDir(path.join("logs", "master"), path.join("logs", "controller"));
await migrateLogsDir(path.join("logs", "slave"), path.join("logs", "host"));
if (
await pathExists(path.join("logs", "cluster"))
&& !await pathExists(path.join("logs", "cluster-prerename"))
await fs.pathExists(path.join("logs", "cluster"))
&& !await fs.pathExists(path.join("logs", "cluster-prerename"))
) {
await fs.rename(path.join("logs", "cluster"), path.join("logs", "cluster-prerename"));
await migrateLogsDir(path.join("logs", "cluster-prerename"), path.join("logs", "cluster"));
}
if (
await pathExists("sharedMods")
&& !await pathExists("mods")
await fs.pathExists("sharedMods")
&& !await fs.pathExists("mods")
) {
logger.info("Moving sharedMods/ to mods/");
await fs.rename("sharedMods", "mods");
@@ -335,14 +328,14 @@ async function migrateRename(args) {
await execFile(`npm${scriptExt}`, ["update"]);
}
const hasRunMaster = await pathExists("run-master.sh") || await pathExists("run-master.cmd");
const hasRunSlave = await pathExists("run-slave.sh") || await pathExists("run-slave.cmd");
const hasRunMaster = await fs.pathExists("run-master.sh") || await fs.pathExists("run-master.cmd");
const hasRunSlave = await fs.pathExists("run-slave.sh") || await fs.pathExists("run-slave.cmd");
if (hasRunMaster || hasRunSlave) {
logger.info("Writing run scripts");
let mode = "standalone";
if (!hasRunSlave) { mode = "controller"; }
if (!hasRunMaster) { mode = "host"; }
await writeScripts(mode, "");
await writeScripts(mode);
}
logger.info(
@@ -361,7 +354,7 @@ async function migrateRename(args) {
async function installClusterio(mode, plugins) {
try {
await fs.writeFile("package.json", JSON.stringify({
await fs.outputFile("package.json", JSON.stringify({
name: "clusterio-install",
private: true,
}, null, 2), { flag: "wx" });
@@ -389,7 +382,31 @@ async function installClusterio(mode, plugins) {
throw new InstallError(`Failed to install: ${err.message}`);
}
// Controller, host and ctl discover installed plugins when loading their configuration.
if (plugins.length) {
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 = require(require.resolve(plugin, { paths: [process.cwd()] })).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) {
@@ -403,7 +420,7 @@ async function groupIdToName(gid) {
}
}
async function writeScripts(mode, selfSignedCert) {
async function writeScripts(mode) {
if (["standalone", "controller"].includes(mode)) {
if (process.platform === "win32") {
await safeOutputFile(
@@ -457,13 +474,12 @@ WantedBy=multi-user.target
if (["standalone", "host"].includes(mode)) {
if (process.platform === "win32") {
let extraCaCertsLine = selfSignedCert ? `set "NODE_EXTRA_CA_CERTS=${selfSignedCert}"\n` : "";
await safeOutputFile(
"run-host.cmd",
`\
@echo off
set "NODE_OPTIONS=--enable-source-maps %NODE_OPTIONS%"
${extraCaCertsLine}:restart
:restart
call .\\node_modules\\.bin\\clusteriohost.cmd run --can-restart
if %errorlevel% equ 0 exit /b
if %errorlevel% equ 8 exit /b
@@ -471,20 +487,18 @@ goto restart
`
);
} else {
let extraCaCertsLine = selfSignedCert ? `export "NODE_EXTRA_CA_CERTS=${selfSignedCert}"\n` : "";
await safeOutputFile(
"run-host.sh",
`\
#!/bin/bash
export "NODE_OPTIONS=--enable-source-maps $NODE_OPTIONS"
${extraCaCertsLine}while true; do
while true; do
./node_modules/.bin/clusteriohost run --can-restart
if [[ $? -eq 0 || $? -eq 8 ]]; then exit $?; fi
done
`,
{ mode: 0o755 },
);
extraCaCertsLine = selfSignedCert ? `Environment=NODE_EXTRA_CA_CERTS=${selfSignedCert}\n` : "";
await safeOutputFile(
"systemd/clusteriohost.service",
`\
@@ -498,7 +512,7 @@ WorkingDirectory=${process.cwd()}
KillMode=mixed
KillSignal=SIGINT
Environment=NODE_OPTIONS=--enable-source-maps
${extraCaCertsLine}ExecStart=${process.cwd()}/node_modules/.bin/clusteriohost run --log-level=warn --can-restart
ExecStart=${process.cwd()}/node_modules/.bin/clusteriohost run --log-level=warn --can-restart
Restart=on-failure
RestartPreventExitStatus=8
@@ -626,10 +640,26 @@ async function inquirerMissingArgs(args) {
let locations = factorioLocations[process.platform] || [];
let foundLocations = [];
for (let location of locations) {
if (await pathExists(path.join(location, "data", "changelog.txt"))) {
if (await fs.pathExists(path.join(location, "data", "changelog.txt"))) {
foundLocations.push(location);
}
}
if (process.platform === "linux") {
if (args.hasOwnProperty("downloadHeadless")) { answers.downloadHeadless = args.downloadHeadless; }
answers = await inquirer.prompt([
{
type: "confirm",
name: "downloadHeadless",
message: "(Linux only) Automatically download latest factorio release?",
default: true,
},
], answers);
if (!answers.factorioDir && answers.downloadHeadless) {
answers.factorioDir = "factorio";
}
}
answers = await inquirer.prompt([
{
@@ -654,7 +684,7 @@ async function inquirerMissingArgs(args) {
},
], answers);
} else if (answers.factorioDir === "factorio") {
await fs.mkdir("factorio", { recursive: true });
await fs.ensureDir("factorio");
}
}
@@ -721,8 +751,55 @@ async function inquirerMissingArgs(args) {
return answers;
}
async function downloadLinuxServer() {
let res = await fetch("https://factorio.com/get-download/stable/headless/linux64");
// get the filename of the latest factorio archive from redirected url
const url = new URL(res.url); // The res is redirects, need final url
const filename = path.posix.basename(url.pathname);
const match = filename.match(/(?<=factorio_headless_x64_|factorio-headless_linux_)\d+\.\d+\.\d+(?=\.tar\.xz)/);
if (!match || !match.length) {
throw Error(`Unable to extract version from filename: ${filename}`);
}
const version = match[0];
const tmpDir = "temp/create-temp/";
const archivePath = tmpDir + filename;
const tmpArchivePath = `${archivePath}.tmp`;
const factorioDir = `factorio/${version}/`;
const tmpFactorioDir = tmpDir + version;
if (await fs.pathExists(factorioDir)) {
logger.warn(`setting downloadDir to ${factorioDir}, but not downloading because already existing`);
} else {
await fs.emptyDir(tmpDir);
logger.info("Downloading latest Factorio server release. This may take a while.");
const writeStream = fs.createWriteStream(tmpArchivePath);
stream.Readable.fromWeb(res.body).pipe(writeStream);
await finished(writeStream);
await fs.rename(tmpArchivePath, archivePath);
try {
await fs.ensureDir(tmpFactorioDir);
await execFile("tar", [
"xf", archivePath, "-C", tmpFactorioDir, "--strip-components", "1",
]);
} catch (e) {
logger.error("error executing command- do you have 'xz-utils' installed?");
throw e;
}
await fs.ensureDir("factorio");
await fs.rename(tmpFactorioDir, factorioDir);
await fs.unlink(archivePath);
await fs.rm(tmpDir);
}
}
async function main() {
let yarg = yargs(process.argv.slice(2))
let args = yargs
.option("log-level", {
nargs: 1, describe: "Log level to print to stdout", default: "info",
choices: ["none"].concat(Object.keys(levels)), type: "string",
@@ -743,6 +820,10 @@ async function main() {
.option("http-port", {
nargs: 1, describe: "HTTP port to listen on [standalone/controller]", type: "number",
})
.option("download-headless", {
describe: "Download the latest headless release [standalone/controller] [linux only]",
type: "boolean", nargs: 0,
})
.option("host-name", {
nargs: 1, describe: "Host name [host]", type: "string",
})
@@ -758,9 +839,6 @@ async function main() {
.option("factorio-dir", {
nargs: 1, describe: "Path to Factorio installation [standalone/host]", type: "string",
})
.option("self-signed-cert", {
nargs: 1, describe: "Path to self-signed certificate of the controller [standalone/host]", type: "string",
})
.option("remote-npm", {
nargs: 0, description: "Allow remote updates via npm [standalone/controller/host]", type: "boolean",
})
@@ -777,14 +855,20 @@ async function main() {
;
if (process.platform === "linux") {
yarg = yarg
args = args
.option("allow-install-as-root", {
nargs: 0, describe: "(Linux only) Allow installing as root (not recommended)", type: "boolean",
})
.option("download-headless", {
nargs: 0,
describe: "(Linux only) Automatically download and unpack the latest factorio release. " +
"Can be set to false using --no-download-headless.",
type: "boolean",
})
;
}
const args = yarg.parse();
args = args.argv;
setLogLevel(args.logLevel === "none" ? -1 : levels[args.logLevel]);
dev = args.dev;
@@ -807,6 +891,13 @@ async function main() {
return;
}
if (answers.downloadHeadless) {
if (answers.factorioDir !== "factorio") {
throw new InstallError("--download-headless option requires --factorio-dir to be set to factorio");
}
await downloadLinuxServer();
}
if (!dev) {
await installClusterio(answers.mode, answers.plugins);
}
@@ -814,7 +905,6 @@ async function main() {
let adminToken = null;
if (["standalone", "controller"].includes(answers.mode)) {
logger.info("Setting up controller");
await execController(["config", "create"]);
await execController(["bootstrap", "create-admin", answers.admin]);
await execController(["config", "set", "controller.http_port", answers.httpPort]);
await execController(["config", "set", "controller.allow_remote_updates", answers.remoteNpm]);
@@ -825,7 +915,6 @@ async function main() {
if (answers.mode === "standalone") {
logger.info("Setting up host");
await execHost(["config", "create"]);
await execHost(["config", "set", "host.name", "local"]);
let result = await execHost(["config", "show", "host.id"]);
@@ -846,7 +935,6 @@ async function main() {
if (answers.mode === "host") {
logger.info("Setting up host");
let hostId = JSON.parse(Buffer.from(answers.controllerToken.split(".")[1], "base64")).host;
await execHost(["config", "create"]);
await execHost(["config", "set", "host.id", hostId]);
await execHost(["config", "set", "host.name", answers.hostName]);
await execHost(["config", "set", "host.controller_url", answers.controllerUrl]);
@@ -859,13 +947,12 @@ async function main() {
if (!dev && ["standalone", "controller", "host"].includes(answers.mode)) {
logger.info("Writing run scripts");
await writeScripts(answers.mode, args.selfSignedCert);
await writeScripts(answers.mode);
}
if (answers.mode === "ctl") {
await execCtl(["config", "create"]);
await execCtl(["config", "set", "ctl.controller_url", answers.controllerUrl]);
await execCtl(["config", "set", "ctl.controller_token", answers.controllerToken]);
await execCtl(["control-config", "set", "control.controller_url", answers.controllerUrl]);
await execCtl(["control-config", "set", "control.controller_token", answers.controllerToken]);
}
/* eslint-disable no-console */
@@ -876,9 +963,8 @@ async function main() {
/* eslint-enable no-console */
}
if (import.meta.main) {
if (module === require.main) {
main().catch(err => {
process.exitCode = 1;
if (err instanceof InstallError) {
logger.error(err.message);
} else {
+7 -1
View File
@@ -1,4 +1,6 @@
export function escapeArg(arg) {
"use strict";
function escapeArg(arg) {
// When shell: true is used execFile does not escaping of arguments, they are
// simply joined by space and then passed to the shell.
@@ -54,3 +56,7 @@ export function escapeArg(arg) {
}
return `"${arg.replace(/[$`"\\]/g, "\\$&")}"`;
}
module.exports = {
escapeArg,
};
+12 -7
View File
@@ -1,7 +1,8 @@
/* eslint-disable no-console */
import chalk from "chalk";
"use strict";
const chalk = require("chalk");
export const levels = Object.freeze({
const levels = Object.freeze({
fatal: 0,
error: 1,
warn: 2,
@@ -10,7 +11,7 @@ export const levels = Object.freeze({
verbose: 6,
});
export const colors = {
const colors = {
fatal: "inverse red",
error: "bold redBright",
warn: "bold yellowBright",
@@ -20,7 +21,7 @@ export const colors = {
};
let maxLogLevel = levels.info;
export const logger = {};
const logger = {};
for (let [name, level] of Object.entries(levels)) {
// eslint-disable-next-line no-loop-func
logger[name] = message => {
@@ -41,6 +42,10 @@ for (let [name, level] of Object.entries(levels)) {
};
}
export function setLogLevel(level) {
maxLogLevel = level;
}
module.exports = {
colors,
levels,
setLogLevel: (level) => { maxLogLevel = level; },
logger,
};
+13 -25
View File
@@ -1,39 +1,27 @@
{
"name": "@clusterio/create",
"version": "2.0.3",
"description": "Installer for clusterio",
"homepage": "https://github.com/clusterio/clusterio#readme",
"author": "Hornwitser <github@hornwitser.no>",
"author": "Hornwitser",
"license": "MIT",
"keywords": [
"clusterio",
"factorio"
],
"repository": {
"type": "git",
"url": "https://github.com/clusterio/clusterio"
},
"bugs": {
"url": "https://github.com/clusterio/clusterio/issues"
},
"publishConfig": {
"access": "public"
},
"type": "module",
"version": "2.0.0-alpha.22b",
"main": "create.js",
"bin": {
"clusteriocreate": "./create.js"
},
"keywords": [
"clusterio",
"factorio"
],
"engines": {
"node": ">=22"
"node": ">=20"
},
"dependencies": {
"chalk": "catalog:",
"inquirer": "^8.2.7",
"yargs": "catalog:"
"fs-extra": "catalog:",
"inquirer": "^8.2.4",
"yargs": "^17.7.2"
},
"publishConfig": {
"access": "public"
}
}
+20 -24
View File
@@ -1,8 +1,9 @@
import { logger } from "./logging.js";
import fs from "node:fs/promises";
import path from "node:path";
"use strict";
const { logger } = require("./logging");
const fs = require("fs-extra");
const path = require("path");
import _package from "./package.json" with { type: "json" };
const _package = require("./package.json");
async function copyTemplateFile(src, dst, properties) {
logger.verbose(`Writing ${dst} from template ${src}`);
@@ -60,15 +61,9 @@ async function copyTemplateFile(src, dst, properties) {
return include ? content : "";
});
// Create folder for output file
const { dir } = path.parse(dst);
if (dir) {
await fs.mkdir(dir, { recursive: true });
}
// Attempt to write the output file, warn if it already exists
try {
await fs.writeFile(dst, outputData, { flag: "wx" });
await fs.outputFile(dst, outputData, { flag: "wx" });
} catch (err) {
if (err.code === "EEXIST") {
logger.warn(`Could not create file ${dst} because it already exists`);
@@ -78,7 +73,7 @@ async function copyTemplateFile(src, dst, properties) {
}
}
export async function copyPluginTemplates(pluginName, templates) {
async function copyPluginTemplates(pluginName, templates) {
logger.info(`Please wait, coping templates for ${templates.join(", ")}`);
const files = new Map();
const prepare = [];
@@ -88,18 +83,15 @@ export async function copyPluginTemplates(pluginName, templates) {
const config = !templates.includes("no_config") && templates.some(value => (
["controller", "host", "instance", "ctl"].includes(value)
));
const webBuild = config || templates.includes("controller") || templates.includes("web");
const webpack = config || templates.includes("controller") || templates.includes("web");
// A count of the number of isolated contexts the plugin runs under
let pluginContexts = 0;
// Get the file extension and path to the templates
const ext = javascriptOnly ? "js" : "ts";
const templatePath = path.resolve(
import.meta.dirname,
javascriptOnly ? "./templates/plugin-js" : "./templates/plugin-ts"
);
const commonPath = path.resolve(import.meta.dirname, "./templates/common");
const templatePath = path.resolve(__dirname, javascriptOnly ? "./templates/plugin-js" : "./templates/plugin-ts");
const commonPath = path.resolve(__dirname, "./templates/common");
// Files included in all templates
files.set(".gitignore", path.join(commonPath, "template.gitignore"));
@@ -113,15 +105,15 @@ export async function copyPluginTemplates(pluginName, templates) {
files.set("tsconfig.json", path.join(templatePath, "tsconfig.json"));
files.set("tsconfig.node.json", path.join(templatePath, "tsconfig.node.json"));
files.set("tsconfig.base.json", path.join(templatePath, "tsconfig.base.json"));
if (webBuild) {
if (webpack) {
files.set("tsconfig.browser.json", path.join(templatePath, "tsconfig.browser.json"));
}
}
// Files and dependences to support the web build
if (webBuild) {
prepare.push("vite build");
files.set("vite.config.js", path.join(commonPath, "vite.config.js"));
// Files and dependences to support webpack
if (webpack) {
prepare.push("webpack-cli --mode production");
files.set("webpack.config.js", path.join(commonPath, "webpack.config.js"));
if (templates.includes("web")) {
files.set(`web/index.${ext}x`, path.join(templatePath, `web/plugin.${ext}x`));
pluginContexts += 1;
@@ -185,7 +177,7 @@ export async function copyPluginTemplates(pluginName, templates) {
ctl: templates.includes("ctl"),
web: templates.includes("web"),
// Macro flags for context requirements
web_build: webBuild,
webpack: webpack,
config: config,
// String values for package json
clusterio_version: _package.version,
@@ -204,3 +196,7 @@ export async function copyPluginTemplates(pluginName, templates) {
await Promise.all(writes);
logger.info("Successfully wrote all template files");
}
module.exports = {
copyPluginTemplates,
};
+13 -34
View File
@@ -2,7 +2,6 @@
"name": "__plugin_name__",
"version": "0.1.0",
"description": "Example Description. Package. Change me in package.json",
"type": "module",
//%if typescript
"main": "dist/node/index.js",
//%endif
@@ -19,6 +18,9 @@
//%if controller
"@clusterio/controller": "^__clusterio_version__",
//%endif
//%if webpack
"@clusterio/web_ui": "^__clusterio_version__",
//%endif
//%if host
"@clusterio/host": "^__clusterio_version__",
//%endif
@@ -27,53 +29,30 @@
//%endif
"@clusterio/lib": "^__clusterio_version__"
},
//%if controller | host | ctl
"peerDependenciesMeta": {
//%endif
//%if controller
"@clusterio/controller": {
"optional": true
//%endif
//%if controller & host | controller & ctl
},
//%endif
//%if host
"@clusterio/host": {
"optional": true
//%endif
//%if host & ctl
},
//%endif
//%if ctl
"@clusterio/ctl": {
"optional": true
//%endif
//%if controller | host | ctl
}
},
//%endif
"devDependencies": {
//%// Typescript and type declarations
//%if typescript
"typescript": "^7.0.2",
"@types/node": "^24.13.4",
"typescript": "^5.5.3",
"@types/node": "^20.4.5",
//%endif
//%if typescript & web
"@types/react": "^18.3.31",
"@types/react": "^18.2.21",
//%endif
//%// Everything else
//%if web
"antd": "^5.29.3",
"antd": "^5.13.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
//%endif
//%if web_build
"vite": "^8.3.4",
//%if webpack
"webpack": "^5.88.2",
"webpack-cli": "^5.1.4",
"webpack-merge": "^5.9.0",
//%endif
//%if controller
"@clusterio/controller": "^__clusterio_version__",
//%endif
//%if web_build
//%if webpack
"@clusterio/web_ui": "^__clusterio_version__",
//%endif
//%if host
@@ -85,7 +64,7 @@
"@clusterio/lib": "^__clusterio_version__"
},
"dependencies": {
"@sinclair/typebox": "^0.34.52"
"@sinclair/typebox": "^0.30.4"
},
"publishConfig": {
"access": "public"
@@ -1,3 +1,3 @@
vite.config.js
webpack.config.js
dist/browser
dist/web
@@ -1,6 +0,0 @@
import { defineConfig } from "vite";
import { clusterioPlugin } from "@clusterio/web_ui/vite.mjs";
export default defineConfig({
plugins: [clusterioPlugin()],
});
@@ -0,0 +1,34 @@
"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
},
}),
],
});
@@ -1,66 +1,85 @@
import * as lib from "@clusterio/lib";
"use strict";
const lib = require("@clusterio/lib");
const { BaseControllerPlugin } = require("@clusterio/controller");
//%if multi_context // Messages requires multi context
import {
const {
PluginExampleEvent, PluginExampleRequest,
//%endif
//%if controller & web // Subscribing requires web content and the controller
ExampleSubscribableUpdate, ExampleSubscribableValue,
//%endif
//%if multi_context // Messages requires multi context
} from "./messages.js";
} = require("./messages");
//%endif
export default async function loadControllerPlugin(context) {
const { controller, logger, plugin } = context;
class ControllerPlugin extends BaseControllerPlugin {
//%if controller & web // Subscribing requires web content and the controller
// If needed, replace with loading from database file such as lib.Datastore
const exampleDatabase = new Map([["foo", new ExampleSubscribableValue("foo", 0, false)]]);
exampleDatabase;
storageDirty = false;
//%endif
//%if multi_context // Subscribing requires multi context
async init() {
this.controller.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
this.controller.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
//%endif
//%if controller & web // Subscribing requires web content and the controller
this.controller.subscriptions.handle(ExampleSubscribableUpdate, this.handleExampleSubscription.bind(this));
// If needed, replace with loading from database file such as lib.Datastore
this.exampleDatabase = new Map([["foo", new ExampleSubscribableValue("foo", 0, false)]]);
//%endif
//%if multi_context // Subscribing requires multi context
}
//%endif
//%if multi_context // Messages requires multi context
controller.handle(PluginExampleEvent, async (event) => {
logger.info(JSON.stringify(event));
});
async onControllerConfigFieldChanged(field, curr, prev) {
this.logger.info(`controller::onControllerConfigFieldChanged ${field}`);
}
//%if instance
controller.handle(PluginExampleRequest, async (request) => {
logger.info(JSON.stringify(request));
async onInstanceConfigFieldChanged(instance, field, curr, prev) {
this.logger.info(`controller::onInstanceConfigFieldChanged ${instance.id} ${field}`);
}
//%endif
async onSaveData() {
this.logger.info("controller::onSaveData");
}
async onShutdown() {
this.logger.info("controller::onShutdown");
}
async onPlayerEvent(instance, event) {
this.logger.info(`controller::onPlayerEvent ${instance.id} ${JSON.stringify(event)}`);
}
//%if multi_context
async handlePluginExampleEvent(event) {
this.logger.info(JSON.stringify(event));
}
async handlePluginExampleRequest(request) {
this.logger.info(JSON.stringify(request));
return {
myResponseString: request.myString,
myResponseNumbers: request.myNumberArray,
};
});
}
//%endif
//%if controller & web // Subscribing requires web content and the controller
controller.subscriptions.handle(ExampleSubscribableUpdate, async (request) => {
logger.info(JSON.stringify(request));
const values = [...exampleDatabase.values()].filter(
async handleExampleSubscription(request) {
this.logger.info(JSON.stringify(request));
const values = [...this.exampleDatabase.values()].filter(
value => value.updatedAtMs > request.lastRequestTimeMs,
);
return values.length ? new ExampleSubscribableUpdate(values) : null;
});
}
//%endif
controller.hooks.controllerConfigFieldChanged.attach(plugin.name, async (field, curr, prev) => {
logger.info(`controller::controllerConfigFieldChanged ${field}`);
});
//%if instance
controller.hooks.instanceConfigFieldChanged.attach(plugin.name, async (instance, field, curr, prev) => {
logger.info(`controller::instanceConfigFieldChanged ${instance.id} ${field}`);
});
//%endif
controller.hooks.save.attach(plugin.name, async () => {
logger.info("controller::save");
});
controller.hooks.shutdown.attach(plugin.name, async () => {
logger.info("controller::shutdown");
});
controller.hooks.playerEvent.attach(plugin.name, async (instance, event) => {
logger.info(`controller::playerEvent ${instance.id} ${JSON.stringify(event)}`);
});
}
module.exports = {
ControllerPlugin,
};
+13 -7
View File
@@ -1,6 +1,8 @@
import { CommandTree, Command } from "@clusterio/lib";
"use strict";
const { BaseCtlPlugin } = require("@clusterio/ctl");
const { CommandTree, Command } = require("@clusterio/lib");
//%// We do not check for multi context here because it doesn't make sense to have a ctl without messages
import { PluginExampleEvent, PluginExampleRequest } from "./messages.js";
const { PluginExampleEvent, PluginExampleRequest } = require("./messages");
/* eslint-disable no-console */
const pluginCommands = new CommandTree({
@@ -11,16 +13,20 @@ pluginCommands.add(new Command({
definition: ["my-command <foo>", "My command description that I really should change", (yargs) => {
yargs.positional("foo", { describe: "foo is foo!", type: "string" });
}],
handler: async function(args, ctl) {
const response = await ctl.sendTo("controller", new PluginExampleRequest(args.foo, [1, 2, 3]));
handler: async function(args, control) {
const response = await control.sendTo("controller", new PluginExampleRequest(args.foo, [1, 2, 3]));
console.log(response);
console.log(args);
},
}));
/* eslint-enable no-console */
export default async function loadCtlPlugin(context) {
context.hooks.addCommands.attach(context.plugin.name, async (rootCommand) => {
class CtlPlugin extends BaseCtlPlugin {
async addCommands(rootCommand) {
rootCommand.add(pluginCommands);
});
}
}
module.exports = {
CtlPlugin,
};
+29 -18
View File
@@ -1,30 +1,41 @@
import * as lib from "@clusterio/lib";
"use strict";
const lib = require("@clusterio/lib");
const { BaseHostPlugin } = require("@clusterio/host");
//%if multi_context
import { PluginExampleEvent, PluginExampleRequest } from "./messages.js";
const { PluginExampleEvent, PluginExampleRequest } = require("./messages");
//%endif
export default async function loadHostPlugin(context) {
const { host, logger, plugin } = context;
class HostPlugin extends BaseHostPlugin {
//%if multi_context
async init() {
this.host.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
this.host.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
}
//%endif
async onHostConfigFieldChanged(field, curr, prev) {
this.logger.info(`host::onInstanceConfigFieldChanged ${field}`);
}
async onShutdown() {
this.logger.info("host::onShutdown");
}
//%if multi_context
host.handle(PluginExampleEvent, async (event) => {
logger.info(JSON.stringify(event));
});
async handlePluginExampleEvent(event) {
this.logger.info(JSON.stringify(event));
}
host.handle(PluginExampleRequest, async (request) => {
logger.info(JSON.stringify(request));
async handlePluginExampleRequest(request) {
this.logger.info(JSON.stringify(request));
return {
myResponseString: request.myString,
myResponseNumbers: request.myNumberArray,
};
});
}
//%endif
host.hooks.hostConfigFieldChanged.attach(plugin.name, async (field, curr, prev) => {
logger.info(`host::hostConfigFieldChanged ${field}`);
});
host.hooks.shutdown.attach(plugin.name, async () => {
logger.info("host::shutdown");
});
}
module.exports = {
HostPlugin,
};
+47 -47
View File
@@ -1,9 +1,42 @@
import * as lib from "@clusterio/lib";
"use strict";
const lib = require("@clusterio/lib");
//%if multi_context
import * as Messages from "./messages.js";
const Messages = require("./messages");
lib.definePermission({
name: "__plugin_name__.example.permission.event",
title: "Example permission event",
description: "Example Description. Event. Change me in index.js",
grantByDefault: true,
});
lib.definePermission({
name: "__plugin_name__.example.permission.request",
title: "Example permission request",
description: "Example Description. Request. Change me in index.js",
grantByDefault: true,
});
//%endif
//%if controller & web // Subscribing requires web content and the controller
lib.definePermission({
name: "__plugin_name__.example.permission.subscribe",
title: "Example permission subscribe",
description: "Example Description. Subscribe. Change me in index.js",
grantByDefault: true,
});
//%endif
//%if web
lib.definePermission({
name: "__plugin_name__.page.view",
title: "Example page view permission",
description: "Example Description. View. Change me in index.js",
grantByDefault: true,
});
//%endif
export const plugin = {
const plugin = {
name: "__plugin_name__",
title: "__plugin_name__",
description: "Example Description. Plugin. Change me in index.js",
@@ -11,7 +44,7 @@ export const plugin = {
//%endif
//%if controller
controllerEntrypoint: "./dist/node/controller.js",
controllerEntrypoint: "./dist/node/controller",
//%endif
//%if controller & config
controllerConfigFields: {
@@ -27,7 +60,7 @@ export const plugin = {
//%endif
//%if host
hostEntrypoint: "./dist/node/host.js",
hostEntrypoint: "./dist/node/host",
//%endif
//%if host & config
hostConfigFields: {
@@ -43,7 +76,7 @@ export const plugin = {
//%endif
//%if instance | module // Modules load an empty instance plugin
instanceEntrypoint: "./dist/node/instance.js",
instanceEntrypoint: "./dist/node/instance",
//%endif
//%if instance & config
instanceConfigFields: {
@@ -59,12 +92,12 @@ export const plugin = {
//%endif
//%if ctl
ctlEntrypoint: "./dist/node/ctl.js",
ctlEntrypoint: "./dist/node/ctl",
//%endif
//%if ctl & config
ctlConfigFields: {
"__plugin_name__.myCtlField": {
title: "My Ctl Field",
controlConfigFields: {
"__plugin_name__.myControlField": {
title: "My Control Field",
description: "This should be removed from index.js",
type: "string",
initialValue: "Remove Me",
@@ -83,43 +116,6 @@ export const plugin = {
//%if multi_context // Subscribing requires multi context
],
//%endif
//%if multi_context | web
permissions: [
//%endif
//%if multi_context
{
name: "__plugin_name__.example.permission.event",
title: "Example permission event",
description: "Example Description. Event. Change me in index.js",
grantByDefault: true,
},
{
name: "__plugin_name__.example.permission.request",
title: "Example permission request",
description: "Example Description. Request. Change me in index.js",
grantByDefault: true,
},
//%endif
//%if controller & web // Subscribing requires web content and the controller
{
name: "__plugin_name__.example.permission.subscribe",
title: "Example permission subscribe",
description: "Example Description. Subscribe. Change me in index.js",
grantByDefault: true,
},
//%endif
//%if web
{
name: "__plugin_name__.page.view",
title: "Example page view permission",
description: "Example Description. View. Change me in index.js",
grantByDefault: true,
},
//%endif
//%if multi_context | web
],
//%endif
//%if web // Web content template has an example route which is the plugin name
webEntrypoint: "./web",
@@ -128,3 +124,7 @@ export const plugin = {
],
//%endif
};
module.exports = {
plugin,
};
+59 -41
View File
@@ -1,56 +1,74 @@
import * as lib from "@clusterio/lib";
"use strict";
const lib = require("@clusterio/lib");
const { BaseInstancePlugin } = require("@clusterio/host");
//%if multi_context
import { PluginExampleEvent, PluginExampleRequest } from "./messages.js";
const { PluginExampleEvent, PluginExampleRequest } = require("./messages");
//%endif
export default async function loadInstancePlugin(context) {
const { instance, logger, plugin } = context;
class InstancePlugin extends BaseInstancePlugin {
//%if multi_context | module
async init() {
//%if multi_context
this.instance.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
this.instance.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
//%endif
//%if module
this.instance.server.handle("__plugin_name__-plugin_example_ipc", this.handlePluginExampleIPC.bind(this));
//%endif
//%if multi_context | module
}
//%endif
async onInstanceConfigFieldChanged(field, curr, prev) {
this.logger.info(`instance::onInstanceConfigFieldChanged ${field}`);
}
async onStart() {
// Called once rcon becomes available
this.logger.info("instance::onStart");
}
async onStop() {
// Called during normal exits before rcon becomes unavailable
this.logger.info("instance::onStop");
}
onExit() {
// Called during all exits, including crashes and init failures, rcon is not available
this.logger.info("instance::onExit");
}
async onPlayerEvent(event) {
this.logger.info(`onPlayerEvent::onPlayerEvent ${JSON.stringify(event)}`);
//%if module
if (this.instance.status === "running") {
await this.sendRcon("/sc __plugin_name__.foo()");
}
//%endif
}
//%if multi_context
instance.handle(PluginExampleEvent, async (event) => {
logger.info(JSON.stringify(event));
});
async handlePluginExampleEvent(event) {
this.logger.info(JSON.stringify(event));
}
instance.handle(PluginExampleRequest, async (request) => {
logger.info(JSON.stringify(request));
async handlePluginExampleRequest(request) {
this.logger.info(JSON.stringify(request));
return {
myResponseString: request.myString,
myResponseNumbers: request.myNumberArray,
};
});
}
//%endif
//%if module
instance.server.handle("__plugin_name__-plugin_example_ipc", async (event) => {
logger.info(JSON.stringify(event));
});
async handlePluginExampleIPC(event) {
this.logger.info(JSON.stringify(event));
}
//%endif
instance.hooks.instanceConfigFieldChanged.attach(plugin.name, async (field, curr, prev) => {
logger.info(`instance::instanceConfigFieldChanged ${field}`);
});
instance.hooks.start.attach(plugin.name, async () => {
// Called once rcon becomes available
logger.info("instance::start");
});
instance.hooks.stop.attach(plugin.name, async () => {
// Called during normal exits before rcon becomes unavailable
logger.info("instance::stop");
});
instance.hooks.exit.attach(plugin.name, () => {
// Called during all exits, including crashes and init failures, rcon is not available
logger.info("instance::exit");
});
instance.hooks.playerEvent.attach(plugin.name, async (event) => {
logger.info(`instance::playerEvent ${JSON.stringify(event)}`);
//%if module
if (instance.status === "running") {
await instance.sendRcon("/sc __plugin_name__.foo()", false, plugin.name);
}
//%endif
});
}
module.exports = {
InstancePlugin,
};
@@ -1,3 +1,11 @@
// This entrypoint is empty because an instance entrypoint must be defined for a module to be injected
// This requirement may change in the future to allow for standalone modules
export default async function loadInstancePlugin(context) { }
"use strict";
const { BaseInstancePlugin } = require("@clusterio/host");
class InstancePlugin extends BaseInstancePlugin {
// This class is empty because an instance plugin must be defined for a module to be injected
// This requirement may change in the future to allow for standalone modules
}
module.exports = {
InstancePlugin,
};
@@ -1,7 +1,8 @@
import { plainJson, jsonArray, JsonBoolean, JsonNumber, JsonString, StringEnum } from "@clusterio/lib";
import { Type } from "@sinclair/typebox";
"use strict";
const { plainJson, jsonArray, JsonBoolean, JsonNumber, JsonString, StringEnum } = require("@clusterio/lib");
const { Type } = require("@sinclair/typebox");
export class PluginExampleEvent {
class PluginExampleEvent {
static type = "event";
static src = ["host", "control"];
static dst = ["controller", "host", "instance"];
@@ -23,7 +24,7 @@ export class PluginExampleEvent {
}
}
export class PluginExampleRequest {
class PluginExampleRequest {
static type = "request";
static src = ["host", "control"];
static dst = ["controller", "host", "instance"];
@@ -51,7 +52,7 @@ export class PluginExampleRequest {
}
//%if controller & web // Subscribing requires web content and the controller
export class ExampleSubscribableValue {
class ExampleSubscribableValue {
constructor(id, updatedAtMs, isDeleted) {
this.id = id;
this.updatedAtMs = updatedAtMs;
@@ -69,7 +70,7 @@ export class ExampleSubscribableValue {
}
}
export class ExampleSubscribableUpdate {
class ExampleSubscribableUpdate {
static type = "event";
static src = "controller";
static dst = "control";
@@ -89,3 +90,12 @@ export class ExampleSubscribableUpdate {
}
}
//%endif
module.exports = {
PluginExampleEvent,
PluginExampleRequest,
//%if controller & web // Subscribing requires web content and the controller
ExampleSubscribableValue,
ExampleSubscribableUpdate,
//%endif
};
@@ -10,7 +10,7 @@ import React, {
// } from "antd";
import {
PageLayout, PageHeader, Control, ControlContext, notifyErrorHandler,
BaseWebPlugin, PageLayout, PageHeader, Control, ControlContext, notifyErrorHandler,
} from "@clusterio/web_ui";
//%if multi_context // Messages requires multi context
@@ -21,26 +21,16 @@ import {
ExampleSubscribableUpdate, ExampleSubscribableValue,
//%endif
//%if multi_context // Messages requires multi context
} from "../messages.js";
} from "../messages";
//%endif
import * as lib from "@clusterio/lib";
//%if controller // Subscribing requires web content and the controller
// Created when the plugin is loaded, before any page is rendered
let exampleSubscriber;
function useSubscribableData() {
const control = useContext(ControlContext);
const subscribe = useCallback((callback) => exampleSubscriber.subscribe(callback), [control]);
return useSyncExternalStore(subscribe, () => exampleSubscriber.getSnapshot());
}
//%endif
function MyTemplatePage() {
let control = useContext(ControlContext);
//%if controller // Subscribing requires web content and the controller
const [subscribableData, synced] = useSubscribableData();
const plugin = control.plugins.get("__plugin_name__");
const [subscribableData, synced] = plugin.useSubscribableData();
//%endif
return <PageLayout nav={[{ name: "__plugin_name__" }]}>
@@ -51,39 +41,53 @@ function MyTemplatePage() {
</PageLayout>;
}
export default async function loadWebPlugin(context) {
const { control, logger, plugin } = context;
export class WebPlugin extends BaseWebPlugin {
//%if controller // Subscribing requires web content and the controller
exampleSubscriber = new lib.MapSubscriber(ExampleSubscribableUpdate, control);
//%endif
subscribableData = new lib.EventSubscriber(ExampleSubscribableUpdate, this.control);
control.hooks.pages.attach(plugin.name, () => [
{
path: "/__plugin_name__",
sidebarName: "__plugin_name__",
// This permission is client side only, so it must match the permission string of a resource request to be secure
// An undefined value means that the page will always be visible
//%endif
async init() {
this.pages = [
{
path: "/__plugin_name__",
sidebarName: "__plugin_name__",
// This permission is client side only, so it must match the permission string of a resource request to be secure
// An undefined value means that the page will always be visible
//%if controller // Subscribing requires web content and the controller
permission: "__plugin_name__.example.permission.subscribe",
permission: "__plugin_name__.example.permission.subscribe",
//%endif
//%if !controller
permission: "__plugin_name__.page.view",
permission: "__plugin_name__.example.permission.view",
//%endif
content: <MyTemplatePage/>,
},
]);
content: <MyTemplatePage/>,
},
];
//%if multi_context
control.handle(PluginExampleEvent, async (event) => {
logger.info(JSON.stringify(event));
});
this.control.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
this.control.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
//%endif
}
//%if controller// Subscribing requires web content and the controller
control.handle(PluginExampleRequest, async (request) => {
logger.info(JSON.stringify(request));
useSubscribableData() {
const control = useContext(ControlContext);
const subscribe = useCallback((callback) => this.subscribableData.subscribe(callback), [control]);
return useSyncExternalStore(subscribe, () => this.subscribableData.getSnapshot());
}
//%endif
//%if multi_context
async handlePluginExampleEvent(event) {
this.logger.info(JSON.stringify(event));
}
async handlePluginExampleRequest(request) {
this.logger.info(JSON.stringify(request));
return {
myResponseString: request.myString,
myResponseNumbers: request.myNumberArray,
};
});
}
//%endif
}
@@ -1,5 +1,5 @@
import * as lib from "@clusterio/lib";
import type { ControllerPluginContext, InstanceRecord } from "@clusterio/controller";
import { BaseControllerPlugin, InstanceInfo } from "@clusterio/controller";
//%if multi_context // Messages requires multi context
import {
@@ -9,61 +9,72 @@ import {
ExampleSubscribableUpdate, ExampleSubscribableValue,
//%endif
//%if multi_context // Messages requires multi context
} from "./messages.js";
} from "./messages";
//%endif
export default async function loadControllerPlugin(context: ControllerPluginContext) {
const { controller, logger, plugin } = context;
export class ControllerPlugin extends BaseControllerPlugin {
//%if controller & web // Subscribing requires web content and the controller
// If needed, replace with loading from database file such as lib.Datastore
const exampleDatabase = new Map([["foo", new ExampleSubscribableValue("foo", 0, false)]]);
exampleDatabase!: Map<string, ExampleSubscribableValue>;
storageDirty = false;
//%endif
//%if multi_context // Subscribing requires multi context
async init() {
this.controller.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
this.controller.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
//%endif
//%if controller & web // Subscribing requires web content and the controller
this.controller.subscriptions.handle(ExampleSubscribableUpdate, this.handleExampleSubscription.bind(this));
// If needed, replace with loading from database file such as lib.Datastore
this.exampleDatabase = new Map([["foo", new ExampleSubscribableValue("foo", 0, false)]]);
//%endif
//%if multi_context // Subscribing requires multi context
}
//%endif
//%if multi_context // Messages requires multi context
controller.handle(PluginExampleEvent, async (event: PluginExampleEvent) => {
logger.info(JSON.stringify(event));
});
async onControllerConfigFieldChanged(field: string, curr: unknown, prev: unknown) {
this.logger.info(`controller::onControllerConfigFieldChanged ${field}`);
}
//%if instance
controller.handle(PluginExampleRequest, async (request: PluginExampleRequest) => {
logger.info(JSON.stringify(request));
async onInstanceConfigFieldChanged(instance: InstanceInfo, field: string, curr: unknown, prev: unknown) {
this.logger.info(`controller::onInstanceConfigFieldChanged ${instance.id} ${field}`);
}
//%endif
async onSaveData() {
this.logger.info("controller::onSaveData");
}
async onShutdown() {
this.logger.info("controller::onShutdown");
}
async onPlayerEvent(instance: InstanceInfo, event: lib.PlayerEvent) {
this.logger.info(`controller::onPlayerEvent ${instance.id} ${JSON.stringify(event)}`);
}
//%if multi_context
async handlePluginExampleEvent(event: PluginExampleEvent) {
this.logger.info(JSON.stringify(event));
}
async handlePluginExampleRequest(request: PluginExampleRequest) {
this.logger.info(JSON.stringify(request));
return {
myResponseString: request.myString,
myResponseNumbers: request.myNumberArray,
};
});
}
//%endif
//%if controller & web // Subscribing requires web content and the controller
controller.subscriptions.handle(ExampleSubscribableUpdate, async (request: lib.SubscriptionRequest) => {
logger.info(JSON.stringify(request));
const values = [...exampleDatabase.values()].filter(
async handleExampleSubscription(request: lib.SubscriptionRequest) {
this.logger.info(JSON.stringify(request));
const values = [...this.exampleDatabase.values()].filter(
value => value.updatedAtMs > request.lastRequestTimeMs,
);
return values.length ? new ExampleSubscribableUpdate(values) : null;
});
}
//%endif
controller.hooks.controllerConfigFieldChanged.attach(plugin.name, async (field, curr, prev) => {
logger.info(`controller::controllerConfigFieldChanged ${field}`);
});
//%if instance
controller.hooks.instanceConfigFieldChanged.attach(
plugin.name, async (instance: InstanceRecord, field, curr, prev) => {
logger.info(`controller::instanceConfigFieldChanged ${instance.id} ${field}`);
}
);
//%endif
controller.hooks.save.attach(plugin.name, async () => {
logger.info("controller::save");
});
controller.hooks.shutdown.attach(plugin.name, async () => {
logger.info("controller::shutdown");
});
controller.hooks.playerEvent.attach(plugin.name, async (instance: InstanceRecord, event: lib.PlayerEvent) => {
logger.info(`controller::playerEvent ${instance.id} ${JSON.stringify(event)}`);
});
}
+7 -7
View File
@@ -1,7 +1,7 @@
import type { Ctl, CtlPluginContext } from "@clusterio/ctl";
import { BaseCtlPlugin, type Control } from "@clusterio/ctl";
import { CommandTree, Command } from "@clusterio/lib";
//%// We do not check for multi context here because it doesn't make sense to have a ctl without messages
import { PluginExampleEvent, PluginExampleRequest } from "./messages.js";
import { PluginExampleEvent, PluginExampleRequest } from "./messages";
/* eslint-disable no-console */
const pluginCommands = new CommandTree({
@@ -12,16 +12,16 @@ pluginCommands.add(new Command({
definition: ["my-command <foo>", "My command description that I really should change", (yargs) => {
yargs.positional("foo", { describe: "foo is foo!", type: "string" });
}],
handler: async function(args: { foo: string }, ctl: Ctl) {
const response = await ctl.sendTo("controller", new PluginExampleRequest(args.foo, [1, 2, 3]));
handler: async function(args: { foo: string }, control: Control) {
const response = await control.sendTo("controller", new PluginExampleRequest(args.foo, [1, 2, 3]));
console.log(response);
console.log(args);
},
}));
/* eslint-enable no-console */
export default async function loadCtlPlugin(context: CtlPluginContext) {
context.hooks.addCommands.attach(context.plugin.name, async (rootCommand) => {
export class CtlPlugin extends BaseCtlPlugin {
async addCommands(rootCommand: CommandTree) {
rootCommand.add(pluginCommands);
});
}
}
+24 -18
View File
@@ -1,30 +1,36 @@
import type { HostPluginContext } from "@clusterio/host";
import * as lib from "@clusterio/lib";
import { BaseHostPlugin } from "@clusterio/host";
//%if multi_context
import { PluginExampleEvent, PluginExampleRequest } from "./messages.js";
import { PluginExampleEvent, PluginExampleRequest } from "./messages";
//%endif
export default async function loadHostPlugin(context: HostPluginContext) {
const { host, logger, plugin } = context;
export class HostPlugin extends BaseHostPlugin {
//%if multi_context
async init() {
this.host.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
this.host.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
}
//%endif
async onHostConfigFieldChanged(field: string, curr: unknown, prev: unknown) {
this.logger.info(`host::onInstanceConfigFieldChanged ${field}`);
}
async onShutdown() {
this.logger.info("host::onShutdown");
}
//%if multi_context
host.handle(PluginExampleEvent, async (event: PluginExampleEvent) => {
logger.info(JSON.stringify(event));
});
async handlePluginExampleEvent(event: PluginExampleEvent) {
this.logger.info(JSON.stringify(event));
}
host.handle(PluginExampleRequest, async (request: PluginExampleRequest) => {
logger.info(JSON.stringify(request));
async handlePluginExampleRequest(request: PluginExampleRequest) {
this.logger.info(JSON.stringify(request));
return {
myResponseString: request.myString,
myResponseNumbers: request.myNumberArray,
};
});
}
//%endif
host.hooks.hostConfigFieldChanged.attach(plugin.name, async (field, curr, prev) => {
logger.info(`host::hostConfigFieldChanged ${field}`);
});
host.hooks.shutdown.attach(plugin.name, async () => {
logger.info("host::shutdown");
});
}
+44 -65
View File
@@ -1,8 +1,40 @@
import * as lib from "@clusterio/lib";
//%if multi_context
import * as Messages from "./messages.js";
import * as Messages from "./messages";
lib.definePermission({
name: "__plugin_name__.example.permission.event",
title: "Example permission event",
description: "Example Description. Event. Change me in index.ts",
grantByDefault: true,
});
lib.definePermission({
name: "__plugin_name__.example.permission.request",
title: "Example permission request",
description: "Example Description. Request. Change me in index.ts",
grantByDefault: true,
});
//%endif
//%if config | multi_context | web
//%if controller & web // Subscribing requires web content and the controller
lib.definePermission({
name: "__plugin_name__.example.permission.subscribe",
title: "Example permission subscribe",
description: "Example Description. Subscribe. Change me in index.ts",
grantByDefault: true,
});
//%endif
//%if web
lib.definePermission({
name: "__plugin_name__.page.view",
title: "Example page view permission",
description: "Example Description. View. Change me in index.ts",
grantByDefault: true,
});
//%endif
//%if config
declare module "@clusterio/lib" {
//%endif
@@ -22,27 +54,11 @@ declare module "@clusterio/lib" {
}
//%endif
//%if ctl & config
export interface CtlConfigFields {
"__plugin_name__.myCtlField": string;
export interface ControlConfigFields {
"__plugin_name__.myControlField": string;
}
//%endif
//%if multi_context | web
export interface Permissions {
//%endif
//%if multi_context
"__plugin_name__.example.permission.event": never;
"__plugin_name__.example.permission.request": never;
//%endif
//%if controller & web // Subscribing requires web content and the controller
"__plugin_name__.example.permission.subscribe": never;
//%endif
//%if web
"__plugin_name__.page.view": never;
//%endif
//%if multi_context | web
}
//%endif
//%if config | multi_context | web
//%if config
}
//%endif
@@ -55,7 +71,7 @@ export const plugin: lib.PluginDeclaration = {
//%endif
//%if controller
controllerEntrypoint: "./dist/node/controller.js",
controllerEntrypoint: "./dist/node/controller",
//%endif
//%if controller & config
controllerConfigFields: {
@@ -71,7 +87,7 @@ export const plugin: lib.PluginDeclaration = {
//%endif
//%if host
hostEntrypoint: "./dist/node/host.js",
hostEntrypoint: "./dist/node/host",
//%endif
//%if host & config
hostConfigFields: {
@@ -87,7 +103,7 @@ export const plugin: lib.PluginDeclaration = {
//%endif
//%if instance | module // Modules load an empty instance plugin
instanceEntrypoint: "./dist/node/instance.js",
instanceEntrypoint: "./dist/node/instance",
//%endif
//%if instance & config
instanceConfigFields: {
@@ -103,12 +119,12 @@ export const plugin: lib.PluginDeclaration = {
//%endif
//%if ctl
ctlEntrypoint: "./dist/node/ctl.js",
ctlEntrypoint: "./dist/node/ctl",
//%endif
//%if ctl & config
ctlConfigFields: {
"__plugin_name__.myCtlField": {
title: "My Ctl Field",
controlConfigFields: {
"__plugin_name__.myControlField": {
title: "My Control Field",
description: "This should be removed from index.js",
type: "string",
initialValue: "Remove Me",
@@ -127,43 +143,6 @@ export const plugin: lib.PluginDeclaration = {
//%if multi_context // Subscribing requires multi context
],
//%endif
//%if multi_context | web
permissions: [
//%endif
//%if multi_context
{
name: "__plugin_name__.example.permission.event",
title: "Example permission event",
description: "Example Description. Event. Change me in index.ts",
grantByDefault: true,
},
{
name: "__plugin_name__.example.permission.request",
title: "Example permission request",
description: "Example Description. Request. Change me in index.ts",
grantByDefault: true,
},
//%endif
//%if controller & web // Subscribing requires web content and the controller
{
name: "__plugin_name__.example.permission.subscribe",
title: "Example permission subscribe",
description: "Example Description. Subscribe. Change me in index.ts",
grantByDefault: true,
},
//%endif
//%if web
{
name: "__plugin_name__.page.view",
title: "Example page view permission",
description: "Example Description. View. Change me in index.ts",
grantByDefault: true,
},
//%endif
//%if multi_context | web
],
//%endif
//%if web // Web content template has an example route which is the plugin name
webEntrypoint: "./web",
+54 -42
View File
@@ -1,64 +1,76 @@
import * as lib from "@clusterio/lib";
import type { InstancePluginContext } from "@clusterio/host";
import { BaseInstancePlugin } from "@clusterio/host";
//%if multi_context
import { PluginExampleEvent, PluginExampleRequest } from "./messages.js";
import { PluginExampleEvent, PluginExampleRequest } from "./messages";
//%endif
//%if module
type PluginExampleIPC = {
type PuginExampleIPC = {
tick: number,
player_name: string,
};
//%endif
export default async function loadInstancePlugin(context: InstancePluginContext) {
const { instance, logger, plugin } = context;
export class InstancePlugin extends BaseInstancePlugin {
//%if multi_context | module
async init() {
//%endif
//%if multi_context
this.instance.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
this.instance.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
//%endif
//%if module
this.instance.server.handle("__plugin_name__-plugin_example_ipc", this.handlePluginExampleIPC.bind(this));
//%endif
//%if multi_context | module
}
//%endif
async onInstanceConfigFieldChanged(field: string, curr: unknown, prev: unknown) {
this.logger.info(`instance::onInstanceConfigFieldChanged ${field}`);
}
async onStart() {
// Called once rcon becomes available
this.logger.info("instance::onStart");
}
async onStop() {
// Called during normal exits before rcon becomes unavailable
this.logger.info("instance::onStop");
}
onExit() {
// Called during all exits, including crashes and init failures, rcon is not available
this.logger.info("instance::onExit");
}
async onPlayerEvent(event: lib.PlayerEvent) {
this.logger.info(`onPlayerEvent::onPlayerEvent ${JSON.stringify(event)}`);
//%if module
if (this.instance.status === "running") {
await this.sendRcon("/sc __plugin_name__.foo()");
}
//%endif
}
//%if multi_context
instance.handle(PluginExampleEvent, async (event: PluginExampleEvent) => {
logger.info(JSON.stringify(event));
});
async handlePluginExampleEvent(event: PluginExampleEvent) {
this.logger.info(JSON.stringify(event));
}
instance.handle(PluginExampleRequest, async (request: PluginExampleRequest) => {
logger.info(JSON.stringify(request));
async handlePluginExampleRequest(request: PluginExampleRequest) {
this.logger.info(JSON.stringify(request));
return {
myResponseString: request.myString,
myResponseNumbers: request.myNumberArray,
};
});
}
//%endif
//%if module
instance.server.handle("__plugin_name__-plugin_example_ipc", async (event: PluginExampleIPC) => {
logger.info(JSON.stringify(event));
});
async handlePluginExampleIPC(event: PuginExampleIPC) {
this.logger.info(JSON.stringify(event));
}
//%endif
instance.hooks.instanceConfigFieldChanged.attach(plugin.name, async (field, curr, prev) => {
logger.info(`instance::instanceConfigFieldChanged ${field}`);
});
instance.hooks.start.attach(plugin.name, async () => {
// Called once rcon becomes available
logger.info("instance::start");
});
instance.hooks.stop.attach(plugin.name, async () => {
// Called during normal exits before rcon becomes unavailable
logger.info("instance::stop");
});
instance.hooks.exit.attach(plugin.name, () => {
// Called during all exits, including crashes and init failures, rcon is not available
logger.info("instance::exit");
});
instance.hooks.playerEvent.attach(plugin.name, async (event: lib.PlayerEvent) => {
logger.info(`instance::playerEvent ${JSON.stringify(event)}`);
//%if module
if (instance.status === "running") {
await instance.sendRcon("/sc __plugin_name__.foo()", false, plugin.name);
}
//%endif
});
}
@@ -1,5 +1,7 @@
import type { InstancePluginContext } from "@clusterio/host";
import * as lib from "@clusterio/lib";
import { BaseInstancePlugin } from "@clusterio/host";
// This entrypoint is empty because an instance entrypoint must be defined for a module to be injected
// This requirement may change in the future to allow for standalone modules
export default async function loadInstancePlugin(context: InstancePluginContext) { }
export class InstancePlugin extends BaseInstancePlugin {
// This class is empty because an instance plugin must be defined for a module to be injected
// This requirement may change in the future to allow for standalone modules
}
@@ -9,5 +9,5 @@
"noEmitOnError": true,
"outDir": "${configDir}/dist/browser",
},
"include": [ "web/**/*.tsx", "web/**/*.ts", "index.ts", "messages.ts", "package.json" ],
"include": [ "web/**/*.tsx", "web/**/*.ts", "messages.ts", "package.json" ],
}
@@ -2,7 +2,6 @@
"extends": "./tsconfig.base.json",
"compilerOptions": {
"lib": ["es2023"],
"types": ["node"],
"module": "node16",
"moduleResolution": "node16",
"target": "es2022",
@@ -10,8 +10,7 @@ import React, {
// } from "antd";
import {
PageLayout, PageHeader, Control, ControlContext, notifyErrorHandler,
type WebPluginContext,
BaseWebPlugin, PageLayout, PageHeader, Control, ControlContext, notifyErrorHandler,
} from "@clusterio/web_ui";
//%if multi_context // Messages requires multi context
@@ -22,26 +21,16 @@ import {
ExampleSubscribableUpdate, ExampleSubscribableValue,
//%endif
//%if multi_context // Messages requires multi context
} from "../messages.js";
} from "../messages";
//%endif
import * as lib from "@clusterio/lib";
//%if controller // Subscribing requires web content and the controller
// Created when the plugin is loaded, before any page is rendered
let exampleSubscriber: lib.MapSubscriber<ExampleSubscribableUpdate>;
function useSubscribableData() {
const control = useContext(ControlContext);
const subscribe = useCallback((callback: () => void) => exampleSubscriber.subscribe(callback), [control]);
return useSyncExternalStore(subscribe, () => exampleSubscriber.getSnapshot());
}
//%endif
function MyTemplatePage() {
const control = useContext(ControlContext);
//%if controller // Subscribing requires web content and the controller
const [subscribableData, synced] = useSubscribableData();
const plugin = control.plugins.get("__plugin_name__") as WebPlugin;
const [subscribableData, synced] = plugin.useSubscribableData();
//%endif
return <PageLayout nav={[{ name: "__plugin_name__" }]}>
@@ -52,39 +41,53 @@ function MyTemplatePage() {
</PageLayout>;
}
export default async function loadWebPlugin(context: WebPluginContext) {
const { control, logger, plugin } = context;
export class WebPlugin extends BaseWebPlugin {
//%if controller // Subscribing requires web content and the controller
exampleSubscriber = new lib.MapSubscriber(ExampleSubscribableUpdate, control);
//%endif
subscribableData = new lib.EventSubscriber(ExampleSubscribableUpdate, this.control);
control.hooks.pages.attach(plugin.name, () => [
{
path: "/__plugin_name__",
sidebarName: "__plugin_name__",
// This permission is client side only, so it must match the permission string of a resource request to be secure
// An undefined value means that the page will always be visible
//%endif
async init() {
this.pages = [
{
path: "/__plugin_name__",
sidebarName: "__plugin_name__",
// This permission is client side only, so it must match the permission string of a resource request to be secure
// An undefined value means that the page will always be visible
//%if controller // Subscribing requires web content and the controller
permission: "__plugin_name__.example.permission.subscribe",
permission: "__plugin_name__.example.permission.subscribe",
//%endif
//%if !controller
permission: "__plugin_name__.page.view",
permission: "__plugin_name__.example.permission.view",
//%endif
content: <MyTemplatePage/>,
},
]);
content: <MyTemplatePage/>,
},
];
//%if multi_context
control.handle(PluginExampleEvent, async (event: PluginExampleEvent) => {
logger.info(JSON.stringify(event));
});
this.control.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
this.control.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
//%endif
}
//%if controller // Subscribing requires web content and the controller
control.handle(PluginExampleRequest, async (request: PluginExampleRequest) => {
logger.info(JSON.stringify(request));
useSubscribableData() {
const control = useContext(ControlContext);
const subscribe = useCallback((callback: () => void) => this.subscribableData.subscribe(callback), [control]);
return useSyncExternalStore(subscribe, () => this.subscribableData.getSnapshot());
}
//%endif
//%if multi_context
async handlePluginExampleEvent(event: PluginExampleEvent) {
this.logger.info(JSON.stringify(event));
}
async handlePluginExampleRequest(request: PluginExampleRequest) {
this.logger.info(JSON.stringify(request));
return {
myResponseString: request.myString,
myResponseNumbers: request.myNumberArray,
};
});
}
//%endif
}
-1
View File
@@ -1 +0,0 @@
# Without an .npmignore pnpm packs using the repository .gitignore, which leaves out dist/
+10 -31
View File
@@ -17,12 +17,9 @@ Common options:
See the `plugin` command for managing this list.
* `--config <file>` JSON file to use for storing configuration for ctl.
Defaults to `config-ctl.json` and is required for operation.
Defaults to `config-control.json` and will be created if it does not exist.
See the `config` command for inspecting and modifying the configuration.
* `--create-config` If the config file is missing, create a new one and carry on instead of exiting with an error.
Alternative to the `config create` command.
### `plugin <command>`
@@ -30,28 +27,17 @@ Configure plugins available to be loaded by ctl.
The available plugins will be automatically loaded and the commands they provide will be added to the ctl utility.
#### `plugin install <package>`
Install a plugin from npm and add it to the plugin list.
The package is passed to `npm install --save` so anything npm accepts works, including a version like `@clusterio/plugin-subspace_storage@2.0.0`.
This command refuses to run outside the directory Clusterio was installed in, as the plugin has to end up in that directory's node_modules to be found.
For example, installing the Subspace Storage plugin:
npx clusterioctl plugin install @clusterio/plugin-subspace_storage
Since the `plugin-list.json` is shared between controller, host and ctl you usually only need to do this once per machine.
#### `plugin add <path>`
Add plugin either by require path or relative/absolute path to the plugin directory.
A relative path must start with ./ or ../ (or .\ and ..\ on Windows) otherwise it will be assumed to be a require path for an installed package in node_modules.
This is mainly useful for plugins under development, packages published on npm are better installed with `plugin install`.
For example, adding a plugin checked out in the external_plugins directory:
For example, installing the Subspace Storage plugin:
npx clusterioctl plugin add ./external_plugins/my_plugin
npm install @clusterio/plugin-subspace_storage
npx clusterioctl plugin add @clusterio/plugin-subspace_storage
Since the `plugin-list.json` is shared between controller, host and ctl you usually only need to do this once per machine.
#### `plugin remove <name>`
@@ -73,20 +59,13 @@ Since the `plugin-list.json` is shared between controller, host and ctl you usua
Lists the plugins set up to be available by name followed by path.
### `config`
### `config-control`
Manage the ctl configuration.
This allows setting the url and token to the controller to manage.
#### `config create`
Create a new config file with the default options inserted into if it doesn't already exist.
See also the `--create-config` option.
#### `config set <config-entry> [value]`
#### `config-control set <config-entry> [value]`
Set a config entry to the given value.
If value is not provided the entry is set to null.
@@ -95,12 +74,12 @@ If the config entry is of type object the value must be a valid JSON serializati
See docs/configuration.md in the main repositiory for the available configuration.
#### `config show <config-entry>`
#### `config-control show <config-entry>`
Shows the value for a single config entry.
#### `config list`
#### `config-control list`
Lists up all configuration entries with their currently configured values.
+123 -80
View File
@@ -4,30 +4,31 @@
* Command line interface for controlling a Clusterio cluster
* @module ctl/ctl
*/
import fs from "node:fs/promises";
import fs from "fs-extra";
import yargs, { type Argv } from "yargs";
import path from "path";
import packageConfig from "@clusterio/ctl/package.json" with { type: "json" };
import { version } from "./package.json";
import setBlocking from "set-blocking";
import { strict as assert } from "assert";
// Reduce startup time by lazy compiling schemas.
import "./src/set_lazy_schema_compliation.js";
(global as any).lazySchemaCompilation = true;
import * as lib from "@clusterio/lib";
import { ConsoleTransport, levels, logger } from "@clusterio/lib";
import * as commands from "./src/commands.js";
import { BaseCtlPlugin, CtlHooks } from "./src/BaseCtlPlugin.js";
import * as commands from "./src/commands";
import BaseCtlPlugin from "./src/BaseCtlPlugin";
/**
* Connector for control connection to controller
* @private
*/
class CtlConnector extends lib.WebSocketClientConnector {
class ControlConnector extends lib.WebSocketClientConnector {
private _token: string;
constructor(url: string, maxReconnectDelay: number, token: string) {
super(url, maxReconnectDelay);
constructor(url: string, maxReconnectDelay: number, tlsCa: string | undefined, token: string) {
super(url, maxReconnectDelay, tlsCa);
this._token = token;
}
@@ -37,7 +38,7 @@ class CtlConnector extends lib.WebSocketClientConnector {
new lib.MessageRegisterControl(
new lib.RegisterControlData(
this._token,
packageConfig.version,
version,
)
)
);
@@ -45,23 +46,31 @@ class CtlConnector extends lib.WebSocketClientConnector {
}
/**
* Handles running ctl
* Handles running the control
*
* Connects to the controller over WebSocket and sends commands to it.
* @static
*/
export class Ctl extends lib.Link {
/** Ctl config used for connecting to the controller. */
config: lib.CtlConfig;
export class Control extends lib.Link {
/** Control config used for connecting to the controller. */
config: lib.ControlConfig;
/** Certificate authority used to validate TLS connections to the controller. */
tlsCa?: string;
/** Mapping of plugin names to their instance for loaded plugins. */
plugins: Map<string, BaseCtlPlugin>;
/** Keep the control connection alive after the command completes. */
keepOpen = false;
constructor(
connector: CtlConnector,
ctlConfig: lib.CtlConfig,
connector: ControlConnector,
controlConfig: lib.ControlConfig,
tlsCa: string | undefined,
ctlPlugins: Map<string, BaseCtlPlugin>
) {
super(connector);
this.config = ctlConfig;
this.config = controlConfig;
this.tlsCa = tlsCa;
this.plugins = ctlPlugins;
this.handle(lib.LogMessageEvent, this.handleLogMessageEvent.bind(this));
this.handle(lib.DebugWsMessageEvent, this.handleDebugWsMessageEvent.bind(this));
@@ -92,7 +101,7 @@ export class Ctl extends lib.Link {
async shutdown() {
try {
await (this.connector as CtlConnector).disconnect();
await (this.connector as ControlConnector).disconnect();
} catch (err) {
if (!(err instanceof lib.SessionLost)) {
throw err;
@@ -101,23 +110,27 @@ export class Ctl extends lib.Link {
}
}
async function loadPlugins(pluginList: Map<string, string>, hooks: CtlHooks) {
async function loadPlugins(pluginList: Map<string, string>) {
let pluginInfos = await lib.loadPluginInfos(pluginList);
lib.registerPluginMessages(pluginInfos);
lib.registerPluginPermissions(pluginInfos);
lib.addPluginConfigFields(pluginInfos);
const ctlPlugins = new Set<lib.PluginNodeEnvInfo>();
let ctlPlugins = new Map<string, BaseCtlPlugin>();
for (let pluginInfo of pluginInfos) {
if (!pluginInfo.ctlEntrypoint) {
continue;
}
const context = { hooks, logger, plugin: pluginInfo };
await lib.loadPlugin(pluginInfo, "ctl", context, "CtlPlugin", BaseCtlPlugin);
ctlPlugins.add(pluginInfo);
let CtlPlugin = await lib.loadPluginClass(
pluginInfo.name,
path.posix.join(pluginInfo.requirePath, pluginInfo.ctlEntrypoint),
"CtlPlugin",
BaseCtlPlugin,
);
let ctlPlugin = new CtlPlugin(pluginInfo, logger);
ctlPlugins.set(pluginInfo.name, ctlPlugin);
await ctlPlugin.init();
}
return ctlPlugins;
}
@@ -128,21 +141,19 @@ interface CtlArguments {
logLevel: keyof typeof levels,
config: string,
pluginList: string,
bypassLockFile: boolean,
createConfig: boolean,
}
interface InitializeParameters {
args: CtlArguments;
shouldRun: boolean;
ctlHooks: CtlHooks;
ctlPlugins?: Map<string, BaseCtlPlugin>;
rootCommands?: lib.CommandTree;
ctlConfig?: lib.CtlConfig;
controlConfig?: lib.ControlConfig;
}
export async function initialize(
argv: string | string[],
ctlHooks: CtlHooks = new CtlHooks(logger),
ctlPlugins?: Map<string, BaseCtlPlugin>,
noLoggerTransport?: boolean,
): Promise<InitializeParameters> {
// Build a fresh, isolated yargs parser each time this function is called.
@@ -168,16 +179,10 @@ export async function initialize(
.option("config", {
nargs: 1,
describe: "config file to get credentails from",
default: "config-ctl.json",
default: "config-control.json",
defaultDescription: "auto",
type: "string",
})
.option("create-config", {
nargs: 0,
describe: "Create config file if it does not exist",
default: false,
type: "boolean",
})
.option("plugin-list", {
nargs: 1,
describe: "File containing list of plugins available with their install path",
@@ -186,7 +191,7 @@ export async function initialize(
})
.option("bypass-lock-file", { hidden: true, type: "boolean", nargs: 0, default: false })
.command("plugin", "Manage available plugins", lib.pluginCommand)
.command("config", "Manage ctl config", lib.configCommand)
.command("control-config", "Manage Control config", lib.configCommand)
.wrap(parser.terminalWidth())
.help(false) // Disable help to avoid triggering it on the first parse.
;
@@ -207,47 +212,63 @@ export async function initialize(
lib.handleUnhandledErrors();
}
// Discover and load plugins. ctlHooks.size check exists to allow tests to inject plugins.
if (ctlHooks.addCommands.size === 0 || args._[0] === "plugin") {
// Discover and load plugins. This check exists to allow tests to inject plugins.
if (!ctlPlugins || args._[0] === "plugin") {
logger.verbose(`Loading available plugins from ${args.pluginList}`);
const pluginList = await lib.loadPluginList(args.pluginList);
// If the command is plugin management we don't try to load plugins
if (args._[0] === "plugin") {
await lib.handlePluginCommand(args, pluginList, args.pluginList);
return { args, ctlHooks, shouldRun: false };
return { args, shouldRun: false };
}
logger.verbose("Loading Plugins");
await loadPlugins(pluginList, ctlHooks);
ctlPlugins = await loadPlugins(pluginList);
}
// Add all commands including from plugins and reparse with help and strict checking.
const rootCommands = await commands.registerCommands(ctlHooks, parser);
const rootCommands = await commands.registerCommands(ctlPlugins, parser);
args = parser
.help()
.strict()
.parse(argv) as CtlArguments
;
const [ctlConfig, ctlConfigLock] = await lib.loadConfigFromArgs(
args,
lib.CtlConfig,
"control",
);
const controlConfigPath = args.config;
const controlConfigLockPath = `${controlConfigPath}.lock`;
if (args.bypassLockFile) {
await fs.unlink(controlConfigLockPath).catch(() => {}); // ignore error, file might not exist
}
let controlConfig;
const controlConfigLock = new lib.LockFile(controlConfigLockPath);
logger.verbose(`Loading config from ${controlConfigPath}`);
try {
controlConfig = await lib.ControlConfig.fromFile("control", controlConfigPath);
} catch (err: any) {
if (err.code === "ENOENT") {
logger.verbose("Config not found, initializing new config");
controlConfig = new lib.ControlConfig("control", undefined, controlConfigPath);
} else {
throw new lib.StartupError(`Failed to load ${args.config}: ${err.stack ?? err.message ?? err}`);
}
}
if (args._.length === 0) {
parser.showHelp();
parser.exit(1, undefined as unknown as Error); // Type definition file is wrong.
}
// Handle the config command before trying to connect.
if (args._[0] === "config") {
await lib.handleConfigCommand(args, ctlConfig, ctlConfigLock);
return { args, ctlConfig, ctlHooks, rootCommands, shouldRun: false };
// Handle the control-config command before trying to connect.
if (args._[0] === "control-config") {
await lib.handleConfigCommand(args, controlConfig, controlConfigLock);
return { args, controlConfig, ctlPlugins, rootCommands, shouldRun: false };
}
return { args, ctlConfig, ctlHooks, rootCommands, shouldRun: true };
return { args, controlConfig, ctlPlugins, rootCommands, shouldRun: true };
}
export function selectTargetCommand(args: CtlArguments, rootCommands: lib.CommandTree): lib.Command {
@@ -260,30 +281,38 @@ export function selectTargetCommand(args: CtlArguments, rootCommands: lib.Comman
return targetCommand;
}
async function startCtl() {
async function startControl() {
const {
args,
shouldRun,
ctlPlugins,
rootCommands,
ctlConfig,
controlConfig,
} = await initialize(process.argv.slice(2));
if (!shouldRun || !rootCommands || !ctlConfig) {
if (!shouldRun || !ctlPlugins || !rootCommands || !controlConfig) {
return;
}
if (!ctlConfig.get("ctl.controller_url") || !ctlConfig.get("ctl.controller_token")) {
if (!controlConfig.get("control.controller_url") || !controlConfig.get("control.controller_token")) {
logger.error("Missing URL and/or token to connect with. See README.md for setting up access.");
process.exitCode = 1;
return;
}
let controlConnector = new CtlConnector(
ctlConfig.get("ctl.controller_url")!,
ctlConfig.get("ctl.max_reconnect_delay"),
ctlConfig.get("ctl.controller_token")!,
let tlsCa: string | undefined;
let tlsCaPath = controlConfig.get("control.tls_ca");
if (tlsCaPath) {
tlsCa = await fs.readFile(tlsCaPath, "utf8");
}
let controlConnector = new ControlConnector(
controlConfig.get("control.controller_url")!,
controlConfig.get("control.max_reconnect_delay"),
tlsCa,
controlConfig.get("control.controller_token")!,
);
let ctl = new Ctl(controlConnector, ctlConfig);
let control = new Control(controlConnector, controlConfig, tlsCa, ctlPlugins);
try {
await controlConnector.connect();
} catch (err) {
@@ -293,26 +322,32 @@ async function startCtl() {
throw err;
}
// Handle interrupts
process.on("SIGINT", lib.createShutdownGuard(logger, "interrupt", ctl.shutdown.bind(ctl)));
process.on("SIGTERM", lib.createShutdownGuard(logger, "termination", ctl.shutdown.bind(ctl)));
process.on("SIGINT", () => {
logger.info("Caught interrupt signal, closing connection");
control.shutdown().catch(err => {
setBlocking(true);
logger.error(err.stack);
process.exit(1);
});
});
try {
const targetCommand = selectTargetCommand(args, rootCommands);
await targetCommand.run(args, ctl);
await targetCommand.run(args, control);
} catch (err) {
ctl.keepOpen = false;
control.keepOpen = false;
if (err instanceof lib.CommandError) {
logger.error(`Error running command: ${err.message}`);
process.exitCode = 1;
} else if (err instanceof lib.ClientError || err instanceof lib.UnavailableError) {
logger.error(`Error sending request: ${err.message}`);
process.exitCode = 1;
} else if (err instanceof lib.ServerError) {
logger.error(`Error sending request:\n${err.stack ?? err.message}`);
} else if (err instanceof lib.RequestError) {
if (err.stack) {
logger.error(`Error sending request:\n${err.stack}`);
} else {
logger.error(`Error sending request: ${err.message}`);
}
process.exitCode = 1;
} else {
@@ -320,23 +355,31 @@ async function startCtl() {
}
} finally {
if (!ctl.keepOpen) {
await ctl.shutdown();
if (!control.keepOpen) {
await control.shutdown();
}
}
}
export function bootstrap() {
startCtl().catch(err => {
// 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
+==========================================================+
`
);
startControl().catch(err => {
if (err.errors) {
logger.fatal(JSON.stringify(err.errors, null, "\t"));
}
if (!(err instanceof lib.StartupError)) {
logger.fatal(`
+---------------------------------------------------------------+
| Unexpected error occured while starting clussterioctl, please |
| report it to https://github.com/clusterio/clusterio/issues |
+---------------------------------------------------------------+
+------------------------------------------------------------+
| Unexpected error occured while starting control, please |
| report it to https://github.com/clusterio/clusterio/issues |
+------------------------------------------------------------+
${err.stack}`
);
} else {
@@ -352,6 +395,6 @@ ${err.stack}`
});
}
if (import.meta.main) {
if (module === require.main) {
bootstrap();
}
+4 -4
View File
@@ -1,7 +1,7 @@
import { bootstrap } from "./ctl.js";
export * from "./ctl.js";
export { BaseCtlPlugin, CtlHooks, type CtlPluginContext } from "./src/BaseCtlPlugin.js";
import { bootstrap } from "./ctl";
export * from "./ctl";
export { default as BaseCtlPlugin } from "./src/BaseCtlPlugin";
if (import.meta.main) {
if (module === require.main) {
bootstrap();
}
+19 -27
View File
@@ -1,28 +1,9 @@
{
"name": "@clusterio/ctl",
"version": "2.0.3",
"description": "Clusterio command line management tool",
"homepage": "https://github.com/clusterio/clusterio#readme",
"author": "Hornwitser <github@hornwitser.no>",
"author": "Hornwitser",
"license": "MIT",
"keywords": [
"clusterio",
"factorio"
],
"repository": {
"type": "git",
"url": "https://github.com/clusterio/clusterio"
},
"bugs": {
"url": "https://github.com/clusterio/clusterio/issues"
},
"publishConfig": {
"access": "public"
},
"type": "module",
"version": "2.0.0-alpha.22",
"main": "dist/node/index.js",
"scripts": {
"prepare": "tsc --build",
@@ -31,19 +12,30 @@
"bin": {
"clusterioctl": "./dist/node/ctl.js"
},
"keywords": [
"clusterio",
"factorio"
],
"engines": {
"node": ">=22"
"node": ">=20"
},
"dependencies": {
"@clusterio/lib": "workspace:^",
"as-table": "^1.0.55",
"@clusterio/lib": "workspace:*",
"set-blocking": "catalog:",
"fs-extra": "catalog:",
"winston": "catalog:",
"yargs": "catalog:"
"phin": "catalog:",
"as-table": "^1.0.55",
"yargs": "^17.7.2"
},
"publishConfig": {
"access": "public"
},
"devDependencies": {
"@types/fs-extra": "catalog:",
"@types/node": "catalog:",
"@types/yargs": "catalog:",
"@types/set-blocking": "catalog:",
"@types/yargs": "^17.0.33",
"typescript": "catalog:"
}
}
+31 -53
View File
@@ -1,18 +1,38 @@
import * as lib from "@clusterio/lib";
export type CtlPluginContext = lib.PluginLoadContext<{
hooks: CtlHooks
}>;
import type { CommandTree, Logger, PluginNodeEnvInfo } from "@clusterio/lib";
/**
* Collection of clusterioctl plugin hooks
* Base class for clusterioctl plugins
*
* Ctl plugins are subclasses of this class which get instantiated by
* clusterioctl in order to extend its functionallity. To be discovered the
* class must be exported under the name `CtlPlugin` in the module
* specified by the `ctlEntrypoint` in the plugin's `plugin` export.
*/
export class CtlHooks extends lib.AsyncHookCollection {
constructor(logger: lib.Logger) {
super(logger);
this.addCommands = this.newHook();
export default class BaseCtlPlugin {
/**
* Logger for this plugin
*
* Instance of winston Logger for sending log messages from this
* plugin. Supported methods and their corresponding log levels are
* `error`, `warn`, `audit`, `info` and `verbose`.
*/
logger: Logger;
constructor(
/**
* The plugin's own info module
*/
public info: PluginNodeEnvInfo,
logger: Logger,
) {
this.logger = logger.child({ plugin: this.info.name }) as unknown as Logger;
}
/**
* Called immediately after the class is instantiated
*/
async init() { }
/**
* Called to add commands to the command line interface.
*
@@ -23,47 +43,5 @@ export class CtlHooks extends lib.AsyncHookCollection {
* @param rootCommand -
* Root of the clusterioctl command tree.
*/
readonly addCommands: lib.AsyncHook<[rootCommand: lib.CommandTree]>;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
export interface BaseCtlPlugin {
addCommands?(rootCommand: lib.CommandTree): Promise<void>;
}
/**
* Base class for clusterioctl plugins
*
* Ctl plugins are subclasses of this class which get instantiated by
* clusterioctl in order to extend its functionallity. To be discovered the
* class must be exported under the name `CtlPlugin` in the module
* specified by the `ctlEntrypoint` in the plugin's `plugin` export.
*/
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
export class BaseCtlPlugin {
constructor(
/**
* The plugin's own info module
*/
public info: lib.PluginNodeEnvInfo,
public logger: lib.Logger,
private hooks: CtlHooks,
) {
if (this.addCommands) {
hooks.addCommands.attach(info.name, this.addCommands.bind(this));
}
}
static fromContext(context: CtlPluginContext) {
return new this(context.plugin, context.logger, context.hooks);
}
detachHooks() {
this.hooks.detachAll(this.info.name);
}
/**
* Called immediately after the class is instantiated
*/
async init() {}
async addCommands(rootCommand: CommandTree) { }
}
-4
View File
@@ -1,4 +0,0 @@
export function print(...content: any) {
// eslint-disable-next-line no-console
console.log(...content);
}
Loaded 100 of 606 files, more files were not shown because too many files have changed in this diff. Show more