Correct developer guidance and skip heavy CI for documentation changes (#1090)

Add a minimal AGENTS.md, update documentation and optimize the CI runs to not run actions for which there are no changes present
This commit is contained in:
Arvids Godjuks authored and GitHub committed 2026-10-16 22:30:05 +03:00
1 parent d5d4eb7fef
commit c4f48b6c12
17 files changed
+968 -244

No files matched your search

+126
View File
@@ -0,0 +1,126 @@
name: Classify CI changes
on:
workflow_call:
outputs:
build_and_test:
description: Run the test and coverage matrix, compatibility tests, and mod build.
value: ${{ jobs.changes.outputs.build_and_test }}
js_lint:
description: Run the JavaScript/TypeScript linter.
value: ${{ jobs.changes.outputs.js_lint }}
lua_lint:
description: Run the Lua linter.
value: ${{ jobs.changes.outputs.lua_lint }}
jobs:
changes:
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
build_and_test: ${{ steps.syntax.outputs.build_and_test || steps.paths.outputs.code }}
js_lint: ${{ steps.lint.outputs.js_lint }}
lua_lint: ${{ steps.lint.outputs.lua_lint }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: dorny/paths-filter@v4
id: paths
with:
token: ''
base: ${{ github.event.pull_request.base.sha || github.event.before }}
ref: ${{ github.sha }}
list-files: json
predicate-quantifier: every
filters: |
# Files requiring validation beyond documentation checks.
code:
- '**'
- '!**/*.md'
- '!docs/**/*.{png,jpg,svg,gif}'
# Sources and configuration owned by each linter.
# Workflow scripts and shared build configuration run both linters.
js:
- '**/*.{js,cjs,mjs,jsx,ts,cts,mts,tsx}'
- '!.github/**'
- '!**/{webpack,babel,rollup,vite,jest}.config.*'
lua:
- '{*.lua,**/*.lua,.emmyrc.json}'
- '!.github/**'
# Modified JS/TS source eligible for the comment-only shortcut.
# TypeScript type changes still count as substantive changes.
syntax:
- modified: '**/*.{js,cjs,mjs,jsx,ts,cts,mts,tsx}'
# Source fixtures and templates always receive full validation.
- '!test/file/**'
- '!packages/create/templates/**'
- '!.github/**'
- '!**/{eslint,webpack,babel,rollup,vite,jest}.config.*'
- name: Select linters
id: lint
env:
CODE_FILES: ${{ steps.paths.outputs.code_files }}
JS_FILES: ${{ steps.paths.outputs.js_files }}
LUA_FILES: ${{ steps.paths.outputs.lua_files }}
run: |
# Unclassified non-documentation files can affect either language.
jq -nr --argjson code "$CODE_FILES" --argjson js "$JS_FILES" --argjson lua "$LUA_FILES" '
($code - $js - $lua | length > 0) as $shared |
"js_lint=\($shared or ($js | length > 0))",
"lua_lint=\($shared or ($lua | length > 0))"
' >> "$GITHUB_OUTPUT"
- name: Install Difftastic
if: steps.paths.outputs.syntax == 'true'
env:
DIFFT_VERSION: 0.71.0
DIFFT_SHA256: 61aea5394a53c56f144637cf39b3a0c0dafa27769731745e5851ff276e6478da
run: |
mkdir -p "$RUNNER_TEMP/difftastic"
curl -fsSL "https://github.com/Wilfred/difftastic/releases/download/$DIFFT_VERSION/difft-$DIFFT_VERSION-x86_64-unknown-linux-gnu.tar.gz" -o "$RUNNER_TEMP/difftastic.tar.gz"
echo "$DIFFT_SHA256 $RUNNER_TEMP/difftastic.tar.gz" | sha256sum -c
tar -xzf "$RUNNER_TEMP/difftastic.tar.gz" -C "$RUNNER_TEMP/difftastic"
echo "$RUNNER_TEMP/difftastic" >> "$GITHUB_PATH"
- name: Check for substantive changes
id: syntax
if: steps.paths.outputs.code == 'true'
env:
CODE_FILES: ${{ steps.paths.outputs.code_files }}
SYNTAX_FILES: ${{ steps.paths.outputs.syntax_files }}
BASE: ${{ github.event.pull_request.base.sha || github.event.before }}
HEAD_SHA: ${{ github.sha }}
run: |
# Skip only when every non-documentation file is an ordinary source edit.
echo 'build_and_test=true' >> "$GITHUB_OUTPUT"
# A new branch has no previous commit; keep full validation on its first push.
if [[ "$BASE" == 0000000000000000000000000000000000000000 ]]; then
exit 0
fi
if ! jq -en --argjson code "$CODE_FILES" --argjson syntax "$SYNTAX_FILES" \
'$code - $syntax | length == 0' > /dev/null; then
exit 0
fi
git cat-file -e "$BASE^{commit}"
jq -j '.[] | ., "\u0000"' <<< "$SYNTAX_FILES" > "$RUNNER_TEMP/syntax-files"
while IFS= read -r -d '' file; do
# Mode changes and symlinks are substantive even when text is identical.
[[ -z "$(git diff --summary --no-renames "$BASE" "$HEAD_SHA" -- "$file")" ]] || exit 0
git ls-tree "$HEAD_SHA" -- "$file" | grep -qE '^100(644|755) blob ' || exit 0
before="$RUNNER_TEMP/before.${file##*.}"
after="$RUNNER_TEMP/after.${file##*.}"
git show "$BASE:$file" > "$before"
git show "$HEAD_SHA:$file" > "$after"
# Difftastic ignores ALL comments; retain compiler/bundler/coverage directives.
if grep -Eq '@ts-|@jsx|@internal|<reference\b|<amd-|sourceMappingURL|sourceURL|webpack|vite|[#@]__|/\*!|(istanbul|c8|v8)[[:space:]]+ignore' "$before" "$after"; then
exit 0
fi
case "$file" in
*.js|*.cjs|*.mjs|*.jsx)
# JavaScript JSDoc can contain type information.
if grep -Fq '/**' "$before" "$after"; then exit 0; fi ;;
esac
if ! difft --ignore-comments --check-only --exit-code "$before" "$after"; then
exit 0
fi
done < "$RUNNER_TEMP/syntax-files"
echo 'build_and_test=false' >> "$GITHUB_OUTPUT"
+5
View File
@@ -8,7 +8,12 @@ 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
+11
View File
@@ -11,7 +11,12 @@ 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:
@@ -58,6 +63,8 @@ jobs:
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:
@@ -90,6 +97,8 @@ jobs:
--published-version "$PUBLISHED_VERSION"
lint:
needs: changes
if: ${{ !cancelled() && needs.changes.outputs.js_lint != 'false' }}
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
@@ -101,6 +110,8 @@ jobs:
- run: pnpm run lint
build:
needs: changes
if: ${{ !cancelled() && needs.changes.outputs.build_and_test != 'false' }}
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
+35
View File
@@ -0,0 +1,35 @@
# 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.
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 `eslint.config.mjs`: 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).
@@ -0,0 +1 @@
<mxfile host="Electron" modified="2023-07-15T21:19:41.336Z" agent="5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) draw.io/20.8.16 Chrome/106.0.5249.199 Electron/21.4.0 Safari/537.36" etag="WQippoIg5xK5LnRPeLWb" version="20.8.16" type="device"><diagram id="wKlty5uROKaaHdmDaOKw" name="Page-1">5Vtdk6I4FP01Ps4UEEB9bO12Pmp3qmvdqnlOQ4R0R+KE2Or++k0kgBBEelTQoR9suFwgOeee5N4YB2C63H5hcBX+TX1EBpbhbwfgcWBZznAsPqVhlxhsABJDwLCfmMzcMMf/IWU0lHWNfRQXHDmlhONV0ejRKEIeL9ggY3RTdFtQUnzrCgZIM8w9SHTrT+zzUHXLMHL7V4SDkJcuLGHqqwxxCH26OTCBpwGYMkp5crTcThGR0KWwJPfNjlzN2sVQxJvc4Lz9M3Hs7zvzZR7gHwEh3/59/OQodt4hWasOq9byXYoAo+vIR/IpxgBMNiHmaL6Cnry6EZQLW8iXRJyZ4jDrpfQNCIxjdRy/Ie6F6mSBCZlSQpk4j2gkHjWBBAeROCVowaU7Z/QNlXx8zATFmEq/mK4lwBMdBoXMO2IcbQ9MCpYviC4RZzvhsqkgNKwiE6ogCrJ7c6DFgcL6A7hnjWyEu3ka90NEBxbwIRotPAkZjMPsGUVQhZvrjdDLojFtK8Sw6D9ish04CpQZMk/p1j2TEHU1lYwaMobq9ICuzOWQLmBdiy5XY2tKIwEmIYgJ+zOjHhKQnaWcEoOG4RhoUUWaYZiPk6m8Q7ThwL7Y/12GAbvIwFhnwDSrGLgWAeZYAxf5YpxWp5TxkAY0guQpt06K8Oc+f1G6UqC/Is53KnjhmtNaSswXaCKrmhL36WFWB70YrpiHanqoOsghCxA/HYqy97VEMkQgx+/FWayKl/2tD4zB3YHDiuKIxwdPfpaGPD7cYnyYdmnu+ZC7OEjen8dH1pEzhlj3BkJmAd2RYVSFzHQInP0VFPkPMk/JpzlhmWHZ2X0jXtfLVdpo5fD7UWaBhmE2vokwGxXjxho6LcQN0Ab7GfQ4ZZgK6xyxdznmX3CkbxAjVxzps3E8xdjteqy3bmGsv4Bwz1CpfVcqHRYjCJhtqNTutUqB0blKR7ek0lNaPE7aGSp17kqlpRwMOG2o1Om3Su2uVQp0Amrw7t06Q7kuGTVcachWJy9OmG3fwLB61UIXuA2HzduodLOZNo0Rt77UHTt17tcZZsHwBoKm04wZjBoGFbiRqLI6qGxTkHoyG4s3FDHuvLIFva9s04TobnRazujaqG1TkHqq0+5rW9vU4O2bTq0706nRQXWbgtRXnXZe3dq38BXPVYslu+ka08VleB4x+rLDT/QiDN8iUdUvZLjLBHgmPj2yjoUNU4+Ti4qlAS9XFEv5q6uqb9DdVrVStdPHJVzBUMDd/bWm6YVP8V4HD8JBQLLNL4qjQP6fpgRKj8+i+caEUO9N/H/EMGBwmb5GNDt5U3KfRrZAlhcZrdwEdHrvkGQJe5A8KPMS+/5e+lUBVAyxZutTlxhMS7VJxcLTuM3wcP7MDUnWhegyh26BLxvohDkVfF1tR5Kj5x7P4S6WYX889+izwqoYq8xWrkeZ/pWlnr7kGb0nscFeraoabBI7iuTJVOFEaKe2jyX2WuZum6MiT3YJ/yRHUnfVLLza7okHJTmU9qBLVQNOZc4zF1Mhkq1U+5Pl5tVey9IaWqd1CVrVpb67s2bqu7ednNrMVbEK2u7MNdTg/kF99PlVlPdHd9L2SiLlmauCsbHbImNAF8hXGvMauu5XLtqWw84XI/W67Y8F324Pe3Ga//QmSQHyny+Bp/8B</diagram></mxfile>
+236
View File
@@ -0,0 +1,236 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg
PUBLIC '-//W3C//DTD SVG 1.1//EN'
'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'>
<!-- Exported from clusterio_2.0_block_diagram.drawio with draw.io Desktop 32.4.1. Export settings: SVG, light theme, white background, width 1000. Native SVG labels: disable Formatted Text and Word Wrap in an export copy, preserving the title at font size 20. -->
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="background: #ffffff; background-color: #ffffff; color-scheme: light;" version="1.1" width="1001px" height="1001px" viewBox="0 0 1001 1001" role="img" aria-labelledby="diagram-title diagram-description">
<desc id="diagram-description">A controller and two hosts run across two physical servers. Each host manages three Factorio servers. The hosts and web or command-line clients connect to the controller over WebSocket.</desc>
<title id="diagram-title">Clusterio 2.0 system architecture</title>
<defs/>
<rect fill="#ffffff" width="100%" height="100%" x="0" y="0" style="fill: rgb(255, 255, 255);"/>
<g>
<g data-cell-id="0">
<g data-cell-id="1">
<g data-cell-id="5kRB54Jy1bSgiNgllITD-59">
<g>
<rect x="0" y="0" width="1000" height="1000" fill="none" stroke="none" transform="rotate(90,500,500)" pointer-events="all"/>
</g>
</g>
<g data-cell-id="5kRB54Jy1bSgiNgllITD-21">
<g>
<rect x="40" y="140" width="440" height="640" rx="26.4" ry="26.4" fill="#dae8fc" stroke="#6c8ebf" stroke-width="2" stroke-dasharray="6 6" pointer-events="all" style="fill: rgb(218, 232, 252); stroke: rgb(108, 142, 191);"/>
</g>
</g>
<g data-cell-id="5kRB54Jy1bSgiNgllITD-6">
<g>
<rect x="80" y="180" width="220" height="60" fill="#0050ef" stroke="#001dbc" stroke-width="2" pointer-events="all" style="fill: rgb(0, 80, 239); stroke: rgb(0, 29, 188);"/>
</g>
<g>
<g fill="#ffffff" font-family="Helvetica" text-anchor="middle" font-size="24px" style="fill: rgb(255, 255, 255);">
<text x="190" y="220">Controller Process</text>
</g>
</g>
</g>
<g data-cell-id="5kRB54Jy1bSgiNgllITD-19">
<g>
<path d="M 120 400 L 120 280 L 120 252.74" fill="none" stroke="#006eaf" stroke-width="2" stroke-miterlimit="10" pointer-events="stroke" style="stroke: rgb(0, 110, 175);"/>
<path d="M 120 242.24 L 127 256.24 L 120 252.74 L 113 256.24 Z" fill="#006eaf" stroke="#006eaf" stroke-width="2" stroke-miterlimit="10" pointer-events="all" style="fill: rgb(0, 110, 175); stroke: rgb(0, 110, 175);"/>
</g>
</g>
<g data-cell-id="5kRB54Jy1bSgiNgllITD-26">
<g>
<path d="M 220 550 L 160 550 L 160 460" fill="none" stroke="#c73500" stroke-width="2" stroke-miterlimit="10" pointer-events="stroke" style="stroke: rgb(199, 53, 0);"/>
</g>
</g>
<g data-cell-id="5kRB54Jy1bSgiNgllITD-23">
<g>
<rect x="220" y="520" width="220" height="60" fill="#fa6800" stroke="#c73500" stroke-width="2" pointer-events="all" style="fill: rgb(250, 104, 0); stroke: rgb(199, 53, 0);"/>
</g>
<g>
<g fill="#ffffff" font-family="Helvetica" text-anchor="middle" font-size="24px" style="fill: rgb(255, 255, 255);">
<text x="330" y="560">Factorio Server</text>
</g>
</g>
</g>
<g data-cell-id="5kRB54Jy1bSgiNgllITD-29">
<g>
<path d="M 220 630 L 140 630 L 140 460" fill="none" stroke="#c73500" stroke-width="2" stroke-miterlimit="10" pointer-events="stroke" style="stroke: rgb(199, 53, 0);"/>
</g>
</g>
<g data-cell-id="5kRB54Jy1bSgiNgllITD-24">
<g>
<rect x="220" y="600" width="220" height="60" fill="#fa6800" stroke="#c73500" stroke-width="2" pointer-events="all" style="fill: rgb(250, 104, 0); stroke: rgb(199, 53, 0);"/>
</g>
<g>
<g fill="#ffffff" font-family="Helvetica" text-anchor="middle" font-size="24px" style="fill: rgb(255, 255, 255);">
<text x="330" y="640">Factorio Server</text>
</g>
</g>
</g>
<g data-cell-id="5kRB54Jy1bSgiNgllITD-28">
<g>
<path d="M 220 710 L 120 710 L 120 460" fill="none" stroke="#c73500" stroke-width="2" stroke-miterlimit="10" pointer-events="stroke" style="stroke: rgb(199, 53, 0);"/>
</g>
</g>
<g data-cell-id="5kRB54Jy1bSgiNgllITD-25">
<g>
<rect x="220" y="680" width="220" height="60" fill="#fa6800" stroke="#c73500" stroke-width="2" pointer-events="all" style="fill: rgb(250, 104, 0); stroke: rgb(199, 53, 0);"/>
</g>
<g>
<g fill="#ffffff" font-family="Helvetica" text-anchor="middle" font-size="24px" style="fill: rgb(255, 255, 255);">
<text x="330" y="720">Factorio Server</text>
</g>
</g>
</g>
<g data-cell-id="5kRB54Jy1bSgiNgllITD-35">
<g>
<rect x="520" y="360" width="440" height="420" rx="25.2" ry="25.2" fill="#dae8fc" stroke="#6c8ebf" stroke-width="2" stroke-dasharray="6 6" pointer-events="all" style="fill: rgb(218, 232, 252); stroke: rgb(108, 142, 191);"/>
</g>
</g>
<g data-cell-id="5kRB54Jy1bSgiNgllITD-44">
<g>
<path d="M 600 400 L 600 320 L 190 320 L 190 252.74" fill="none" stroke="#006eaf" stroke-width="2" stroke-miterlimit="10" pointer-events="stroke" style="stroke: rgb(0, 110, 175);"/>
<path d="M 190 242.24 L 197 256.24 L 190 252.74 L 183 256.24 Z" fill="#006eaf" stroke="#006eaf" stroke-width="2" stroke-miterlimit="10" pointer-events="all" style="fill: rgb(0, 110, 175); stroke: rgb(0, 110, 175);"/>
</g>
</g>
<g data-cell-id="5kRB54Jy1bSgiNgllITD-37">
<g>
<path d="M 700 550 L 640 550 L 640 460" fill="none" stroke="#c73500" stroke-width="2" stroke-miterlimit="10" pointer-events="stroke" style="stroke: rgb(199, 53, 0);"/>
</g>
</g>
<g data-cell-id="5kRB54Jy1bSgiNgllITD-38">
<g>
<rect x="700" y="520" width="220" height="60" fill="#fa6800" stroke="#c73500" stroke-width="2" pointer-events="all" style="fill: rgb(250, 104, 0); stroke: rgb(199, 53, 0);"/>
</g>
<g>
<g fill="#ffffff" font-family="Helvetica" text-anchor="middle" font-size="24px" style="fill: rgb(255, 255, 255);">
<text x="810" y="560">Factorio Server</text>
</g>
</g>
</g>
<g data-cell-id="5kRB54Jy1bSgiNgllITD-39">
<g>
<path d="M 700 630 L 620 630 L 620 460" fill="none" stroke="#c73500" stroke-width="2" stroke-miterlimit="10" pointer-events="stroke" style="stroke: rgb(199, 53, 0);"/>
</g>
</g>
<g data-cell-id="5kRB54Jy1bSgiNgllITD-40">
<g>
<rect x="700" y="600" width="220" height="60" fill="#fa6800" stroke="#c73500" stroke-width="2" pointer-events="all" style="fill: rgb(250, 104, 0); stroke: rgb(199, 53, 0);"/>
</g>
<g>
<g fill="#ffffff" font-family="Helvetica" text-anchor="middle" font-size="24px" style="fill: rgb(255, 255, 255);">
<text x="810" y="640">Factorio Server</text>
</g>
</g>
</g>
<g data-cell-id="5kRB54Jy1bSgiNgllITD-41">
<g>
<path d="M 700 710 L 600 710 L 600 460" fill="none" stroke="#c73500" stroke-width="2" stroke-miterlimit="10" pointer-events="stroke" style="stroke: rgb(199, 53, 0);"/>
</g>
</g>
<g data-cell-id="5kRB54Jy1bSgiNgllITD-42">
<g>
<rect x="700" y="680" width="220" height="60" fill="#fa6800" stroke="#c73500" stroke-width="2" pointer-events="all" style="fill: rgb(250, 104, 0); stroke: rgb(199, 53, 0);"/>
</g>
<g>
<g fill="#ffffff" font-family="Helvetica" text-anchor="middle" font-size="24px" style="fill: rgb(255, 255, 255);">
<text x="810" y="720">Factorio Server</text>
</g>
</g>
</g>
<g data-cell-id="5kRB54Jy1bSgiNgllITD-46">
<g>
<path d="M 560 210 L 312.74 210" fill="none" stroke="#006eaf" stroke-width="2" stroke-miterlimit="10" pointer-events="stroke" style="stroke: rgb(0, 110, 175);"/>
<path d="M 302.24 210 L 316.24 203 L 312.74 210 L 316.24 217 Z" fill="#006eaf" stroke="#006eaf" stroke-width="2" stroke-miterlimit="10" pointer-events="all" style="fill: rgb(0, 110, 175); stroke: rgb(0, 110, 175);"/>
</g>
</g>
<g data-cell-id="5kRB54Jy1bSgiNgllITD-45">
<g>
<rect x="560" y="180" width="320" height="60" fill="#1ba1e2" stroke="#006eaf" stroke-width="2" pointer-events="all" style="fill: rgb(27, 161, 226); stroke: rgb(0, 110, 175);"/>
</g>
<g>
<g fill="#ffffff" font-family="Helvetica" text-anchor="middle" font-size="24px" style="fill: rgb(255, 255, 255);">
<text x="720" y="220">Web Interface / clusterioctl</text>
</g>
</g>
</g>
<g data-cell-id="5kRB54Jy1bSgiNgllITD-49">
<g>
<rect x="60" y="40" width="580" height="60" fill="none" stroke="none" pointer-events="all"/>
</g>
<g>
<g fill="#000000" font-family="Helvetica" text-anchor="start" font-size="40px" style="fill: rgb(0, 0, 0);">
<text x="64" y="88">Clusterio 2.0 Block Diagram</text>
</g>
</g>
</g>
<g data-cell-id="5kRB54Jy1bSgiNgllITD-51">
<g>
<rect x="352" y="860" width="100" height="40" rx="8" ry="8" fill="#dae8fc" stroke="#6c8ebf" stroke-width="2" stroke-dasharray="6 6" pointer-events="all" style="fill: rgb(218, 232, 252); stroke: rgb(108, 142, 191);"/>
</g>
</g>
<g data-cell-id="5kRB54Jy1bSgiNgllITD-52">
<g>
<rect x="60" y="860" width="220" height="40" fill="none" stroke="none" pointer-events="all"/>
</g>
<g>
<g fill="#000000" font-family="Helvetica" text-anchor="start" font-size="24px" style="fill: rgb(0, 0, 0);">
<text x="64" y="890">Physical Server</text>
</g>
</g>
</g>
<g data-cell-id="5kRB54Jy1bSgiNgllITD-54">
<g>
<path d="M 836 880 L 923.26 880" fill="none" stroke="#001dbc" stroke-width="2" stroke-miterlimit="10" pointer-events="stroke" style="stroke: rgb(0, 29, 188);"/>
<path d="M 933.76 880 L 919.76 887 L 923.26 880 L 919.76 873 Z" fill="#001dbc" stroke="#001dbc" stroke-width="2" stroke-miterlimit="10" pointer-events="all" style="fill: rgb(0, 29, 188); stroke: rgb(0, 29, 188);"/>
</g>
</g>
<g data-cell-id="5kRB54Jy1bSgiNgllITD-55">
<g>
<rect x="544" y="860" width="260" height="40" fill="none" stroke="none" pointer-events="all"/>
</g>
<g>
<g fill="#000000" font-family="Helvetica" text-anchor="start" font-size="24px" style="fill: rgb(0, 0, 0);">
<text x="548" y="890">WebSocket connection</text>
</g>
</g>
</g>
<g data-cell-id="5kRB54Jy1bSgiNgllITD-56">
<g>
<rect x="352" y="920" width="100" height="40" fill="#0050ef" stroke="#001dbc" stroke-width="2" pointer-events="all" style="fill: rgb(0, 80, 239); stroke: rgb(0, 29, 188);"/>
</g>
</g>
<g data-cell-id="5kRB54Jy1bSgiNgllITD-57">
<g>
<rect x="60" y="920" width="192" height="40" fill="none" stroke="none" pointer-events="all"/>
</g>
<g>
<g fill="#000000" font-family="Helvetica" text-anchor="start" font-size="24px" style="fill: rgb(0, 0, 0);">
<text x="64" y="950">Node.js Process</text>
</g>
</g>
</g>
<g data-cell-id="5kRB54Jy1bSgiNgllITD-36">
<g>
<rect x="560" y="400" width="220" height="60" fill="#0050ef" stroke="#001dbc" stroke-width="2" pointer-events="all" style="fill: rgb(0, 80, 239); stroke: rgb(0, 29, 188);"/>
</g>
<g>
<g fill="#ffffff" font-family="Helvetica" text-anchor="middle" font-size="24px" style="fill: rgb(255, 255, 255);">
<text x="670" y="440">Host Process</text>
</g>
</g>
</g>
<g data-cell-id="5kRB54Jy1bSgiNgllITD-9">
<g>
<rect x="80" y="400" width="220" height="60" fill="#0050ef" stroke="#001dbc" stroke-width="2" pointer-events="all" style="fill: rgb(0, 80, 239); stroke: rgb(0, 29, 188);"/>
</g>
<g>
<g fill="#ffffff" font-family="Helvetica" text-anchor="middle" font-size="24px" style="fill: rgb(255, 255, 255);">
<text x="190" y="440">Host Process</text>
</g>
</g>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 13 KiB

+42 -21
View File
@@ -1,36 +1,57 @@
# Communication Layer
<sub>This document describes the communication layer in Clusterio and unless you're developing Clusterio it's probably not going to be very useful for you.</sub>
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.
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.
## Link
## Links and routing
The Link class represents one side of a two way communication channel where the actual communication is done by a connector associated with the link.
The link also knows what the endpoint types it's connected to are via the source and target attributes.
[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 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.
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.
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.
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.
## Connector
## Connectors and sessions
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.
[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.
There's also the virtual connector which lets two links in the same program be connected to each other without having to establish a network connection.
The protocol reference describes [connection and recovery behavior](devs/protocol.md#connection-flow), [connector states and events](devs/protocol.md#connector-flow), and [graceful disconnects](devs/protocol.md#graceful-disconnect).
For handling these events in a plugin, including queueing work and preparing for disconnection, see [connection handling](writing-plugins.md#handling-connection-events).
## Message
## Message definitions and registration
There's two types of messages currently implemented apart from the Link builtin close message and that's Request and Event.
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.
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.
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.
`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.
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.
+22 -7
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 and instance configs.
Plugins can add their own config fields for the controller, host, instance, and ctl configs.
The fields are defined early in the startup, and fields for disabled plugins will still be created.
The built-in config fields are defined in [packages/lib/src/config/definitions.ts](/packages/lib/src/config/definitions.ts) while plugins can add their own config field definitions to the `controllerConfigFields`, `hostConfigFields` and `instanceConfigFields` properties of the `plugin` export.
A `<plugin_name>.load_plugin` field is automatically created for each plugin and it's used to enable/disable the loading plugins on startup.
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.
## Defing Fields
## Defining Fields
Fields are defined as POJS object properties where the name of the property correspond to the name of the configuration field.
The property name is the primary way the field is accessed both in code and on the command line, as such it should follow the lower\_case\_underscore style and consist of a group name and a field name separated by a dot.
For plugins the group name is the name of the plugin, e.g., the `level` field of the `foo_frobber` plugin would be named `foo_frobber.lavel`.
For plugins the group name is the name of the plugin, e.g., the `level` field of the `foo_frobber` plugin would be named `foo_frobber.level`.
**access**:
The locations this config field can be read and modified from.
@@ -44,7 +44,7 @@ For plugins the group name is the name of the plugin, e.g., the `level` field of
**initialValue** (optional if optional is true):
Value this config field should take in a newly initialized config.
This can also be an async function returning the value to use.
This can also be a synchronous function returning the value to use.
**validator** (optional):
Function called with the value being set and the config it belongs to.
@@ -83,7 +83,22 @@ For plugins the group name is the name of the plugin, e.g., the `level` field of
**inputComponent** (optional):
Name of the input component the web interface should render for this field instead of the default for its type.
Plugins can add their own components through `inputComponents` on their web plugin class.
Plugins register components with the `control.hooks.inputComponents` hook in their web entrypoint.
The hook returns an object whose keys match the field's `inputComponent` value.
For example, with `inputComponent: "foo_frobber_level"` on a field:
```js
import LevelInput from "./LevelInput.js";
export default async function({ control, plugin }) {
control.hooks.inputComponents.attach(plugin.name, () => ({
foo_frobber_level: LevelInput,
}));
}
```
`LevelInput` must implement the `InputComponent` contract exported by `@clusterio/web_ui`.
If the value for a field stored in on disk somehow ends up being invalid the field will take on the initial\_value instead when the config is loaded.
+129 -27
View File
@@ -3,16 +3,20 @@
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.
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.
This document covers the development workflow, commands, and repository layout.
Read our [past decisions](/docs/decisions.md) for compatibility policies and design rationale.
## 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)
- [Editing your Feature Branch](#editing-your-feature-branch)
- [Resolving Merge Conflicts](#resolving-merge-conflicts)
@@ -28,6 +32,28 @@ Although, we recomend checking out out our [past decisions](/docs/decisions.md)
- [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 webpack 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.
@@ -52,16 +78,20 @@ Do not run `git pull upstream` it will mess thing up!
### Setting up the Development Environment
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:
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:
pnpm install
pnpm install --no-frozen-lockfile
This installs dependencies needed by tests and links the packages up so they work from the git work tree.
To start a cluster from the repo you will first need to initialize it, this can be done using the installer:
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:
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
@@ -79,9 +109,10 @@ By default node does not enable source maps, to get stack traces pointing to the
Note that to use `clusterioctl` you will have to create a ctl config first:
node packages/controller bootstrap create-ctl-config [admin-user]
node packages/controller bootstrap create-ctl-config <admin-user>
Once you've set up the cluster you can use `node packages/controller run` to run the controller and `node packages/host run` to run the host which connects to the controller.
Use the name of an existing admin user.
Run `node --enable-source-maps packages/controller run` and `node --enable-source-maps packages/host run` in separate terminals.
When editing TypeScript files ran by Node.js you need to run `pnpm build` to compile the files to JavaScript before restarting the controller/host/ctl for changes take effect.
To have TypeScript watch for changes and rebuild immediadly you may run `pnpm watch` instead.
@@ -94,14 +125,46 @@ For web development on the controller there are also the following flags:
These options can be combined and multiple plugins can be actived for dev mode.
Keep in mind if you're working on external plugins the `--dev-plugin` option is likely to only work if the plugin is placed inside the `external_plugins` directory and its dependencies are installed with pnpm as part of the clusterio repository workspace.
Note that webpack is configured to delete the web interface build when using these options, so you will have to run `pnpm run -r --if-present prepare` before you start the controller without these flags again.
Note that webpack is configured to delete the web interface build when using these options, so you will have to run `pnpm -r run prepare` before you start the controller without these flags again.
### Build outputs
| 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 docs` | Generated API documentation. |
`pnpm build` alone does not regenerate the standalone validator or webpack bundles.
After changing `packages/lib/src/data/messages_core.ts`, run `pnpm -r run prepare` to compile the schema and regenerate its validator.
### Debugging
Establish the controller, host, instance, Factorio version, enabled plugins, and exact reproduction first.
Enable source maps and confirm the affected process is running rebuilt code.
Use the contextual logger so host, instance, and plugin metadata remain available.
Development logs are in `logs/`; integration logs are in `temp/test/logs/` and are uploaded by CI.
For a failing request or event, start with its handler and the input that triggers the failure.
Handlers are registered with `handle(MessageClass, handler)`; searching for the message class will help you find its handler.
Inspect the input and the state used by the failing operation, then try to reproduce the failure in a [focused test](#checking-your-code-for-errors).
If the handler is never reached, investigate message registration, routing, and permission checks using the [communication guide](communication.md).
For errors reported by Factorio, start with the Lua stack trace and the module or mod it identifies; the [developing-for-Clusterio guide](developing-for-clusterio.md) explains how that code communicates with the host.
For connection and recovery bugs, test disconnect, reconnect, timeout, and shutdown as applicable.
A cached controller status does not prove the current state of a host or Factorio process.
Recovery mode disables normal plugin loading; account for it when diagnosing missing plugin behavior and [Lua state migrations](developing-for-clusterio.md#initialisation-and-recovery).
Verify UI behavior in the browser: successful compilation does not verify rendering, permissions, or subscriptions.
### Starting a Feature Branch
Development is done exclusively in feature branches.
This means the first thing to do before starting any development at all is to make a new branch starting from the master branch of the main repository.
To do this check out that branch from the main repository.
To start a new feature branch, first check out master from the main repository.
git fetch upstream
git checkout upstream/master
@@ -117,18 +180,61 @@ But it's nice to have its name be somewhat descriptive of what it implements.
### Checking your code for errors
There's automated tests and linting setup for the project.
Please do add tests for any code you add and run both:
Tests use Mocha and Node assertions, mostly as JavaScript ESM importing compiled packages.
Run the test suite and linter from the repository root:
pnpm run test
pnpm run lint
To check that your changes pass the integration tests as well as the ESLint rules set up for the project.
`pnpm test` builds TypeScript and runs root and bundled-plugin tests, excluding `test/manual/`.
Run tests from the root: individual package `test` scripts are often placeholders that deliberately fail.
Use `pnpm cover` for the full suite with c8 coverage.
The full test suite can take a while to run.
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.
When writing tests that take a lot of time call `slowTest(this)` at the start of the test, and for tests that call out to external APIs call `externalTest(this)`, both are exported from `test/integration/index.js`.
For example, `FAST_TEST=y NO_EXTERNAL_TEST=y pnpm test` combines both exclusions.
These flags still run the remaining integration setup and tests; neither produces an isolated unit-test run.
When writing slow tests call `slowTest(this)`, and for external API tests call `externalTest(this)`; both are exported from `test/common.js`.
For tests that need a Factorio installation, call `requiresFactorio(this)` in the test or pass the suite to `requiresFactorio`, imported from `test/integration/index.js`.
`test/common.js` also provides JSON round-trip/schema helpers, and `test/mock.js` provides shared mocks.
For a focused test, build first and select the file explicitly instead of appending it to the full-suite script:
```sh
pnpm build
NODE_EXTRA_CA_CERTS="$PWD/test/file/tls/cert.pem" pnpm exec mocha \
--enable-source-maps --check-leaks test/lib/helpers.js
```
Replace the example file with the relevant test; use `--grep` to narrow it further.
Preserve the test TLS CA configuration for HTTPS integration tests.
Use `--require ./test/setup_logging.js` when an isolated test needs the shared logger configured.
Some tests import `test/integration/index.js`, which installs root hooks and starts a controller and host even for a targeted invocation.
The integration harness recreates its configs, database, mods, and instances under `temp/test/` and uses HTTP port 8880 and HTTPS port 4443.
Do not run competing integration suites in the same checkout or on those ports.
It locates Factorio through `host.factorio_directory` in root `config-host.json`, defaulting to `factorio/`.
The full harness currently requires a usable Factorio installation: a missing directory fails discovery, and an empty directory fails setup while selecting a version.
Some individual tests have conditional skips, but these do not make a full run without Factorio succeed.
Use the relevant production bundle, mod build, or [manual scenario](../test/manual/README.md) for changes affecting those surfaces.
For documentation changes, check that links, paths, and example commands are correct.
### Cleaning generated and runtime files
Inspect `node clean.js --help` and preview the selected scope with `--dry-run` before cleaning.
| Command | Removal scope |
| --- | --- |
| `node clean.js --dry-run` | Preview workspace package/plugin build output and webpack caches; omit `--dry-run` to clean, or use `pnpm clean`. |
| `node clean.js tests --dry-run` | Also preview root `.nyc_output/`, `coverage/`, the whole `temp/` directory, and `static/`. |
| `node clean.js all --dry-run` | Also preview runtime databases, instances, logs, mods, local configs, `FactorioAdminToken.txt`, and `plugin-list.json`. |
`all` deletes cluster runtime data, including configurations and saves; back up anything you want to keep before using it.
The cleaner excludes `external_plugins/` and does not remove mod archives in root `dist/`.
Do not commit local configs, tokens, databases, saves, logs, or generated build output.
### Submitting a Pull Request
@@ -200,8 +306,7 @@ The format of this log should be self explanatory, please follow it carefully. P
## Supported Node.js and Factorio Versions
Clusterio requires Node.js 22 or newer and supports LTS releases in their active and maintenance phases.
For a new development environment, install the latest LTS release using the [installation instructions](../README.md#installation).
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.
@@ -229,13 +334,10 @@ 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.
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.:
import libLink from "./link";
import libLuaTools from "./lua_tools";
Note that no sub-modules of the lib package is imported directly from the outside of it with the exception of `build_mod`.
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.
For lua code lowercase_underscore is used for everything.
@@ -244,7 +346,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.
@@ -285,7 +387,7 @@ For example:
### Markdown
Markdown files are formatted using one line per sentence to improve diff reports when making changes.
Headings have two spaces before them and on space after them to make them stand out, the heading at the top of the file does not have any spaces before it, and heading immediadly followed by another heading have one space between them.
Headings have two blank lines before them and one after them; the top heading has no blank lines before it, and consecutive headings have one blank line between them.
For example:
```md
+7 -5
View File
@@ -452,11 +452,13 @@ While we aim to increase the number of useful checks performed by CI in the futu
🧾 Time Immemorial
> We do not use any CD actions, and have no plans of implementing them.
> We publish packages and mods when a maintainer publishes a stable GitHub release.
Unlike CI, we do not find continuous deployment useful at our current stage of the project.
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.
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.
### Mocha
@@ -475,5 +477,5 @@ However, we are always looking for a better framework which is worth the effort
> We perform all tests through GitHub actions using Ubuntu latest, using all supported versions of node and all supported versions of factorio.
We perform tests on all versions of NodeJS and Factorio that we support.
The [current CI workflow](../.github/workflows/node.js.yml) tests selected Node.js major versions and pinned Factorio releases.
However, we do not conduct tests on all supported operating systems, as the added demand on our testing resources would be significant, and the value it would provide for a cross-platform engine like NodeJS would be limited.
+50 -9
View File
@@ -10,7 +10,9 @@ 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)
@@ -73,7 +75,7 @@ The following entries are recognized by `add_lib`, all of which are optional:
Callback called when the callback of `script.on_configuration_changed` is invoked.
- `events`:
Table mapping event ids as defined in `defines.events` to callbacks.
For example the table `{ [defines.events.on_player_died] = foo }` will call bar with the usual `event` table as argument every time a player dies.
For example the table `{ [defines.events.on_player_died] = foo }` will call foo with the usual `event` table as argument every time a player dies.
- `on_nth_tick`:
Table mapping the nth tick number to a callback.
For example the table `{ [30] = bar }` will call bar every 30th tick.
@@ -92,7 +94,7 @@ For example a module could be defined as
```lua
-- example.lua
local function my_handler(event)
local name = game.player[event.player_index].name
local name = game.get_player(event.player_index).name
game.print("It appears that " .. name .. " has died")
end
@@ -176,12 +178,27 @@ This means always declearing your top level variables and functions local and ex
You can require other files in your module by prefixing your require paths with `modules/your_module_name/`.
It's also possible to require files from other modules this way too.
The global variables and data stored in the global table is shared by all Clusterio modules as well as the scenario, so it's important that you use unique names.
It's recommended to prefix the global variables and data in the global table that your module has with the name of your module.
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
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 the custom `on_server_startup` event that can be used as a substitute, see the next section.
The Clusterio Lua API provides `on_server_startup` for setup and migrations, but some Factorio events can run before it.
In particular, player-leaving, console-command, and configuration-change handlers must not assume startup has initialized their state.
Use an idempotent state accessor in handlers that can run early.
It should migrate any legacy namespace before creating fresh state, fill missing fields, and return the same state on repeated calls.
A guard that returns early because the new namespace is absent can silently discard recovery work on an older save.
See [player_sync's state accessor](../plugins/player_sync/module/get_script_data.lua) for an implementation that handles a renamed namespace and [the module template](../packages/create/templates/common/module/control.lua) for lifecycle setup.
Use `on_load` only to restore local references and other operations permitted by Factorio's load lifecycle; do not create or migrate persistent state there.
Perform state changes in startup or other events that permit them.
Recovery mode skips normal Node plugin loading, so save data may still use an older plugin namespace when Lua handlers run.
Test old and current saves, early events, repeated access, and save/load behavior on the relevant supported Factorio versions.
## Clusterio Lua API
@@ -190,6 +207,29 @@ Clusterio provides a save patched module as well as a regular Factorio mod for i
This includes tools sending data to plugins, script events to listen for, and an item serialization module.
### 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**
@@ -228,7 +268,8 @@ 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.
It is invoked on the first tick the server runs after the save has been patched, before most other events but not all of them.
See [initialisation and recovery](#initialisation-and-recovery) for handlers that can run earlier.
Use this event to initialize and/or migrate the data structures you need.
Keep in mind that both mods and Clusterio modules can switch from any version to any version so it should be able to handle both forwards and backwards migrations gracefully.
@@ -236,7 +277,7 @@ Keep in mind that both mods and Clusterio modules can switch from any version to
#### clusterio_api.send_json(channel, data)
Send JSON data to Clusterio over the given channel.
The `data` argument should be table that can be serialized with `game.table_to_json`.
The `data` argument should be a table that can be serialized with `compat.table_to_json`.
Clusterio plugins can listen to channels and will receive an event with the data sent here.
See the [Communicating with Factorio section](writing-plugins.md#communicating-with-factorio) in the Writing Plugins document for more information.
@@ -249,7 +290,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 game.table_to_json
- `data`: table that can be converted to JSON with `compat.table_to_json`
### serialize library
@@ -260,7 +301,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 `game.table_to_json`.
Serializes inventories and item stacks into a table that in turn can be converted into JSON with `compat.table_to_json`.
Properly handles the extra data for most items.
#### serialize.serialize_equipment_grid(LuaEquipmentGrid)
+113 -84
View File
@@ -2,38 +2,32 @@
Clusterio communicates using a simple homebrew message passing protocol based on JSON over WebSocket with limited support for reliable transport of messages.
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 there's a `Ctl` instance and web_ui there's a `Control` instance that acts as the link to the controller.
Messages received invokes message handlers that were registered when the link was brought up.
These handlers are named after the name of the property the message is stored under with `EventHandler` appended for events and `RequestHandler` appended for requests.
The handlers are defined on the link class itself for core messages, and on the plugin class in the case of plugin defined messages.
## Connection flow
Clients connect via WebSocket to the `/api/socket` endpoint and upon establishing connection to the server the connection is in the handshake phase and the server will send a `hello` message to the client.
If the client has no previous session with the server it sends a `register_host` or `register_control` message depending on what kind of client it is to the server.
If the register is valid then the server will respond with a `ready` message with a session token and the connection has left the handshake phase, otherwise the server will close the connection with a WebSocket close code indicating the kind of failure.
If the 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.
Upon leaving the handshake phase both parties should start sending heartbeat messages with the sequence number of the last received message.
Messages sent over the link should be stored in a send buffer and only cleared from the send buffer when a heartbeat with a `last_seq` equal or or higher is received or the session is invalidated.
Routable messages are stored in a send buffer until a heartbeat acknowledges an equal or higher sequence number, or the session ends.
The heartbeat's top-level `seq` acknowledges receipt by the transport, not successful completion of a message handler.
If the connection goes stale or is unexpectedly closed the client can reastablish the same session by connecting to the server again and sending a `resume` message after received the `hello` from the server.
If the connection goes stale or is unexpectedly closed, the client can re-establish the same session by connecting again and sending a `resume` message after receiving `hello`.
If the session resume is successful then the server will respond with a `continue` message and the connection is active again.
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`.
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.
![Connection flow](../assets/connection_flow.svg)
@@ -41,36 +35,42 @@ Otherwise the server will respond with `invalidate` and the client is expected t
## 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`, `WebSocketServerConnector` and `VirtualConnector`.
There's three kinds of connectors defined: `WebSocketClientConnector`, `WsServerConnector` and `VirtualConnector`.
The virtual connector is for in-memory connections and doesn't have any connection state logic.
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` 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`
- `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.
![Conector States](../assets/connector_states.svg)
### WsClientConnector
The client connector will keep trying to establish and mantain a connection after `.connect()` is called on it, stopping only in the case of authentication failure or `.close()`/`.setClosing()` being called.
Other than that, the logic is pretty straight forward.
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.
![WsClientConnector](../assets/WsClientConnector.svg)
### WsServerConnector
The server connector doesn't do the work of connecting or validating the handshake.
The [server connector](/packages/controller/src/WsServerConnector.ts) doesn't do the work of connecting or validating the handshake.
Instead it's handed over a WebSocket connection from `WsServer` once there's only the step of sending the final message left to do.
Because of this there's no `connecting` state, and once a session times out from the `resuming` state it goes directly to closed.
@@ -79,19 +79,26 @@ Because of this there's no `connecting` state, and once a session times out from
## Wire format
All communication in the protocol is done via WebSocket text frames containing a JSON payload with the following properties:
All communication uses WebSocket text frames containing a JSON object.
Every frame has a `type` string; the other fields depend on that type.
Requests, responses, and events carry an integer transport sequence number in `seq`, plus source and destination addresses in `src` and `dst`.
Message-specific data is carried in `data` when present.
- `seq: integer | null` - The sequence number of the message, can only be null for handshake and heartbeats.
- `type: string` - The type of message
- `data: object` - Message data
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.
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.
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.
### Handshake Messages
The following messages are the only valid messages that can be sent during the handshake phase.
The following messages are used during the handshake phase.
Their listed fields belong inside the frame's `data` object, except for `invalidate`, which has no data.
Property names are case-sensitive.
#### `hello`
@@ -102,7 +109,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.
#### `register_host`
#### `registerHost`
Host handshake for establishing a new connection session.
@@ -112,7 +119,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.
#### `register_control`
#### `registerControl`
Control handshake for enstablishing a new connection session.
@@ -123,29 +130,32 @@ Control handshake for enstablishing a new connection session.
Handshake for resuming an existing connection session where the connection had dropped unexpectedly.
- `session_token: string` - Session token to resume session with.
- `last_seq: integer | null` - Last message seq received by the client.
- `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.
#### `invalidate`
Sent by the controller in reply to a resume for a session that cannot be resumed.
The client should start a new session with a register\_\* message in response.
The frame is `{"type":"invalidate"}`, without a `data` object.
The client should start a new session with `registerHost` or `registerControl` in response.
#### `continue`
Sent by the controller to signal resuming the session was successful.
- `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.
- `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.
#### `ready`
Sent by the controller to signal the connection has been established with a new session.
- `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.
- `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`.
### Heartbeat message
@@ -158,64 +168,83 @@ 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.
- `last_seq: integer` - Last message seq received over the link.
- `seq: integer` (optional, top-level) - Last transport sequence number received over the link.
For example, `{"type":"heartbeat","seq":123}` acknowledges messages through sequence 123.
Before receiving any messages, send `{"type":"heartbeat"}`; there is no `data.last_seq` field.
### Events
Events 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.
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.
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.
See [packages/lib/src/data/](/packages/lib/src/data/) for core event definitions.
Plugins may define their own events as well.
#### `*_event`
#### `event`
- any properties defined by the event type.
- `seq: integer` - Transport sequence number.
- `src: Address` - Source address.
- `dst: Address` - Destination address, including broadcast destinations.
- `name: string` - Registered event name.
- `data` (optional) - Payload defined by the event class.
### Requests
Requests 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.
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`.
See [packages/lib/src/link/messages.ts](/packages/lib/src/link/messages.ts) for the recognized core requests and their contents.
See [packages/lib/src/data/](/packages/lib/src/data/) for core request definitions.
Plugins may define their own requests as well.
#### `*_request`
#### `request`
- any properties defined by the request type.
- `seq: integer` - Transport sequence number.
- `src: Address` - Source address, including the request ID.
- `dst: Address` - Destination address.
- `name: string` - Registered request name.
- `data` (optional) - Payload defined by the request class.
#### `*_response` success
#### `response` success
- `seq: integer` - The seq number of the request message.
- any additional properties defined by the request type.
- `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.
#### `*_response` error
#### `responseError`
- `seq: integer` - The seq number of the request message.
- `error: string` - Human readable text message describing the 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) - Error code.
- `data.stack: string` (optional) - Error stack.
### Graceful Disconnect
To ensure no data is lost when closing the link it's important to send a prepare disconnect request and wait for it to complete before closing the WebSocket.
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.
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.
#### `prepare_disconnect_request`
#### `disconnect` with `data: "prepare"`
Request graceful disconnect of the connection.
The frame `{"type":"disconnect","data":"prepare"}` requests a graceful disconnect.
The receiving party should stop tasks that send events and requests over the link and wait for pending requests it has over the link to complete, but it should not stop responding to requests sent to it.
Only after this process is done should the response be sent.
#### `prepare_disconnect_response`
#### `disconnect` with `data: "ready"`
Signals the preparation for disconnect is complete and that the connection can be closed.
The 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.
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.
### Example handshake
@@ -224,10 +253,10 @@ Server hello after opening the WebSocket.
```json
{
"seq": null,
"type": "hello",
"data": {
"version": "2.0.0"
"version": "2.0.0",
"plugins": {}
}
}
```
@@ -236,12 +265,12 @@ Host registering a new connection session.
```json
{
"seq": null,
"type": "register_host",
"type": "registerHost",
"data": {
"token": "xyzabc123",
"version": "2.0.0",
"id": 123
"id": 123,
"plugins": {}
}
}
```
@@ -250,12 +279,12 @@ Controller reply signaling the session is established and ready.
```json
{
"seq": null,
"type": "ready",
"data": {
"session_token": "xyz.azy.bzy",
"session_timeout": 60,
"heartbeat_interval": 15
"src": [1, 123],
"sessionToken": "xyz.azy.bzy",
"sessionTimeout": 60,
"heartbeatInterval": 15
}
}
```
+22
View File
@@ -1,3 +1,25 @@
# How Clusterio works
Clusterio runs a group of Factorio servers as a cluster, managed through a web interface or the `clusterioctl` command-line client.
The controller is the central point of contact: clients connect to it to manage the cluster, and hosts connect to it to receive commands and exchange messages.
Each host manages its local Factorio server processes, with each server belonging to an instance.
## System overview
The diagram shows one possible layout with two physical servers, each running a host and three Factorio servers.
The controller shares a machine with one host in this example, but it can run on a separate machine.
The number of hosts and instances can vary with the needs of the cluster.
![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.
+8 -4
View File
@@ -11,18 +11,21 @@ If the script fails due to GitHub's 60/hour public access rate limit, you can se
## Packages
The [Publish workflow](../.github/workflows/publish.yml) publishes npm packages and Lua mods when a GitHub release is published.
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 triggers the workflow; pushing a tag alone does not.
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. Ensure you are on the latest version of master branch with no untracked files
1. Inspect `git status` and `git remote -v`, verify which remote is the main repository, and use a clean release worktree on its latest master.
The commands below assume that remote is named `upstream`; preserve unrelated work and external plugin checkouts.
```sh
git switch master
@@ -59,6 +62,7 @@ The steps below describe publishing npm packages manually 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
@@ -67,7 +71,7 @@ The steps below describe publishing npm packages manually instead.
## Plugins
1. Bump the version in the `package.json` file
2. Run `pnpm install` in the repository root
2. Run `pnpm install --no-frozen-lockfile` in the repository root
3. Run `pnpm adduser` to authenticate
4. Run `pnpm publish` in the plugin/package directory
+142 -83
View File
@@ -41,6 +41,9 @@ 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.
@@ -74,15 +77,15 @@ For example, a plugin that needs an export map can use:
Here's an example of `index.js`:
```js
import * as messages from "./messages.js";
export const plugin = {
name: "foo_frobber",
title: "Foo Frobber",
description: "Does advanced frobnication",
instanceEntrypoint: "instance.js",
controllerEntrypoint: "controller.js",
messages: {
/* See below */
},
messages: [messages.Frobnicate],
};
```
@@ -120,11 +123,24 @@ The following properties are recognized:
Object mapping controller configuration fields to `FieldDefinition` objects 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.
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 Module Federation exposure key for the web entrypoint, such as `"./web"`.
It must match the key in the plugin's webpack `exposes` configuration; it is not a Node.js file path.
**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.
@@ -135,7 +151,7 @@ The following properties are recognized:
See [Handling Invalid Configuration](#handling-invalid-configuration)
**messages**:
Object with link messages definitions for this plugin.
Array of request and event classes for this plugin.
See guide for [defining link messages](#defining-link-messages) below.
**permissions**:
@@ -149,7 +165,8 @@ The only restriction imposed on modules embedded into plugins is that they must
While there is no standard for how to organize a plugin it's recommended to put the controller entrypoint into controller.js and the instance entrypoint into instance.js.
You can put them into whatever file you want (even the same one for both).
The entrypoint paths must include the .js extension and use forward slashes for directory separators if any.
Node.js entrypoint paths must include the `.js` extension and use forward slashes for directory separators.
This rule does not apply to the `webEntrypoint` exposure key.
## Defining the plugin entrypoint
@@ -236,7 +253,7 @@ try {
// Operation that should not throw but may end up throwing
} catch (err) {
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.
@@ -245,7 +262,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.
You can take advantage of it by declaring config fields under either `controllerConfigFields` or `instanceConfigFields` in the `plugin` export.
Declare fields under `controllerConfigFields`, `hostConfigFields`, `instanceConfigFields`, or `ctlConfigFields` in the `plugin` export.
For example in index.js:
```ts
@@ -340,6 +357,8 @@ 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
@@ -367,7 +386,7 @@ They run synchronously, a handler that throws stops the controller or host from
`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, each config has a hidden `<plugin>.version` field with the version of the plugin it was last loaded with, which is updated after the migrations have run:
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 => {
@@ -474,7 +493,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.
It's also important to catch any errors that might occur as they will otherwise be propogated to the instance code and kill the server.
With raw `server.on(...)` listeners, catch errors in the handler; `server.handle(...)` already handles error logging for IPC handlers.
Data out from Factorio does not have the same limits as data into Factorio, RCON responses can be in 100kB range without causing issues, and payloads to the `send_json` API can be in the 4MB range provided the server has a fast enough storage system.
@@ -496,59 +515,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.
Messages are defined as items of the `plugin.messages` array exported by `index.js`.
For example, the following could be defined in `plugin`:
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:
```js
messages: [
class Frobnicate {
static plugin = "foo_frobber"; // Plugin name
static type = "request";
static src = "controller";
static dst = "instance";
static jsonSchema = {
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: {
type: "object",
properties: {
"verbosity": { type: "integer" },
"special": { type: "boolean" },
},
required: ["verbosity"],
additionalProperties: false,
};
constructor(json) {
this.verbosity = json.verbosity;
this.special = json.special;
}
static fromJSON(json) {
return new Frobnicate(json);
}
toJSON() {
return {
verbosity: this.verbosity,
special: this.special,
};
}
static Response = {
jsonSchema: {
type: "object",
properties: {
"report": {
type: "array",
items: { type: "string" },
},
report: {
type: "array",
items: { type: "string" },
},
},
fromJSON(json) {
return json
}
}
}
]
required: ["report"],
additionalProperties: false,
},
fromJSON(json) {
return json;
},
};
}
```
This specifies a request that can be sent from the controller to an instance, being routed through the correct host behind the scenes.
This specifies a request sent from an instance to the controller through its host.
The request data must contain the property `verbosity` with an integer number as the value, and it may also contain a boolean `special` property.
It also defines that the response sent may contain a `report` property mapping to an array of strings.
The response must contain a `report` property containing an array of strings.
The following properties are recognized as part of a message class:
@@ -577,6 +596,34 @@ If the target is multiple hops away it the message will be forwarded towards its
For example a message sent from a control connection an instance will be forwarded by the controller to the relevant host and from the host to the correct instance.
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 `PermissionError` for a normal denial.
For example, this checks the payload shape needed to choose a permission; the destination schema still validates the full payload:
```js
import { PermissionError } from "@clusterio/lib";
function checkFrobnicatePermission(user, message) {
const data = message.data;
if (!data || typeof data !== "object" || typeof data.special !== "boolean") {
throw new PermissionError("Expected a boolean special field");
}
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.
@@ -588,7 +635,8 @@ This should return an object that's valid according to `jsonSchema` and accepted
#### static fromJSON
Required function to restore the class from a JSON object.
Function to restore the class from a JSON object; it must be provided together with `jsonSchema` for messages carrying data.
Messages without data can omit both and are constructed without arguments.
This function is called when the message reaches its destination and is expected to return an identical instance of the class.
I.e. the result of `fromJSON(JSON.parse(JSON.stringify(instance)))` should be a complete copy of the message.
@@ -597,54 +645,62 @@ 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({
const response = await instance.sendTo("controller", new messages.Frobnicate({
verbosity: 2,
special: false,
}));
console.log(response); // { report: [...] }
}
```
Because the send and handle methods rely on this, you should always call them directly on the connection object.
If you extract them into a variable, destructure them, or pass them as unbound callbacks, they lose their this context. The method will eventually crash with an error like Cannot read properties of undefined (reading 'sendRequest').
While it's fine to type cast a direct method invocation, casting and then storing the result causes that same dangerous loss of context.
Here is what that looks like in practice:
Register the corresponding handler on the controller:
```js
// ❌ BAD: The extracted method loses its `this` binding and crashes when called.
const send = instance.sendTo;
send("controller", new messages.Frobnicate({ foo: "bar" }));
import * as messages from "./messages.js";
// ✅ GOOD: Call it directly on the connection object. (Cast args/results here if needed).
instance.sendTo("controller", new messages.Frobnicate({ foo: "bar" }));
// ✅ GOOD: Explicitly bind the receiver if you need to pass a method as a value (like a handler).
instance.handle(messages.Frobnicate, plugin.handleFrobnicate.bind(plugin));
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 specification can either be the ID of a particular control, host or instance, or one of the keywords used to send to multiple targets at once.
The destination can identify a particular control, host, or instance.
The `allHosts`, `allControls`, and `allInstances` broadcast destinations are for events, not requests.
Example destinations:
```js
sendTo("controller", message);
sendTo("allHosts", message);
sendTo("allControls", message);
sendTo("allInstances", message);
sendTo({ hostId: 123 }, message);
sendTo({ instanceId: 123 }, message);
sendTo({ controlId: 123 }, message);
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);
```
### Handling connection events
@@ -656,20 +712,23 @@ This is signaled to controller plugins via the `prepareHostDisconnect` hook and
After the prepare disconnect the connection will be closed, which will result in pending requests and events being dropped.
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 the sending of an event from an instance plugin can be stopped while the connection is not connected, not in the dropped state, and not in the process of discunnecting by using the following code:
For example, an instance plugin can avoid starting a request while the host is disconnected or preparing to disconnect:
```js
if (host.connected) {
instance.sendTo("controller", new messages.Frobnicate({ foo: "bar" }));
await instance.sendTo("controller", new messages.Frobnicate({ verbosity: 2, special: false }));
}
```
If the event or request needs to be sent to the controller it can be put into a queue and sent out when the connection is established again.
The re-establishement of the connection is notified to plugins via the `connect` event to the `controllerConnectionEvent` and `hostConnectionEvent` hooks.
The connection hooks report `connect` for a new session and `resume` for a retained session.
Keep application queues separate from messages already accepted into the connector buffer; blindly sending both again can duplicate an operation.
The second connection event which is of lesser importance to respond to is the `drop` connection event served through `controllerConnectionEvent` for instance plugins and through `hostConnectionEvent` for controller plugins.
This is raised when the connection between the controller and host in question is lost, most likely due to networking issues.
When in the dropped state the host will keep trying to reconnect to the controller in order to re-establish it, and if successful no events or requests will be lost.
When in the dropped state the host retries the connection and attempts to resume the retained session.
Successful resumption replays unacknowledged buffered messages; expiry, invalidation, or buffer overflow can instead end the session.
This is not an application-level exactly-once guarantee; stateful plugins need their own recovery and duplicate-handling strategy.
However while in the dropped state any requests and events sent gets queued up in memory until the connection is either re-established or the session times out.
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.
@@ -699,7 +758,7 @@ It's recommended that plugin metrics follow `clusterio_<plugin_name>_<metric_nam
For metrics that are per-instance, you must define an `instance_id` label and set it accordingly, for example:
```js
import { Counter } from "@clusterio/lib";
import { Gauge } from "@clusterio/lib";
const barMetric = new Gauge(
"clusterio_foo_frobber_bar_metric", "Bar instance level",
+4 -3
View File
@@ -109,8 +109,8 @@ export interface FieldDefinition<
*/
autoComplete?: string;
/**
* Web UI component to use for rending this input. Can be extended by
* @{link "@clusterio/web_ui".BaseWebPlugin.inputComponents}
* Web UI component to use for rendering this input. Plugins register components
* through the inputComponents hook on control.hooks in their web entrypoint.
*/
inputComponent?: string;
/**
@@ -119,7 +119,8 @@ export interface FieldDefinition<
optional?: Type extends null ? true : false;
/**
* Value this config field should take in a newly initialized config.
* This can also be an function returning the value to use.
* This can also be a synchronous function returning the value to use.
* Promise results are not awaited.
*/
initialValue?: unknown | (() => unknown);
/**
+15 -1
View File
@@ -1,6 +1,6 @@
# Clusterio Subspace Storage Plugin
Plugin for the Subpsace Storage mod.
Plugin for the [Subspace Storage mod](https://github.com/clusterio/subspace_storage).
## Installation
@@ -40,3 +40,17 @@ Defaults to `false`.
Log every item transfer in and out of the storage to the host console at verbose level.
Defaults to `false`.
## Development: inventory updates
The Node sender is in [instance.ts](instance.ts); the Factorio receiver is `UpdateInvData` in the separate mod repository's [src/control.lua](https://github.com/clusterio/subspace_storage/blob/master/src/control.lua).
Check both ends when changing inventory synchronization.
Each entry is `[name, count, quality]` and replaces the absolute count for that name/quality pair; it is not an amount to add or subtract.
With `full=true`, the receiver clears the previous inventory before applying entries, so an empty full snapshot clears all old state.
Without `full=true`, entries update only the listed pairs; omitted pairs retain their previous values, and an explicit zero records a zero count.
A new or restarted instance needs a complete snapshot before incremental updates.
Keep a complete inventory cache separate from any latest-delta cache: one delta is not enough to initialize a new receiver, and changes across several update windows must not overwrite each other before delivery.
Verify empty snapshots, zero counts, multiple qualities, and consecutive deltas when changing this path.