mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-13 23:14:53 +09:00
Compare commits
69
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b781731ecc | ||
|
|
c1826aea98 | ||
|
|
9b654b1ad8 | ||
|
|
e7c21fb907 | ||
|
|
cc8888fc69 | ||
|
|
b9d7033724 | ||
|
|
75cdfbad8d | ||
|
|
3707fc35fc | ||
|
|
0f7c8be060 | ||
|
|
53a4fce934 | ||
|
|
ed2057e4a2 | ||
|
|
8738c74d7f | ||
|
|
c9db2fb29e | ||
|
|
41e3f50dbe | ||
|
|
5df375d490 | ||
|
|
0312a3b099 | ||
|
|
a174c355d2 | ||
|
|
8a3aa56c11 | ||
|
|
c13b1fa78f | ||
|
|
31e132d4fd | ||
|
|
b989815829 | ||
|
|
814a4a201a | ||
|
|
5589609b1b | ||
|
|
78f1e8a993 | ||
|
|
666dd48412 | ||
|
|
eb1e2e2a5e | ||
|
|
fa2883d74e | ||
|
|
3079426712 | ||
|
|
7d421847f5 | ||
|
|
885de50625 | ||
|
|
c724f144e7 | ||
|
|
96dddb1526 | ||
|
|
3c2ee3e372 | ||
|
|
9bd6effd40 | ||
|
|
a82af49c05 | ||
|
|
1eea756ab3 | ||
|
|
2fb5300ec5 | ||
|
|
11e067a933 | ||
|
|
9aa232e2cd | ||
|
|
82b7c16dba | ||
|
|
ebdbb1fe56 | ||
|
|
6f70ccbf85 | ||
|
|
188de979b6 | ||
|
|
9ab36151bd | ||
|
|
942773c66c | ||
|
|
4d4c509722 | ||
|
|
bf847b8c74 | ||
|
|
5c92a44e65 | ||
|
|
ebedf6b3b0 | ||
|
|
f40113e01c | ||
|
|
a4db2dc460 | ||
|
|
e94abe51c9 | ||
|
|
3227179613 | ||
|
|
35125a28ab | ||
|
|
1a03e66479 | ||
|
|
54e9340e61 | ||
|
|
1c7c00b033 | ||
|
|
ffe82f81d8 | ||
|
|
e36a76acc5 | ||
|
|
bda5c5a8c0 | ||
|
|
86829d50ba | ||
|
|
903e2e0ab6 | ||
|
|
abb04f3258 | ||
|
|
3a81cfd94b | ||
|
|
ee097bfa6f | ||
|
|
d7fc3aaec4 | ||
|
|
5355a0170a | ||
|
|
e16d0bc410 | ||
|
|
a6d2d48e48 |
No files matched your search
@@ -1,5 +1,5 @@
|
||||
name: Install dependencies
|
||||
description: Set up pnpm and Node.js, restore caches and run pnpm install
|
||||
description: Set up pnpm and Node.js and run pnpm install
|
||||
|
||||
inputs:
|
||||
node-version:
|
||||
@@ -12,42 +12,15 @@ inputs:
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
package-manager-cache: false
|
||||
|
||||
# The lockfile is not committed, so the usual lockfile hash key does not
|
||||
# apply. Instead a new store snapshot is saved whenever a package.json
|
||||
# changes or a week passes, and every run restores the most recent one.
|
||||
- id: store
|
||||
shell: bash
|
||||
run: |
|
||||
echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
||||
echo "week=$(date -u +%G-%V)" >> "$GITHUB_OUTPUT"
|
||||
- uses: actions/cache@v6
|
||||
with:
|
||||
path: ${{ steps.store.outputs.path }}
|
||||
key: pnpm-store-${{ runner.os }}-${{ steps.store.outputs.week }}-${{ hashFiles('**/package.json', 'pnpm-workspace.yaml') }}
|
||||
restore-keys: pnpm-store-${{ runner.os }}-
|
||||
|
||||
# The prepare scripts run tsc --build and webpack, both incremental when
|
||||
# their previous output is present. Every run saves its output and the
|
||||
# next run starts from the most recent one.
|
||||
- if: inputs.ignore-scripts != 'true'
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: |
|
||||
packages/*/dist
|
||||
plugins/*/dist
|
||||
packages/*/node_modules/.cache
|
||||
plugins/*/node_modules/.cache
|
||||
key: build-${{ runner.os }}-${{ github.sha }}
|
||||
restore-keys: build-${{ runner.os }}-
|
||||
# pnpm/action-setup installs a bootstrap pnpm and then switches to the
|
||||
# packageManager version, which takes about 10 seconds.
|
||||
- shell: bash
|
||||
run: npm install --global --no-audit --no-fund "$(node -p 'require("./package.json").packageManager.split("+")[0]')"
|
||||
|
||||
- shell: bash
|
||||
run: pnpm i --no-frozen-lockfile ${{ inputs.ignore-scripts == 'true' && '--ignore-scripts' || '' }}
|
||||
# Drop packages the restored store had but this install did not use, so
|
||||
# the snapshot does not grow with every version bump.
|
||||
- shell: bash
|
||||
run: pnpm store prune
|
||||
@@ -4,6 +4,10 @@ on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref == 'refs/heads/master' && github.sha || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
EMMYLUA_CHECK_VERSION: "0.25.1"
|
||||
|
||||
|
||||
@@ -7,6 +7,12 @@ on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
# Superseded runs of a branch or pull request are cancelled. Runs on master
|
||||
# each get their own group so every commit there is tested.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref == 'refs/heads/master' && github.sha || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
NODE_VERSION: 24.x
|
||||
|
||||
@@ -22,17 +28,33 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# Test Node.js 24 and 26 across the Factorio matrix; Node.js 22 runs coverage below.
|
||||
# To stay within the concurrent job limit, every Factorio version is
|
||||
# tested on the latest Node.js, and every other supported Node.js
|
||||
# version only with the latest Factorio. Coverage is performed on the
|
||||
# oldest supported Node.js.
|
||||
# Node.js release schedule: https://nodejs.org/en/about/releases/
|
||||
node-version: [24.x, 26.x]
|
||||
|
||||
# Supported factorio versions are latest stable and one minor version before stable (even if from a previous major)
|
||||
# Supported factorio versions are latest stable and one minor version before stable (even if from a previous major)
|
||||
node-version: [26.x]
|
||||
factorio-version: [1.1.110, 2.0.77, 2.1.17]
|
||||
|
||||
# Coverage is performed on the older of the supported node versions
|
||||
|
||||
# The tests run in two halves of similar length on separate runners,
|
||||
# the clusterioctl, plugin system and instance integration tests and the rest.
|
||||
tests: [ctl, other]
|
||||
|
||||
include:
|
||||
- node-version: 24.x
|
||||
factorio-version: 2.1.17
|
||||
tests: ctl
|
||||
- node-version: 24.x
|
||||
factorio-version: 2.1.17
|
||||
tests: other
|
||||
- node-version: 22.x
|
||||
factorio-version: 2.1.17
|
||||
tests: ctl
|
||||
coverage: true
|
||||
- node-version: 22.x
|
||||
factorio-version: 2.1.17
|
||||
tests: other
|
||||
coverage: true
|
||||
|
||||
steps:
|
||||
@@ -45,21 +67,23 @@ jobs:
|
||||
run: wget -q -O factorio.tar.gz https://www.factorio.com/get-download/${{ matrix.factorio-version }}/headless/linux64 && tar -xf factorio.tar.gz && rm factorio.tar.gz
|
||||
- name: Run tests
|
||||
if: ${{ !matrix.coverage }}
|
||||
run: pnpm test
|
||||
run: pnpm test --grep "^(Integration of Clusterio (clusterioctl|plugin system)|Clusterio Instance) " ${{ matrix.tests == 'other' && '--invert' || '' }}
|
||||
- name: Run coverage
|
||||
if: ${{ matrix.coverage }}
|
||||
run: pnpm run ci-cover
|
||||
run: pnpm run ci-cover --grep "^(Integration of Clusterio (clusterioctl|plugin system)|Clusterio Instance) " ${{ matrix.tests == 'other' && '--invert' || '' }}
|
||||
- name: Upload coverage to Codecov
|
||||
if: ${{ matrix.coverage }}
|
||||
uses: codecov/codecov-action@v7
|
||||
with:
|
||||
files: ./coverage/lcov.info
|
||||
# Otherwise the raw V8 coverage in coverage/tmp is uploaded as well
|
||||
disable_search: true
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
- name: Upload test logs for debugging
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: logs-${{ matrix.node-version }}-${{ matrix.factorio-version }}
|
||||
name: logs-${{ matrix.node-version }}-${{ matrix.factorio-version }}-${{ matrix.tests }}
|
||||
path: temp/test/logs
|
||||
|
||||
version-compat:
|
||||
@@ -87,8 +111,6 @@ jobs:
|
||||
uses: ./.github/actions/pnpm-install
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
- name: Build
|
||||
run: pnpm run build
|
||||
- name: Test ${{ matrix.controller }} controller with ${{ matrix.host }} host
|
||||
run: >
|
||||
node scripts/version-compat-test.js
|
||||
|
||||
@@ -23,6 +23,8 @@ jobs:
|
||||
registry-url: https://registry.npmjs.org
|
||||
- run: pnpm install --no-frozen-lockfile
|
||||
- run: pnpm -r --filter='./packages/*' --filter='./plugins/*' --fail-if-no-match publish --no-git-checks
|
||||
env:
|
||||
NODE_ENV: production
|
||||
- uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
files: pnpm-lock.yaml
|
||||
|
||||
+1
-3
@@ -23,9 +23,6 @@ coverage
|
||||
build/Release
|
||||
dist/
|
||||
|
||||
# Autogenerated docs output
|
||||
/docs/api
|
||||
|
||||
node_modules
|
||||
|
||||
/factorio*
|
||||
@@ -35,6 +32,7 @@ node_modules
|
||||
/plugin-list.json
|
||||
/config-*.json
|
||||
/config-*.json.lock
|
||||
/npm-operation.lock*
|
||||
pnpm-lock.yaml
|
||||
mod_cache.json
|
||||
.luarc.json
|
||||
|
||||
+458
@@ -0,0 +1,458 @@
|
||||
// Formatting rules come from @stylistic as Oxlint does not implement them.
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": [
|
||||
"node"
|
||||
],
|
||||
"jsPlugins": [
|
||||
"@stylistic/eslint-plugin"
|
||||
],
|
||||
"categories": {
|
||||
"correctness": "off"
|
||||
},
|
||||
"env": {
|
||||
"builtin": true,
|
||||
"node": true
|
||||
},
|
||||
"ignorePatterns": [
|
||||
"**/dist/",
|
||||
"dist/"
|
||||
],
|
||||
"rules": {
|
||||
"@stylistic/indent": [
|
||||
"error",
|
||||
"tab",
|
||||
{
|
||||
"ignoredNodes": [
|
||||
"JSXFragment"
|
||||
],
|
||||
"ignoreComments": true,
|
||||
"SwitchCase": 1
|
||||
}
|
||||
],
|
||||
"accessor-pairs": "error",
|
||||
"array-callback-return": "error",
|
||||
"arrow-body-style": "error",
|
||||
"block-scoped-var": "error",
|
||||
"complexity": [
|
||||
"error",
|
||||
{
|
||||
"max": 25
|
||||
}
|
||||
],
|
||||
"curly": "error",
|
||||
"default-case": "error",
|
||||
"default-case-last": "error",
|
||||
"eqeqeq": "error",
|
||||
"func-name-matching": "error",
|
||||
"func-style": [
|
||||
"error",
|
||||
"declaration",
|
||||
{
|
||||
"allowArrowFunctions": true
|
||||
}
|
||||
],
|
||||
"grouped-accessor-pairs": "error",
|
||||
"guard-for-in": "error",
|
||||
"id-match": "error",
|
||||
"max-depth": "error",
|
||||
"max-nested-callbacks": "error",
|
||||
"new-cap": [
|
||||
"error",
|
||||
{
|
||||
"capIsNewExceptions": [
|
||||
"StringEnum",
|
||||
"StringKey"
|
||||
],
|
||||
"capIsNewExceptionPattern": "^Type\\.",
|
||||
"newIsCapExceptions": [
|
||||
"constructor"
|
||||
]
|
||||
}
|
||||
],
|
||||
"no-alert": "error",
|
||||
"no-array-constructor": "error",
|
||||
"no-caller": "error",
|
||||
"no-console": "error",
|
||||
"no-constant-condition": [
|
||||
"error",
|
||||
{
|
||||
"checkLoops": false
|
||||
}
|
||||
],
|
||||
"no-constructor-return": "error",
|
||||
"no-div-regex": "error",
|
||||
"no-duplicate-imports": "error",
|
||||
"no-else-return": "error",
|
||||
"no-eq-null": "error",
|
||||
"no-eval": "error",
|
||||
"no-extend-native": "error",
|
||||
"no-extra-bind": "error",
|
||||
"no-extra-label": "error",
|
||||
"no-implicit-coercion": "error",
|
||||
"no-implicit-globals": "error",
|
||||
"no-implied-eval": "error",
|
||||
"no-iterator": "error",
|
||||
"no-label-var": "error",
|
||||
"no-lone-blocks": "error",
|
||||
"no-lonely-if": "error",
|
||||
"no-loop-func": "error",
|
||||
"no-multi-assign": "error",
|
||||
"no-multi-str": "error",
|
||||
"no-nested-ternary": "error",
|
||||
"no-new": "error",
|
||||
"no-new-func": "error",
|
||||
"no-new-wrappers": "error",
|
||||
"no-plusplus": [
|
||||
"error",
|
||||
{
|
||||
"allowForLoopAfterthoughts": true
|
||||
}
|
||||
],
|
||||
"no-proto": "error",
|
||||
"no-restricted-globals": "error",
|
||||
"no-restricted-imports": "error",
|
||||
"no-restricted-properties": "error",
|
||||
"no-return-assign": "error",
|
||||
"no-script-url": "error",
|
||||
"no-self-compare": "error",
|
||||
"no-sequences": "error",
|
||||
"no-shadow": [
|
||||
"error",
|
||||
{
|
||||
"allow": [
|
||||
"Event",
|
||||
"Request",
|
||||
"yargs"
|
||||
]
|
||||
}
|
||||
],
|
||||
"no-template-curly-in-string": "error",
|
||||
"no-throw-literal": "error",
|
||||
"no-unmodified-loop-condition": "error",
|
||||
"no-unneeded-ternary": "error",
|
||||
"no-unused-expressions": "error",
|
||||
"no-useless-backreference": "error",
|
||||
"no-useless-call": "error",
|
||||
"no-useless-computed-key": "error",
|
||||
"no-useless-concat": "error",
|
||||
"no-useless-rename": "error",
|
||||
"no-useless-return": "error",
|
||||
"no-var": "error",
|
||||
"no-void": [
|
||||
"error",
|
||||
{
|
||||
"allowAsStatement": true
|
||||
}
|
||||
],
|
||||
"prefer-arrow-callback": "error",
|
||||
"prefer-exponentiation-operator": "error",
|
||||
"prefer-numeric-literals": "error",
|
||||
"prefer-object-spread": "error",
|
||||
"prefer-promise-reject-errors": "error",
|
||||
"prefer-regex-literals": "error",
|
||||
"prefer-rest-params": "error",
|
||||
"prefer-spread": "error",
|
||||
"prefer-template": "error",
|
||||
"radix": [
|
||||
"error",
|
||||
"always"
|
||||
],
|
||||
"symbol-description": "error",
|
||||
"unicode-bom": [
|
||||
"error",
|
||||
"never"
|
||||
],
|
||||
"vars-on-top": "error",
|
||||
"yoda": [
|
||||
"error",
|
||||
"never",
|
||||
{
|
||||
"exceptRange": true
|
||||
}
|
||||
],
|
||||
"node/no-sync": [
|
||||
"error",
|
||||
{
|
||||
"ignores": [
|
||||
"TechnologySync",
|
||||
"techsToSync"
|
||||
]
|
||||
}
|
||||
],
|
||||
"@stylistic/array-bracket-newline": "off",
|
||||
"@stylistic/array-bracket-spacing": [
|
||||
"error",
|
||||
"never"
|
||||
],
|
||||
"@stylistic/array-element-newline": "off",
|
||||
"@stylistic/arrow-parens": "off",
|
||||
"@stylistic/arrow-spacing": [
|
||||
"error",
|
||||
{
|
||||
"after": true,
|
||||
"before": true
|
||||
}
|
||||
],
|
||||
"@stylistic/block-spacing": "error",
|
||||
"@stylistic/brace-style": [
|
||||
"error",
|
||||
"1tbs",
|
||||
{
|
||||
"allowSingleLine": true
|
||||
}
|
||||
],
|
||||
"@stylistic/comma-dangle": [
|
||||
"error",
|
||||
{
|
||||
"arrays": "always-multiline",
|
||||
"objects": "always-multiline",
|
||||
"imports": "always-multiline",
|
||||
"exports": "always-multiline",
|
||||
"functions": "only-multiline",
|
||||
"generics": "only-multiline",
|
||||
"enums": "always-multiline",
|
||||
"tuples": "always-multiline"
|
||||
}
|
||||
],
|
||||
"@stylistic/comma-spacing": "error",
|
||||
"@stylistic/comma-style": [
|
||||
"error",
|
||||
"last"
|
||||
],
|
||||
"@stylistic/computed-property-spacing": [
|
||||
"error",
|
||||
"never"
|
||||
],
|
||||
"@stylistic/dot-location": [
|
||||
"error",
|
||||
"property"
|
||||
],
|
||||
"@stylistic/eol-last": "error",
|
||||
"@stylistic/function-call-argument-newline": "off",
|
||||
"@stylistic/function-paren-newline": "off",
|
||||
"@stylistic/generator-star-spacing": [
|
||||
"error",
|
||||
{
|
||||
"before": false,
|
||||
"after": true
|
||||
}
|
||||
],
|
||||
"@stylistic/implicit-arrow-linebreak": "error",
|
||||
"@stylistic/jsx-quotes": "error",
|
||||
"@stylistic/key-spacing": "error",
|
||||
"@stylistic/keyword-spacing": "error",
|
||||
"@stylistic/line-comment-position": "off",
|
||||
"@stylistic/linebreak-style": [
|
||||
"error",
|
||||
"unix"
|
||||
],
|
||||
"@stylistic/lines-around-comment": "off",
|
||||
"@stylistic/lines-between-class-members": [
|
||||
"error",
|
||||
"always",
|
||||
{
|
||||
"exceptAfterSingleLine": true
|
||||
}
|
||||
],
|
||||
"@stylistic/max-len": [
|
||||
"error",
|
||||
{
|
||||
"code": 120
|
||||
}
|
||||
],
|
||||
"@stylistic/max-statements-per-line": "off",
|
||||
"@stylistic/multiline-comment-style": [
|
||||
"error",
|
||||
"separate-lines"
|
||||
],
|
||||
"@stylistic/multiline-ternary": "off",
|
||||
"@stylistic/new-parens": "error",
|
||||
"@stylistic/newline-per-chained-call": "off",
|
||||
"@stylistic/no-confusing-arrow": "error",
|
||||
"@stylistic/no-extra-parens": "off",
|
||||
"@stylistic/no-floating-decimal": "error",
|
||||
"@stylistic/no-mixed-operators": [
|
||||
"error",
|
||||
{
|
||||
"groups": [
|
||||
[
|
||||
"&",
|
||||
"|",
|
||||
"^",
|
||||
"~",
|
||||
"<<",
|
||||
">>",
|
||||
">>>"
|
||||
],
|
||||
[
|
||||
"==",
|
||||
"!=",
|
||||
"===",
|
||||
"!==",
|
||||
">",
|
||||
">=",
|
||||
"<",
|
||||
"<="
|
||||
],
|
||||
[
|
||||
"in",
|
||||
"instanceof"
|
||||
]
|
||||
]
|
||||
}
|
||||
],
|
||||
"@stylistic/no-multi-spaces": "error",
|
||||
"@stylistic/no-multiple-empty-lines": "error",
|
||||
"@stylistic/no-tabs": "off",
|
||||
"@stylistic/no-trailing-spaces": "error",
|
||||
"@stylistic/no-whitespace-before-property": "error",
|
||||
"@stylistic/nonblock-statement-body-position": "error",
|
||||
"@stylistic/object-curly-newline": "error",
|
||||
"@stylistic/object-curly-spacing": "off",
|
||||
"@stylistic/one-var-declaration-per-line": "error",
|
||||
"@stylistic/operator-linebreak": [
|
||||
"error",
|
||||
"after",
|
||||
{
|
||||
"overrides": {
|
||||
"?": "before",
|
||||
":": "before",
|
||||
"||": "before",
|
||||
"&&": "before",
|
||||
"??": "before",
|
||||
"|": "before",
|
||||
"&": "before"
|
||||
}
|
||||
}
|
||||
],
|
||||
"@stylistic/padded-blocks": "off",
|
||||
"@stylistic/padding-line-between-statements": "error",
|
||||
"@stylistic/quote-props": "off",
|
||||
"@stylistic/quotes": [
|
||||
"error",
|
||||
"double",
|
||||
{
|
||||
"avoidEscape": true
|
||||
}
|
||||
],
|
||||
"@stylistic/rest-spread-spacing": [
|
||||
"error",
|
||||
"never"
|
||||
],
|
||||
"@stylistic/semi": "error",
|
||||
"@stylistic/semi-spacing": "error",
|
||||
"@stylistic/semi-style": "off",
|
||||
"@stylistic/space-before-blocks": "error",
|
||||
"@stylistic/space-before-function-paren": "off",
|
||||
"@stylistic/space-in-parens": [
|
||||
"error",
|
||||
"never"
|
||||
],
|
||||
"@stylistic/space-infix-ops": "off",
|
||||
"@stylistic/space-unary-ops": "error",
|
||||
"@stylistic/spaced-comment": [
|
||||
"error",
|
||||
"always",
|
||||
{
|
||||
"line": {
|
||||
"markers": [
|
||||
"/"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"@stylistic/switch-colon-spacing": "error",
|
||||
"@stylistic/template-curly-spacing": [
|
||||
"error",
|
||||
"never"
|
||||
],
|
||||
"@stylistic/template-tag-spacing": "error",
|
||||
"@stylistic/wrap-iife": "error",
|
||||
"@stylistic/wrap-regex": "off",
|
||||
"@stylistic/yield-star-spacing": "error"
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": [
|
||||
"{packages,plugins}/**/*.{ts,tsx}"
|
||||
],
|
||||
"rules": {
|
||||
"no-unused-vars": "off",
|
||||
"typescript/ban-ts-comment": "error",
|
||||
"typescript/no-duplicate-enum-values": "error",
|
||||
"typescript/no-empty-object-type": "error",
|
||||
"typescript/no-explicit-any": "off",
|
||||
"typescript/no-extra-non-null-assertion": "error",
|
||||
"typescript/no-misused-new": "error",
|
||||
"typescript/no-namespace": "error",
|
||||
"typescript/no-non-null-asserted-optional-chain": "error",
|
||||
"typescript/no-require-imports": "off",
|
||||
"typescript/no-this-alias": "error",
|
||||
"typescript/no-unnecessary-type-constraint": "error",
|
||||
"typescript/no-unsafe-declaration-merging": "error",
|
||||
"typescript/no-unsafe-function-type": "error",
|
||||
"typescript/no-wrapper-object-types": "error",
|
||||
"typescript/prefer-as-const": "error",
|
||||
"typescript/prefer-namespace-keyword": "error",
|
||||
"typescript/triple-slash-reference": "off",
|
||||
"no-shadow": [
|
||||
"error",
|
||||
{
|
||||
"allow": [
|
||||
"Event",
|
||||
"Request",
|
||||
"yargs"
|
||||
]
|
||||
}
|
||||
],
|
||||
"no-useless-constructor": "error"
|
||||
},
|
||||
"plugins": [
|
||||
"typescript"
|
||||
]
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
"test/**/*.js",
|
||||
"plugins/*/test/**/*.js"
|
||||
],
|
||||
"rules": {
|
||||
"prefer-arrow-callback": "off"
|
||||
},
|
||||
"env": {
|
||||
"mocha": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
"packages/web_ui/src/**/*.{ts,tsx}",
|
||||
"packages/lib/browser.ts",
|
||||
"packages/create/templates/*/web/**/*.{ts,js,tsx,jsx}",
|
||||
"{packages,plugins}/*/web/**/*.{ts,tsx}"
|
||||
],
|
||||
"globals": {
|
||||
"SpeechRecognitionAlternative": "readonly",
|
||||
"SpeechRecognitionResult": "readonly",
|
||||
"SpeechRecognitionResultList": "readonly"
|
||||
},
|
||||
"env": {
|
||||
"browser": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
"packages/create/templates/**/*.{js,cjs,ts,cts,jsx,tsx}"
|
||||
],
|
||||
"rules": {
|
||||
"@stylistic/indent": "off",
|
||||
"@stylistic/spaced-comment": "off",
|
||||
"@stylistic/comma-spacing": "off",
|
||||
"@stylistic/max-len": "off"
|
||||
},
|
||||
"jsPlugins": [
|
||||
"@stylistic/eslint-plugin"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -18,7 +18,7 @@ Use the detailed guides for the affected area:
|
||||
- Read the relevant source and tests before changing behavior.
|
||||
If documentation and implementation disagree, report the discrepancy and distinguish observed behavior from intended policy.
|
||||
Update affected documentation in the same change.
|
||||
- Follow nearby code and `eslint.config.mjs`: tabs, double quotes, semicolons, and `.js` extensions in relative TypeScript imports.
|
||||
- Follow nearby code and `.oxlintrc.json`: tabs, double quotes, semicolons, and `.js` extensions in relative TypeScript imports.
|
||||
Import shared public APIs through `@clusterio/lib` and check Node/browser exports when changing them.
|
||||
- Run commands from the root using the pinned pnpm version and the [documented Node requirements](docs/contributing.md#supported-nodejs-and-factorio-versions).
|
||||
Install with `pnpm install --no-frozen-lockfile`; the lockfile is intentionally ignored.
|
||||
|
||||
@@ -49,7 +49,7 @@ async function removeBuildArtifacts() {
|
||||
tryRemove(path.join(basePath, packageName, "dist"))
|
||||
);
|
||||
tasks.push(
|
||||
tryRemove(path.join(basePath, packageName, "node_modules", ".cache"))
|
||||
tryRemove(path.join(basePath, packageName, "node_modules", ".vite"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ Plugin classes also supply a `plugin` name.
|
||||
|
||||
`Link.register` registers request and event classes and compiles their payload validators.
|
||||
Core classes are registered by the link module; `registerPluginMessages` registers each plugin's `messages` array.
|
||||
It also registers the plugin's `errors` array with `Link.registerError`, so an error thrown by a request handler is rejected on the requester as the same class.
|
||||
Incoming messages invoke handlers attached with `link.handle(MessageClass, handler)`.
|
||||
Plugins attach these handlers from their entrypoint functions rather than relying on method-name conventions.
|
||||
|
||||
|
||||
@@ -116,7 +116,7 @@ E.g.:
|
||||
let value = configInstance.fields["group.field"]
|
||||
|
||||
The set method also takes an extra argument, which is the value to set the field to.
|
||||
If the value is not valid this will throw an InvalidValue exception, if the field doesn't exist it will throw an InvalidField exception.
|
||||
If the value is not valid this will throw an InvalidValueError exception, if the field doesn't exist it will throw an InvalidFieldError exception.
|
||||
|
||||
|
||||
## Config Lifecycle
|
||||
|
||||
@@ -103,7 +103,7 @@ Defaults to null meaning assume localhost.
|
||||
|
||||
Used to override where static assets such as the web bundle is loaded from.
|
||||
This can be used if you want to put the static assets on a separate cdn url like `https://cdn.example.com/static/` while keeping the API hosted directly by clusterio.
|
||||
Due to a limitation on how the webpack assets are built the pathname component of the url have to be `/static/`, values with any other path are rejected.
|
||||
Due to a limitation on how the web assets are built the pathname component of the url have to be `/static/`, values with any other path are rejected.
|
||||
|
||||
See also the `clusteriocontroller copy-static` command for how to obtain the static asset files that need to be hosted.
|
||||
|
||||
@@ -644,6 +644,8 @@ The settings in `data/server-settings.example.json` of the Factorio installation
|
||||
|
||||
Changes to the following properties will be applied live if the instance is running while it is changed: `afk_autokick_interval`, `allow_commands`, `autosave_interval`, `autosave_only_on_server`, `description`, `ignore_player_limit_for_returning_players`, `max_players`, `max_upload_slots`, `max_upload_in_kilobytes_per_second`, `name`, `only_admins_can_pause_the_game`, `game_password`, `require_user_verification`, `tags`, `visibility`.
|
||||
|
||||
With `auto_pause` on, the plugins included with Clusterio stop their periodic commands while no players are online, so the game stays paused.
|
||||
|
||||
Defaults to a set of common settings filled in when the instance is created, such as `"tags": ["clusterio"]`, `"auto_pause": false` and a `name` and `description` based on the cluster and instance names.
|
||||
|
||||
|
||||
|
||||
+40
-8
@@ -18,6 +18,7 @@ Read our [past decisions](/docs/decisions.md) for compatibility policies and des
|
||||
- [Checking your code for errors](#checking-your-code-for-errors)
|
||||
- [Cleaning generated and runtime files](#cleaning-generated-and-runtime-files)
|
||||
- [Submitting a Pull Request](#submitting-a-pull-request)
|
||||
- [Breaking Compatibility with the Last Release](#breaking-compatibility-with-the-last-release)
|
||||
- [Editing your Feature Branch](#editing-your-feature-branch)
|
||||
- [Resolving Merge Conflicts](#resolving-merge-conflicts)
|
||||
- [Updating Your GitHub Fork](#updating-your-github-fork)
|
||||
@@ -42,7 +43,7 @@ Clusterio manages distributed Factorio servers through a controller, hosts, and
|
||||
| `packages/host/` | Instance lifecycle, Factorio processes, save patching, and exports. Start with `src/Host.ts`, `src/Instance.ts`, `src/server.ts`, and `src/patch.ts`. |
|
||||
| `packages/ctl/` | Command-line administration and plugin commands. |
|
||||
| `packages/lib/` | Shared messages, routing, config, permissions, plugin loading, logging, and utilities for Node and browser consumers. |
|
||||
| `packages/web_ui/` | React/Ant Design interface and browser control connection; bundled through the controller's webpack build. |
|
||||
| `packages/web_ui/` | React/Ant Design interface and browser control connection; bundled through the controller's Vite build. |
|
||||
| `packages/create/` | Installer and JavaScript/TypeScript plugin templates. |
|
||||
| `plugins/` | Bundled plugins, including their Node, web, Lua, and test code. |
|
||||
| `external_plugins/` | Plugins developed in independent repositories and included in the local workspace. |
|
||||
@@ -119,13 +120,12 @@ To have TypeScript watch for changes and rebuild immediadly you may run `pnpm wa
|
||||
|
||||
For web development on the controller there are also the following flags:
|
||||
|
||||
`--dev` - Recompile the `web_ui` package on the fly providing live reloading of it.
|
||||
`--dev` - Serve the `web_ui` package from source through Vite's development server with hot module replacement.
|
||||
|
||||
`--dev-plugin [name]` - Recompile the web module for the given plugin on the fly proving live reloading of it.
|
||||
`--dev-plugin [name]` - Serve the web module for the given plugin from source the same way.
|
||||
|
||||
These options can be combined and multiple plugins can be actived for dev mode.
|
||||
Keep in mind if you're working on external plugins the `--dev-plugin` option is likely to only work if the plugin is placed inside the `external_plugins` directory and its dependencies are installed with pnpm as part of the clusterio repository workspace.
|
||||
Note that webpack is configured to delete the web interface build when using these options, so you will have to run `pnpm -r run prepare` before you start the controller without these flags again.
|
||||
|
||||
|
||||
### Build outputs
|
||||
@@ -136,9 +136,8 @@ Note that webpack is configured to delete the web interface build when using the
|
||||
| `pnpm watch` | TypeScript compilation as files change; restart affected Node processes afterward. |
|
||||
| `pnpm -r run prepare` | Compiled packages, generated validators, and production web bundles. |
|
||||
| `pnpm build-mod` | Factorio mod archives in root `dist/`. |
|
||||
| `pnpm docs` | Generated API documentation. |
|
||||
|
||||
`pnpm build` alone does not regenerate the standalone validator or webpack bundles.
|
||||
`pnpm build` alone does not regenerate the standalone validator or web bundles.
|
||||
After changing `packages/lib/src/data/messages_core.ts`, run `pnpm -r run prepare` to compile the schema and regenerate its validator.
|
||||
|
||||
|
||||
@@ -228,7 +227,7 @@ Inspect `node clean.js --help` and preview the selected scope with `--dry-run` b
|
||||
|
||||
| Command | Removal scope |
|
||||
| --- | --- |
|
||||
| `node clean.js --dry-run` | Preview workspace package/plugin build output and webpack caches; omit `--dry-run` to clean, or use `pnpm clean`. |
|
||||
| `node clean.js --dry-run` | Preview workspace package/plugin build output and Vite dependency caches; omit `--dry-run` to clean, or use `pnpm clean`. |
|
||||
| `node clean.js tests --dry-run` | Also preview root `.nyc_output/`, `coverage/`, the whole `temp/` directory, and `static/`. |
|
||||
| `node clean.js all --dry-run` | Also preview runtime databases, instances, logs, mods, local configs, `FactorioAdminToken.txt`, and `plugin-list.json`. |
|
||||
|
||||
@@ -251,6 +250,38 @@ If there are more changes you want to have merged or mistakes you need to fix yo
|
||||
Pull request descriptions should contain a `### Changelog` section with additions for the changelog contained inside a code block, see the [CHANGELOG.md](/CHANGELOG.md) file for the formatting of changelog entries and possible sections to use.
|
||||
If there are no changelog entries to add, remove the code block and start the changelog section with the text `None.`, followed by an optional reason for why it is not is needed.
|
||||
|
||||
### Breaking Compatibility with the Last Release
|
||||
|
||||
The version-compat job in CI runs the local controller with the latest published host, and the published controller with the local host.
|
||||
It fails if a link message changed its src, dst, data or response schema since that release.
|
||||
Control is left out of src and dst, and messages that only go between control and the controller are not checked, as the web UI is served by the controller it talks to.
|
||||
If the break is intended, add an entry to [scripts/version-compat-breaks.json](../scripts/version-compat-breaks.json) in the same pull request.
|
||||
Breaking changes are only accepted if the next release is intended to include them, see our decision on [breaking changes](/docs/decisions.md#breaking-changes).
|
||||
The entry is keyed by the published version directly before the breaking change was introduced, which is the latest release at the time.
|
||||
Every entry needs the pull request number, a reason saying what breaks, and an impact saying why end users are not or only minimally affected.
|
||||
|
||||
```json
|
||||
{
|
||||
"2.0.3": {
|
||||
"messages": {
|
||||
"request FactorioVersionsRequest": {
|
||||
"pr": 1129,
|
||||
"reason": "What breaks between the two versions.",
|
||||
"impact": "Why end users are not or only minimally affected."
|
||||
}
|
||||
},
|
||||
"connect": { "pr": 1234, "reason": "Why a host cannot connect at all.", "impact": "..." }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Listed messages are reported as warnings with the reason and impact instead of failing the job.
|
||||
Only add `connect` when the published and local versions cannot connect at all, it skips starting the cluster.
|
||||
Listing a message that did not change fails the job.
|
||||
Entries only apply while their version is the latest release, after the next release they are ignored and can be removed.
|
||||
Describe the break in the Breaking Changes section of the changelog.
|
||||
|
||||
|
||||
### Editing your Feature Branch
|
||||
|
||||
Until your feature branch is merged you can edit it as you see fit by using the rebase tool.
|
||||
@@ -308,7 +339,8 @@ The format of this log should be self explanatory, please follow it carefully. P
|
||||
|
||||
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.
|
||||
The GitHub Actions tests run every Factorio version on the latest Node.js, and every other supported Node.js version with the latest Factorio only, which keeps the number of jobs within GitHub's concurrent job limit.
|
||||
Coverage is collected on the oldest supported Node.js; see the [CI workflow](../.github/workflows/node.js.yml) for the version matrix.
|
||||
|
||||
For Factorio, active support covers the latest stable version and the previous major version, using version lines such as 2.1, 2.0 and 1.1 as described in the [Factorio support policy](decisions.md#factorio).
|
||||
Older versions from 0.17 onwards receive passive support where it does not restrict actively supported versions.
|
||||
|
||||
+20
-13
@@ -18,7 +18,7 @@ If there is a particular issue or concern you would like the team to comment on
|
||||
- [Naming Times and Durations](#naming-times-and-durations)
|
||||
- [Strings](#strings)
|
||||
3. Code Style (TS / JS / Web)
|
||||
- [ESLint](#eslint)
|
||||
- [Oxlint](#oxlint)
|
||||
- [Imports Within `lib` Package](#imports-within-lib-package)
|
||||
- [Name Style](#name-style-ts)
|
||||
4. Code Style (Lua)
|
||||
@@ -38,7 +38,7 @@ If there is a particular issue or concern you would like the team to comment on
|
||||
- [React](#react)
|
||||
- [Separation of Host and Controller](#separation-of-host-and-controller)
|
||||
- [Typescript](#typescript)
|
||||
- [Webpack](#webpack)
|
||||
- [Vite](#vite)
|
||||
8. Supportability
|
||||
- [Breaking Changes](#breaking-changes)
|
||||
- [Distribution](#distribution)
|
||||
@@ -91,6 +91,11 @@ Therefore, we decided to use TSDoc comments for this purpose as it is well inter
|
||||
Our specific implementation is TypeDoc because it is lightweight it and can output TSDoc comments as html.
|
||||
API Extractor was also considered but not selected.
|
||||
|
||||
🧾 29th October 2026 | [Github PR #1147](https://github.com/clusterio/clusterio/pull/1147)
|
||||
|
||||
We dropped TypeDoc as the generated docs were never published and it does not support TypeScript 7.
|
||||
TSDoc comments are still used for editor hover text.
|
||||
|
||||
|
||||
## Code Style (General)
|
||||
|
||||
@@ -157,14 +162,15 @@ Therefore, we selected double quotes as our standard.
|
||||
|
||||
## Code Style (TS / JS / Web)
|
||||
|
||||
### ESLint
|
||||
### Oxlint
|
||||
|
||||
🧾 Time Immemorial
|
||||
🧾 20th October 2026
|
||||
|
||||
> Use ESLint for style checking.
|
||||
> Use Oxlint for style checking, with formatting rules from `@stylistic/eslint-plugin`.
|
||||
|
||||
We needed to maintain a consistent style across all our files.
|
||||
Therefore, we utilise the industry-standard ESLint, as it is familiar and highly configurable.
|
||||
We needed to maintain a consistent style across all our files and used ESLint for it from the start.
|
||||
Oxlint runs the same rules about three times faster, reads ESLint disable comments, and does not depend on the TypeScript compiler API.
|
||||
The formatting rules ESLint deprecated run through `@stylistic/eslint-plugin` loaded as an Oxlint JS plugin, so the code style did not change.
|
||||
|
||||
|
||||
### Imports Within `lib` Package
|
||||
@@ -347,14 +353,15 @@ Transitioning to any other langauge would be too significant an undertaking.
|
||||
Initially, we supported multiple languages for plugins, including Rust and Go; however, this approach was quickly abandoned due to its complexity.
|
||||
|
||||
|
||||
### Webpack
|
||||
### Vite
|
||||
|
||||
🧾 Time Immemorial
|
||||
🧾 20th October 2026
|
||||
|
||||
> Our web stack uses Webpack and associated plugins.
|
||||
> Our web stack uses Vite. Plugin web bundles are ES modules that import shared dependencies through an import map.
|
||||
|
||||
We required a bundler for our web build, and at the time, Webpack was the standard.
|
||||
While we have considered alternatives since, transitioning to another solution would be a significant undertaking.
|
||||
We used Webpack with Module Federation for loading plugins from the start, as it was the standard at the time.
|
||||
Vite builds the web interface several times faster, its development server updates modules without reloading the page, and plugin bundles no longer depend on one bundler's runtime.
|
||||
See [#1127](https://github.com/clusterio/clusterio/issues/1127) for the discussion.
|
||||
|
||||
|
||||
## Supportability
|
||||
@@ -370,7 +377,7 @@ We aim to be highly transparent with breaking changes and maintain compatibility
|
||||
During the alpha stage every alpha was allowed to contain breaking changes, as long as they were documented and provided migration actions.
|
||||
From 2.0 onwards changes which require action from people running a cluster, such as changes to configs, stored data or the command line, are held back for the next major version.
|
||||
Changes to the plugin API may still be made in minor versions.
|
||||
Plugin authors should check the changelog when a new minor version is released, non-trivial changes come with a migration guide such as [Migrating Plugins to 2.0](/docs/devs/plugin-migration-to-2.0.md).
|
||||
Plugin authors should check the changelog when a new minor version is released, non-trivial changes come with a migration guide such as [Migrating Plugins to 2.1](/docs/devs/plugin-migration-to-2.1.md).
|
||||
|
||||
|
||||
### Distribution
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# Migrating Plugins to 2.1
|
||||
|
||||
This guide covers the changes needed to bring a plugin written for 2.0 up to 2.1.
|
||||
Plugins still on an alpha should start with the [2.0 guide](plugin-migration-to-2.0.md).
|
||||
|
||||
|
||||
## Request errors
|
||||
|
||||
`RequestError` is gone.
|
||||
Errors thrown by request handlers are now received by the requester as the same class, see [Request errors](/docs/writing-plugins.md#request-errors).
|
||||
Replace `RequestError` with the class that fits:
|
||||
|
||||
- Invalid input: `ValidationError`.
|
||||
- Operation disabled by config: `ForbiddenError`.
|
||||
- Missing instance, user, save etc.: `NotFoundError`.
|
||||
- Instance running, name taken etc.: `ConflictError`.
|
||||
- Host not connected, instance not running: `UnavailableError`.
|
||||
- Timeouts: `TimeoutError`.
|
||||
|
||||
`catch` blocks that check `err instanceof RequestError` should check `ClientError` instead, or the specific class.
|
||||
Checks of `err.code` for remote errors can use `instanceof`, e.g. `err instanceof lib.SessionLost`.
|
||||
|
||||
The config errors `lib.InvalidValue`, `lib.InvalidField` and `lib.InvalidAccess` were renamed to `lib.InvalidValueError`, `lib.InvalidFieldError` and `lib.InvalidAccessError`.
|
||||
|
||||
Errors of classes that are not registered, like a `TypeError` from a bug, are received as `InternalServerError`.
|
||||
Plugins that define their own error classes need to list them in `errors` of the plugin declaration.
|
||||
|
||||
|
||||
## Build the web module with Vite
|
||||
|
||||
The web interface is now built with Vite instead of webpack, and plugin web modules are loaded as ES modules instead of through Module Federation.
|
||||
A plugin built with webpack fails to load with the error "Incompatible dist/web/manifest.json, the plugin must be rebuilt with Vite".
|
||||
|
||||
Delete `webpack.config.cjs` and add a `vite.config.js` next to `package.json`:
|
||||
|
||||
```js
|
||||
import { defineConfig } from "vite";
|
||||
import { clusterioPlugin } from "@clusterio/web_ui/vite.mjs";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [clusterioPlugin()],
|
||||
});
|
||||
```
|
||||
|
||||
In `package.json` replace `webpack-cli --mode production` in the `prepare` script with `vite build`, and replace the `webpack`, `webpack-cli` and `webpack-merge` dev dependencies with `vite`.
|
||||
Add `vite.config.js` to `.npmignore` if you had `webpack.config.js` there.
|
||||
|
||||
`clusterioPlugin` exposes `index.ts` (or `index.js`) as `"./"`, `package.json` as `"./package.json"` and `web/index.tsx` (or `.ts`, `.jsx`, `.js`) as `"./web"`.
|
||||
These are the same keys the plugin template used with webpack, so `webEntrypoint: "./web"` stays as it is.
|
||||
If your webpack config exposed other paths, move the code so it is reachable from `web/index.tsx`.
|
||||
|
||||
The web interface provides `@clusterio/lib`, `@clusterio/web_ui`, `@sinclair/typebox`, `antd`, `react`, `react-dom`, `react-router` and `react-router-dom` to plugins.
|
||||
These are left as imports in the plugin bundle and resolved by the browser, there is no need to list them as shared.
|
||||
Everything else is bundled into the plugin, including `@ant-design/icons`.
|
||||
Import `react-dom/client` and `react/jsx-runtime` by those names, other subpaths of the shared packages are bundled as a separate copy.
|
||||
|
||||
Imports of `assert`, `buffer`, `events`, `os`, `path`, `process`, `stream`, `util` and `zlib` resolve to the same browser packages as before, provided by `@clusterio/web_ui`.
|
||||
|
||||
The `container` property of `WebPluginContext` is now the plugin's ES module namespace, `await container.get("./web")` returns the module instead of a factory function.
|
||||
|
||||
`lib.PluginWebpackEnvInfo` is renamed to `lib.PluginWebEnvInfo`.
|
||||
@@ -223,7 +223,8 @@ Plugins may define their own requests as well.
|
||||
- `src: Address` - Responding endpoint.
|
||||
- `dst: Address` - Requester's address, including the original request ID.
|
||||
- `data.message: string` - Human-readable error description.
|
||||
- `data.code: string` (optional) - Error code.
|
||||
- `data.code: string` (optional) - Registered name of the error class, `<plugin>:<class>` for plugin errors.
|
||||
Missing or unknown names are received as `InternalServerError`.
|
||||
- `data.stack: string` (optional) - Error stack.
|
||||
|
||||
|
||||
|
||||
@@ -283,10 +283,15 @@ This command lists all plugins currently known to the remote. Newly installed pl
|
||||
|
||||
### Update
|
||||
|
||||
ctl> controller plugin update <name> [--restart]
|
||||
ctl> host plugin update <host> <name> [--restart]
|
||||
ctl> controller plugin update <name>[@<version>] [--restart]
|
||||
ctl> host plugin update <host> <name>[@<version>] [--restart]
|
||||
|
||||
If remote plugin updates are enabled on the remote, this will execute `npm update --save <name>`. Changes will not be applied until the remote is restarted. If the `--restart` flag is provided, the remote will automatically restart once the update is complete.
|
||||
If remote plugin updates are enabled on the remote, this will execute `npm update --save <name>`, which stays within the version range saved in the remote's `package.json`.
|
||||
To update to a specific version, including one outside that range, pass it as `<name>@<version>`, for example `@clusterio/plugin-example@2.1.0`.
|
||||
The version must be published on npm, and the remote then executes `npm install --save <name>@<version>`.
|
||||
The plugin must already be installed on the remote, and only remote plugin updates need to be enabled.
|
||||
Changes will not be applied until the remote is restarted.
|
||||
If the `--restart` flag is provided, the remote will automatically restart once the update is complete.
|
||||
|
||||
### Update All
|
||||
|
||||
@@ -297,10 +302,30 @@ Updates the Clusterio package and all plugins installed from npm in a single `np
|
||||
|
||||
### Install
|
||||
|
||||
ctl> controller plugin install <name> [--restart]
|
||||
ctl> host plugin install <host> <name> [--restart]
|
||||
ctl> controller plugin install <name>[@<version>] [--restart]
|
||||
ctl> host plugin install <host> <name>[@<version>] [--restart]
|
||||
|
||||
If remote plugin installs are enabled on the remote, this will execute `npm install --save <name>`.
|
||||
Pass `<name>@<version>` to install a specific version, for example to match the version used by the rest of the cluster.
|
||||
The package, and the version if given, must be published on npm.
|
||||
Changes will not be applied until the remote is restarted.
|
||||
If the `--restart` flag is provided, the remote will automatically restart once the update is complete.
|
||||
|
||||
### Search
|
||||
|
||||
ctl> controller plugin search [query] [--page <n>] [--page-size <n>]
|
||||
|
||||
Searches the npm registry for packages tagged with the clusterio-plugin keyword.
|
||||
The search is done by the controller and does not require remote installs to be enabled.
|
||||
The web UI has a plugin browser on the plugins page that uses the same search.
|
||||
|
||||
### Concurrent Operations
|
||||
|
||||
Only one npm operation runs at a time on a machine.
|
||||
A standalone controller and host share their working directory, so the operation holds the `npm-operation.lock` file there while npm runs.
|
||||
Other installs and updates on the same machine or installation are rejected with "Another package operation is already in progress on this machine" until it finishes.
|
||||
A lock file left behind by a process that is no longer running is removed automatically.
|
||||
|
||||
If remote plugin installs are enabled on the remote, this will execute `npm install --save <name>`. Changes will not be applied until the remote is restarted. If the `--restart` flag is provided, the remote will automatically restart once the update is complete.
|
||||
|
||||
### Enable/Disable Updates
|
||||
|
||||
|
||||
@@ -19,6 +19,20 @@ Use [contributing](contributing.md) for setup and validation commands.
|
||||
Check supported configurations and compatibility policy; a merged revision or earlier clean review is not proof of correctness.
|
||||
|
||||
|
||||
## Surface overlapping responsibilities
|
||||
|
||||
- For substantial new or replaced functionality, check whether existing code or test helpers already serve the same responsibility, including outside the changed files.
|
||||
Search by behavior, then verify the implementations and their callers; nearby code is not necessarily the preferred shared implementation.
|
||||
- Compare contracts, callers, and constraints, not just names or similar code.
|
||||
Distinguish shared behavior and general correctness requirements from differences that may justify separate implementations.
|
||||
- Surface significant overlap to the developer with source locations for both implementations, the duplicated responsibility, its consequences, and any legitimate differences.
|
||||
Explain maintenance or consistency risks without presenting them as reproduced runtime defects.
|
||||
- Recommend an approach based on the evidence, including alternatives, tradeoffs, and affected callers or tests.
|
||||
Leave the architectural decision to the developer unless already authorized; do not silently consolidate the implementations or dismiss the overlap as intentional.
|
||||
- Recheck after substantial fixes, especially when shared functionality is replaced, bypassed, or overridden.
|
||||
Keep unresolved design questions in the candidate ledger and final report, and record the developer's decision when provided.
|
||||
|
||||
|
||||
## Exercise the boundaries
|
||||
|
||||
- Write a small matrix of relevant states and transitions before probing.
|
||||
|
||||
+76
-5
@@ -138,8 +138,8 @@ The following properties are recognized:
|
||||
Object mapping ctl configuration fields to `FieldDefinition` objects.
|
||||
|
||||
**webEntrypoint**:
|
||||
Optional Module Federation exposure key for the web entrypoint, such as `"./web"`.
|
||||
It must match the key in the plugin's webpack `exposes` configuration; it is not a Node.js file path.
|
||||
Optional key of the web entrypoint in the plugin's web bundle, this should be `"./web"`.
|
||||
The `clusterioPlugin` Vite plugin exposes `web/index.tsx` (or `.ts`, `.jsx`, `.js`) under this key; it is not a Node.js file path.
|
||||
|
||||
**migrationEntrypoint**:
|
||||
Path to a Node.js module relative to the plugin directory which exports the migration entrypoint function for this plugin.
|
||||
@@ -154,6 +154,10 @@ The following properties are recognized:
|
||||
Array of request and event classes for this plugin.
|
||||
See guide for [defining link messages](#defining-link-messages) below.
|
||||
|
||||
**errors**:
|
||||
Array of error classes for this plugin.
|
||||
See [Request errors](#request-errors).
|
||||
|
||||
**permissions**:
|
||||
Array of permission definitions for this plugin.
|
||||
See [Plugin Permissions](#plugin-permissions)
|
||||
@@ -501,6 +505,25 @@ Data out from Factorio does not have the same limits as data into Factorio, RCON
|
||||
For `send_json` it's possible that payloads greater than 4kB are received after payloads that were sent at a later point in time.
|
||||
For RCON, commands longer than 50 characters may end up being executed after shorter commands sent after it.
|
||||
|
||||
### Auto pause
|
||||
|
||||
Each RCON command makes a paused game run one tick, so commands sent on a timer keep a server with `auto_pause` on running while nobody is online.
|
||||
`instance.autoPaused` is true while the instance was started with `auto_pause` on and no players are online, and the `autoPauseChanged` hook is invoked with the new value when it changes.
|
||||
Skip periodic commands while it is true:
|
||||
|
||||
```js
|
||||
instance.hooks.start.attach(plugin.name, async () => {
|
||||
timer = setInterval(() => {
|
||||
if (!instance.autoPaused) {
|
||||
instance.sendRcon("/sc my_plugin.update()").catch(handleError);
|
||||
}
|
||||
}, 1000);
|
||||
});
|
||||
```
|
||||
|
||||
Commands sent in response to events from elsewhere in the cluster are fine, they cost one tick each.
|
||||
A game paused with `game.tick_paused` does not run ticks for commands.
|
||||
|
||||
### Communicating over UDP
|
||||
|
||||
Instances can optionally exchange UDP packets with the Factorio server for low latency fire-and-forget communication, using the `--enable-lua-udp` option of Factorio.
|
||||
@@ -641,16 +664,16 @@ Registration rejects control-origin messages with no permission declaration.
|
||||
|
||||
A callback receives `(user, message)`, where `message` is the raw wire envelope.
|
||||
It runs on the controller before destination payload validation and deserialization, so `message.data` is untrusted and may be missing or malformed.
|
||||
Check any required payload shape before inspecting fields, and use `user.checkPermission(...)` or throw `PermissionError` for a normal denial.
|
||||
Check any required payload shape before inspecting fields, and use `user.checkPermission(...)` or throw a `ClientError` such as `PermissionError` for a normal denial.
|
||||
For example, this checks the payload shape needed to choose a permission; the destination schema still validates the full payload:
|
||||
|
||||
```js
|
||||
import { PermissionError } from "@clusterio/lib";
|
||||
import { ValidationError } from "@clusterio/lib";
|
||||
|
||||
function checkFrobnicatePermission(user, message) {
|
||||
const data = message.data;
|
||||
if (!data || typeof data !== "object" || typeof data.special !== "boolean") {
|
||||
throw new PermissionError("Expected a boolean special field");
|
||||
throw new ValidationError("Expected a boolean special field");
|
||||
}
|
||||
user.checkPermission(data.special ? "foo_frobber.special" : "foo_frobber.frobnicate");
|
||||
}
|
||||
@@ -740,6 +763,54 @@ connection.sendTo({ instanceId: 123 }, message);
|
||||
connection.sendTo({ controlId: 123 }, message);
|
||||
```
|
||||
|
||||
### Request errors
|
||||
|
||||
An error thrown by a request handler is sent back to the requester, and the promise returned by `.sendTo()` rejects with an instance of the same class.
|
||||
The error classes follow the HTTP status codes:
|
||||
|
||||
```
|
||||
Error
|
||||
├── ClientError (4xx)
|
||||
│ ├── ValidationError (400)
|
||||
│ │ ├── InvalidValueError
|
||||
│ │ └── InvalidFieldError
|
||||
│ ├── ForbiddenError (403)
|
||||
│ │ ├── PermissionError
|
||||
│ │ └── InvalidAccessError
|
||||
│ ├── NotFoundError (404)
|
||||
│ ├── TimeoutError (408)
|
||||
│ └── ConflictError (409)
|
||||
└── ServerError (5xx)
|
||||
├── InternalServerError (500)
|
||||
└── UnavailableError (503)
|
||||
```
|
||||
|
||||
Throw a `ClientError` subclass when the request can't be done as asked, and `UnavailableError` when the host or instance that should handle it is not connected or not running, for example:
|
||||
|
||||
```js
|
||||
controller.handle(messages.Frobnicate, async request => {
|
||||
if (!frobnicators.has(request.id)) {
|
||||
throw new lib.NotFoundError(`Frobnicator ${request.id} does not exist`);
|
||||
}
|
||||
// ...
|
||||
});
|
||||
```
|
||||
|
||||
Errors of a class that is not registered are logged as unexpected by the side that threw them and are received as `InternalServerError`.
|
||||
Registered errors are not logged.
|
||||
The message and stack trace of the original error are kept in both cases.
|
||||
|
||||
Plugins can define their own error classes by listing them in `errors` of the plugin declaration.
|
||||
They need a static `plugin` property set to the plugin name and must take the message as the first constructor argument:
|
||||
|
||||
```js
|
||||
export class FrobnicatorJammed extends lib.ConflictError {
|
||||
static plugin = "foo_frobber";
|
||||
}
|
||||
```
|
||||
|
||||
A subclass that is not registered is received as its closest registered parent class.
|
||||
|
||||
### Handling connection events
|
||||
|
||||
There are a few connection related events that plugins neeed to repsond to in order to avoid data loss and connection problems.
|
||||
|
||||
@@ -1,504 +0,0 @@
|
||||
import {
|
||||
defineConfig,
|
||||
globalIgnores,
|
||||
} from "eslint/config";
|
||||
|
||||
import typescriptEslint from "@typescript-eslint/eslint-plugin";
|
||||
import node from "eslint-plugin-node";
|
||||
import globals from "globals";
|
||||
import tsParser from "@typescript-eslint/parser";
|
||||
import js from "@eslint/js";
|
||||
import stylistic from "@stylistic/eslint-plugin";
|
||||
|
||||
import {
|
||||
FlatCompat,
|
||||
} from "@eslint/eslintrc";
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: import.meta.dirname,
|
||||
recommendedConfig: js.configs.recommended,
|
||||
allConfig: js.configs.all,
|
||||
});
|
||||
|
||||
export default defineConfig([{
|
||||
plugins: {
|
||||
"@typescript-eslint": typescriptEslint,
|
||||
"@stylistic": stylistic,
|
||||
node,
|
||||
},
|
||||
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
...globals.commonjs,
|
||||
},
|
||||
},
|
||||
|
||||
"rules": {
|
||||
"@stylistic/indent": ["error", "tab", {
|
||||
"ignoredNodes": [
|
||||
"JSXFragment", // TODO: Linter crashes if this is removed
|
||||
],
|
||||
"ignoreComments": true,
|
||||
"SwitchCase": 1, // Indent case statements one level from switch
|
||||
}],
|
||||
"accessor-pairs": "error",
|
||||
"array-bracket-newline": "off",
|
||||
"array-bracket-spacing": ["error", "never"],
|
||||
"array-callback-return": "error",
|
||||
"array-element-newline": "off",
|
||||
"arrow-body-style": "error",
|
||||
"arrow-parens": "off",
|
||||
|
||||
"arrow-spacing": ["error", {
|
||||
"after": true,
|
||||
"before": true,
|
||||
}],
|
||||
|
||||
"block-scoped-var": "error",
|
||||
"block-spacing": "error",
|
||||
|
||||
"brace-style": ["error", "1tbs", {
|
||||
"allowSingleLine": true,
|
||||
}],
|
||||
|
||||
"camelcase": "off",
|
||||
"capitalized-comments": "off",
|
||||
"class-methods-use-this": "off",
|
||||
|
||||
"comma-dangle": ["error", {
|
||||
"arrays": "always-multiline",
|
||||
"objects": "always-multiline",
|
||||
"imports": "always-multiline",
|
||||
"exports": "always-multiline",
|
||||
"functions": "only-multiline",
|
||||
}],
|
||||
|
||||
"comma-spacing": "error",
|
||||
"comma-style": ["error", "last"],
|
||||
"complexity": ["error", { "max": 25 }],
|
||||
"computed-property-spacing": ["error", "never"],
|
||||
"consistent-return": "error",
|
||||
"consistent-this": "error",
|
||||
"curly": "error",
|
||||
"default-case": "error",
|
||||
"default-case-last": "error",
|
||||
"default-param-last": "off",
|
||||
"dot-location": ["error", "property"],
|
||||
|
||||
"dot-notation": ["error", {
|
||||
"allowPattern": "^[a-z]+(_[a-z]+)*$",
|
||||
}],
|
||||
|
||||
"eol-last": "error",
|
||||
"eqeqeq": "error",
|
||||
"func-call-spacing": "error",
|
||||
"func-name-matching": "error",
|
||||
"func-names": "off",
|
||||
|
||||
"func-style": ["error", "declaration", {
|
||||
"allowArrowFunctions": true,
|
||||
}],
|
||||
|
||||
"function-call-argument-newline": "off",
|
||||
"function-paren-newline": "off",
|
||||
|
||||
"generator-star-spacing": ["error", {
|
||||
"before": false,
|
||||
"after": true,
|
||||
}],
|
||||
|
||||
"grouped-accessor-pairs": "error",
|
||||
"guard-for-in": "error",
|
||||
"id-blacklist": "error",
|
||||
"id-length": "off",
|
||||
"id-match": "error",
|
||||
"implicit-arrow-linebreak": "error",
|
||||
|
||||
"indent-legacy": "off",
|
||||
"init-declarations": "off",
|
||||
"jsx-quotes": "error",
|
||||
"key-spacing": "error",
|
||||
"keyword-spacing": "error",
|
||||
"line-comment-position": "off",
|
||||
"linebreak-style": ["error", "unix"],
|
||||
"lines-around-comment": "off",
|
||||
"lines-around-directive": "off",
|
||||
|
||||
"lines-between-class-members": ["error", "always", {
|
||||
"exceptAfterSingleLine": true,
|
||||
}],
|
||||
|
||||
"max-classes-per-file": "off",
|
||||
"max-depth": "error",
|
||||
|
||||
"max-len": ["error", {
|
||||
"code": 120,
|
||||
}],
|
||||
|
||||
"max-lines": "off",
|
||||
"max-lines-per-function": "off",
|
||||
"max-nested-callbacks": "error",
|
||||
"max-params": "off",
|
||||
"max-statements": "off",
|
||||
"max-statements-per-line": "off",
|
||||
"multiline-comment-style": ["error", "separate-lines"],
|
||||
"multiline-ternary": "off",
|
||||
|
||||
"new-cap": ["error", {
|
||||
"capIsNewExceptions": ["StringEnum", "StringKey"],
|
||||
"capIsNewExceptionPattern": "^Type\\.",
|
||||
"newIsCapExceptions": ["constructor"],
|
||||
}],
|
||||
|
||||
"new-parens": "error",
|
||||
"newline-after-var": "off",
|
||||
"newline-before-return": "off",
|
||||
"newline-per-chained-call": "off",
|
||||
"no-alert": "error",
|
||||
"no-array-constructor": "error",
|
||||
"no-await-in-loop": "off",
|
||||
"no-bitwise": "off",
|
||||
"no-buffer-constructor": "error",
|
||||
"no-caller": "error",
|
||||
"no-catch-shadow": "off",
|
||||
"no-confusing-arrow": "error",
|
||||
"no-console": "error",
|
||||
|
||||
"no-constant-condition": ["error", {
|
||||
"checkLoops": false,
|
||||
}],
|
||||
|
||||
"no-constructor-return": "error",
|
||||
"no-continue": "off",
|
||||
"no-div-regex": "error",
|
||||
"no-duplicate-imports": "error",
|
||||
"no-else-return": "error",
|
||||
"no-empty-function": "off",
|
||||
"no-eq-null": "error",
|
||||
"no-eval": "error",
|
||||
"no-extend-native": "error",
|
||||
"no-extra-bind": "error",
|
||||
"no-extra-label": "error",
|
||||
"no-extra-parens": "off",
|
||||
"no-floating-decimal": "error",
|
||||
"no-implicit-coercion": "error",
|
||||
"no-implicit-globals": "error",
|
||||
"no-implied-eval": "error",
|
||||
"no-inline-comments": "off",
|
||||
"no-invalid-this": "error",
|
||||
"no-iterator": "error",
|
||||
"no-label-var": "error",
|
||||
"no-lone-blocks": "error",
|
||||
"no-lonely-if": "error",
|
||||
"no-loop-func": "error",
|
||||
"no-magic-numbers": "off",
|
||||
|
||||
"no-mixed-operators": ["error", {
|
||||
"groups": [
|
||||
["&", "|", "^", "~", "<<", ">>", ">>>"],
|
||||
["==", "!=", "===", "!==", ">", ">=", "<", "<="],
|
||||
["in", "instanceof"],
|
||||
],
|
||||
}],
|
||||
|
||||
"no-multi-assign": "error",
|
||||
"no-multi-spaces": "error",
|
||||
"no-multi-str": "error",
|
||||
"no-multiple-empty-lines": "error",
|
||||
"no-native-reassign": "error",
|
||||
"no-negated-condition": "off",
|
||||
"no-negated-in-lhs": "error",
|
||||
"no-nested-ternary": "error",
|
||||
"no-new": "error",
|
||||
"no-new-func": "error",
|
||||
"no-new-object": "error",
|
||||
"no-new-wrappers": "error",
|
||||
"no-octal-escape": "error",
|
||||
"no-param-reassign": "off",
|
||||
|
||||
"no-plusplus": ["error", {
|
||||
"allowForLoopAfterthoughts": true,
|
||||
}],
|
||||
|
||||
"no-proto": "error",
|
||||
"no-restricted-exports": "error",
|
||||
"no-restricted-globals": "error",
|
||||
"no-restricted-imports": "error",
|
||||
"no-restricted-properties": "error",
|
||||
"no-restricted-syntax": "error",
|
||||
"no-return-assign": "error",
|
||||
"no-return-await": "off",
|
||||
"no-script-url": "error",
|
||||
"no-self-compare": "error",
|
||||
"no-sequences": "error",
|
||||
|
||||
"no-shadow": ["error", {
|
||||
"allow": ["Event", "Request", "yargs"],
|
||||
}],
|
||||
|
||||
"no-spaced-func": "error",
|
||||
|
||||
"no-tabs": "off",
|
||||
|
||||
"no-template-curly-in-string": "error",
|
||||
"no-ternary": "off",
|
||||
"no-throw-literal": "error",
|
||||
"no-trailing-spaces": "error",
|
||||
"no-undef-init": "error",
|
||||
"no-undefined": "off",
|
||||
"no-unmodified-loop-condition": "error",
|
||||
"no-unneeded-ternary": "error",
|
||||
"no-unused-expressions": "error",
|
||||
"no-use-before-define": "off",
|
||||
"no-useless-backreference": "error",
|
||||
"no-useless-call": "error",
|
||||
"no-useless-computed-key": "error",
|
||||
"no-useless-concat": "error",
|
||||
"no-useless-constructor": "off",
|
||||
"no-useless-rename": "error",
|
||||
"no-useless-return": "error",
|
||||
"no-var": "error",
|
||||
|
||||
"no-void": ["error", {
|
||||
"allowAsStatement": true,
|
||||
}],
|
||||
|
||||
"no-warning-comments": "off",
|
||||
"no-whitespace-before-property": "error",
|
||||
"nonblock-statement-body-position": "error",
|
||||
"object-curly-newline": "error",
|
||||
"object-curly-spacing": "off",
|
||||
"object-shorthand": "off",
|
||||
"one-var": "off",
|
||||
"one-var-declaration-per-line": "error",
|
||||
"operator-assignment": "off",
|
||||
|
||||
"operator-linebreak": ["error", "after", {
|
||||
"overrides": {
|
||||
"?": "before",
|
||||
":": "before",
|
||||
"||": "before",
|
||||
"&&": "before",
|
||||
"??": "before",
|
||||
},
|
||||
}],
|
||||
|
||||
"padded-blocks": "off",
|
||||
"padding-line-between-statements": "error",
|
||||
"prefer-arrow-callback": "error",
|
||||
"prefer-const": "off",
|
||||
"prefer-destructuring": "off",
|
||||
"prefer-exponentiation-operator": "error",
|
||||
"prefer-named-capture-group": "off",
|
||||
"prefer-numeric-literals": "error",
|
||||
"prefer-object-spread": "error",
|
||||
"prefer-promise-reject-errors": "error",
|
||||
"prefer-reflect": "off",
|
||||
"prefer-regex-literals": "error",
|
||||
"prefer-rest-params": "error",
|
||||
"prefer-spread": "error",
|
||||
"prefer-template": "error",
|
||||
"quote-props": "off",
|
||||
|
||||
"quotes": ["error", "double", {
|
||||
"avoidEscape": true,
|
||||
}],
|
||||
|
||||
"radix": ["error", "always"],
|
||||
"require-atomic-updates": "off",
|
||||
"require-await": "off",
|
||||
"require-jsdoc": "off",
|
||||
"require-unicode-regexp": "off",
|
||||
"rest-spread-spacing": ["error", "never"],
|
||||
"semi": "error",
|
||||
"semi-spacing": "error",
|
||||
"semi-style": "off",
|
||||
"sort-imports": "off",
|
||||
"sort-keys": "off",
|
||||
"sort-vars": "off",
|
||||
"space-before-blocks": "error",
|
||||
"space-before-function-paren": "off",
|
||||
"space-in-parens": ["error", "never"],
|
||||
"space-infix-ops": "off",
|
||||
"space-unary-ops": "error",
|
||||
|
||||
"spaced-comment": ["error", "always", {
|
||||
"line": {
|
||||
"markers": ["/"],
|
||||
},
|
||||
}],
|
||||
|
||||
"switch-colon-spacing": "error",
|
||||
"symbol-description": "error",
|
||||
"template-curly-spacing": ["error", "never"],
|
||||
"template-tag-spacing": "error",
|
||||
"unicode-bom": ["error", "never"],
|
||||
|
||||
"vars-on-top": "error",
|
||||
"wrap-iife": "error",
|
||||
"wrap-regex": "off",
|
||||
"yield-star-spacing": "error",
|
||||
|
||||
"yoda": ["error", "never", {
|
||||
"exceptRange": true,
|
||||
}],
|
||||
|
||||
"node/callback-return": "off",
|
||||
"node/exports-style": "off",
|
||||
"node/file-extension-in-import": "off",
|
||||
"node/global-require": "off",
|
||||
"node/handle-callback-err": "off",
|
||||
"node/no-callback-literal": "off",
|
||||
"node/no-deprecated-api": "off",
|
||||
"node/no-exports-assign": "off",
|
||||
"node/no-extraneous-import": "off",
|
||||
"node/no-extraneous-require": "off",
|
||||
|
||||
"node/no-missing-import": "off",
|
||||
|
||||
"node/no-missing-require": "off",
|
||||
"node/no-mixed-requires": "off",
|
||||
"node/no-new-require": "off",
|
||||
"node/no-path-concat": "off",
|
||||
"node/no-process-env": "off",
|
||||
"node/no-process-exit": "off",
|
||||
"node/no-restricted-require": "off",
|
||||
"node/no-sync": "error",
|
||||
"node/no-unpublished-bin": "off",
|
||||
"node/no-unpublished-import": "off",
|
||||
|
||||
"node/no-unpublished-require": "off",
|
||||
|
||||
"node/no-unsupported-features/es-builtins": "off",
|
||||
"node/no-unsupported-features/es-syntax": "off",
|
||||
"node/no-unsupported-features/node-builtins": "off",
|
||||
"node/prefer-global/buffer": "off",
|
||||
"node/prefer-global/console": "off",
|
||||
"node/prefer-global/process": "off",
|
||||
"node/prefer-global/text-decoder": "off",
|
||||
"node/prefer-global/text-encoder": "off",
|
||||
"node/prefer-global/url-search-params": "off",
|
||||
"node/prefer-global/url": "off",
|
||||
"node/prefer-promises/dns": "off",
|
||||
"node/prefer-promises/fs": "off",
|
||||
"node/process-exit-as-throw": "off",
|
||||
|
||||
"node/shebang": "off",
|
||||
},
|
||||
}, {
|
||||
files: ["{packages,plugins}/**/*.ts?(x)"],
|
||||
|
||||
languageOptions: {
|
||||
parser: tsParser,
|
||||
},
|
||||
|
||||
extends: compat.extends("plugin:@typescript-eslint/recommended"),
|
||||
|
||||
"rules": {
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-useless-constructor": "error",
|
||||
"@typescript-eslint/no-unused-vars": "off",
|
||||
"@typescript-eslint/triple-slash-reference": "off",
|
||||
|
||||
"@typescript-eslint/no-shadow": ["error", {
|
||||
"allow": ["Event", "Request", "yargs"],
|
||||
}],
|
||||
|
||||
// Allow require imports where necessary (dynamic loading, webpack configs, etc.)
|
||||
"@typescript-eslint/no-require-imports": "off",
|
||||
|
||||
"prefer-const": "off",
|
||||
"no-shadow": "off",
|
||||
},
|
||||
}, {
|
||||
files: ["test/**/*.js", "plugins/*/test/**/*.js"],
|
||||
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.mocha,
|
||||
},
|
||||
|
||||
sourceType: "module",
|
||||
parserOptions: {},
|
||||
},
|
||||
|
||||
"rules": {
|
||||
"prefer-arrow-callback": "off",
|
||||
"no-invalid-this": "off",
|
||||
"node/no-unpublished-import": "off",
|
||||
"node/no-unpublished-require": "off",
|
||||
},
|
||||
}, {
|
||||
files: [
|
||||
"packages/web_ui/src/**/*.ts?(x)",
|
||||
"packages/lib/browser.ts",
|
||||
"packages/create/templates/*/web/**/*.{t,j}s?(x)",
|
||||
"{packages,plugins}/*/web/**/*.ts?(x)",
|
||||
],
|
||||
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
},
|
||||
|
||||
"sourceType": "module",
|
||||
|
||||
parserOptions: {
|
||||
"ecmaFeatures": {
|
||||
"jsx": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
"rules": {
|
||||
"node/callback-return": "off",
|
||||
"node/exports-style": "off",
|
||||
"node/file-extension-in-import": "off",
|
||||
"node/global-require": "off",
|
||||
"node/handle-callback-err": "off",
|
||||
"node/no-callback-literal": "off",
|
||||
"node/no-deprecated-api": "off",
|
||||
"node/no-exports-assign": "off",
|
||||
"node/no-extraneous-import": "off",
|
||||
"node/no-extraneous-require": "off",
|
||||
"node/no-missing-import": "off",
|
||||
"node/no-missing-require": "off",
|
||||
"node/no-mixed-requires": "off",
|
||||
"node/no-new-require": "off",
|
||||
"node/no-path-concat": "off",
|
||||
"node/no-process-env": "off",
|
||||
"node/no-process-exit": "off",
|
||||
"node/no-restricted-require": "off",
|
||||
"node/no-sync": "error",
|
||||
"node/no-unpublished-bin": "off",
|
||||
"node/no-unpublished-import": "off",
|
||||
"node/no-unpublished-require": "off",
|
||||
"node/no-unsupported-features/es-builtins": "off",
|
||||
"node/no-unsupported-features/es-syntax": "off",
|
||||
"node/no-unsupported-features/node-builtins": "off",
|
||||
"node/prefer-global/buffer": "off",
|
||||
"node/prefer-global/console": "off",
|
||||
"node/prefer-global/process": "off",
|
||||
"node/prefer-global/text-decoder": "off",
|
||||
"node/prefer-global/text-encoder": "off",
|
||||
"node/prefer-global/url-search-params": "off",
|
||||
"node/prefer-global/url": "off",
|
||||
"node/prefer-promises/dns": "off",
|
||||
"node/prefer-promises/fs": "off",
|
||||
"node/process-exit-as-throw": "off",
|
||||
"node/shebang": "off",
|
||||
},
|
||||
}, {
|
||||
files: ["packages/create/templates/**/*.?(c){t,j}s?(x)"],
|
||||
|
||||
"rules": {
|
||||
"node/no-missing-require": "off",
|
||||
"node/no-missing-import": "off",
|
||||
"@stylistic/indent": "off",
|
||||
"spaced-comment": "off",
|
||||
"comma-spacing": "off",
|
||||
"max-len": "off",
|
||||
"indent": "off",
|
||||
},
|
||||
}, globalIgnores(["**/dist/", "dist/"])]);
|
||||
+4
-14
@@ -15,10 +15,9 @@
|
||||
"cover": "c8 pnpm test",
|
||||
"fast-cover": "FAST_TEST=y c8 pnpm test",
|
||||
"ci-cover": "c8 --reporter=lcovonly pnpm test",
|
||||
"lint": "eslint packages plugins test",
|
||||
"lint-fix": "eslint --fix packages plugins test",
|
||||
"lint": "oxlint packages plugins test",
|
||||
"lint-fix": "oxlint --fix packages plugins test",
|
||||
"clean": "node ./clean.js",
|
||||
"docs": "typedoc",
|
||||
"bundle-dependencies": "bundle-dependencies"
|
||||
},
|
||||
"c8": {
|
||||
@@ -29,7 +28,7 @@
|
||||
]
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
"node": "^22.18.0 || >=24.2.0"
|
||||
},
|
||||
"workspaces": [
|
||||
"packages/*",
|
||||
@@ -43,22 +42,13 @@
|
||||
"@clusterio/host": "workspace:^",
|
||||
"@clusterio/lib": "workspace:^",
|
||||
"@clusterio/web_ui": "workspace:^",
|
||||
"@eslint/eslintrc": "^3.3.7",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@stylistic/eslint-plugin": "^5.10.0",
|
||||
"@swc/core": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"c8": "^12.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.71.2",
|
||||
"@typescript-eslint/parser": "^8.71.2",
|
||||
"eslint": "^10.13.0",
|
||||
"eslint-plugin-node": "^11.1.0",
|
||||
"express": "catalog:",
|
||||
"form-data": "^4.0.6",
|
||||
"globals": "^17.13.0",
|
||||
"jsonwebtoken": "catalog:",
|
||||
"mocha": "^11.8.0",
|
||||
"typedoc": "^0.28.20",
|
||||
"oxlint": "^1.88.0",
|
||||
"typescript": "catalog:",
|
||||
"yargs": "catalog:",
|
||||
"yauzl": "catalog:",
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
webpack.config.js
|
||||
vite.config.js
|
||||
dist/browser
|
||||
@@ -26,10 +26,12 @@ import DailyRotateFile from "winston-daily-rotate-file";
|
||||
import jwt from "jsonwebtoken";
|
||||
|
||||
// homebrew modules
|
||||
// Reduce startup time by lazy compiling schemas.
|
||||
import "./src/set_lazy_schema_compilation.js";
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { ConsoleTransport, levels, logger } from "@clusterio/lib";
|
||||
|
||||
import Controller from "./src/Controller.js";
|
||||
import type Controller from "./src/Controller.js";
|
||||
import UserManager from "./src/UserManager.js";
|
||||
import UserRecord from "./src/UserRecord.js";
|
||||
import packageConfig from "@clusterio/controller/package.json" with { type: "json" };
|
||||
@@ -147,15 +149,15 @@ async function handleBootstrapCommand(
|
||||
path.join(databaseDirectory, "roles.json"),
|
||||
);
|
||||
|
||||
const roles = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
|
||||
const roles = await lib.SubscribableDatastore.fromProvider(new lib.JsonIdDatastoreProvider(
|
||||
path.join(databaseDirectory, "roles.json"),
|
||||
lib.Role.fromJSON.bind(lib.Role),
|
||||
).bootstrap());
|
||||
));
|
||||
|
||||
const users = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
|
||||
const users = await lib.SubscribableDatastore.fromProvider(new lib.JsonIdDatastoreProvider(
|
||||
path.join(databaseDirectory, "users.json"),
|
||||
UserRecord.fromJSON.bind(UserRecord),
|
||||
).bootstrap());
|
||||
));
|
||||
|
||||
const userManager = new UserManager(users, roles, controllerConfig);
|
||||
|
||||
@@ -203,6 +205,7 @@ async function handleBootstrapCommand(
|
||||
}
|
||||
let ctlConfig = new lib.CtlConfig("control");
|
||||
|
||||
const { default: Controller } = await import("./src/Controller.js");
|
||||
ctlConfig.set("ctl.controller_url", Controller.calculateControllerUrl(controllerConfig));
|
||||
ctlConfig.set(
|
||||
"ctl.controller_token",
|
||||
@@ -400,7 +403,8 @@ async function initialize(): Promise<InitializeParameters> {
|
||||
}
|
||||
|
||||
logger.info(`Loading available plugins from ${args.pluginList}`);
|
||||
let pluginList = await lib.loadPluginList(args.pluginList);
|
||||
const skippedNpmPlugins = new Map<string, string>();
|
||||
let pluginList = await lib.loadPluginList(args.pluginList, true, skippedNpmPlugins);
|
||||
|
||||
// If the command is plugin management we don't try to load plugins
|
||||
if (command === "plugin") {
|
||||
@@ -415,7 +419,7 @@ async function initialize(): Promise<InitializeParameters> {
|
||||
}
|
||||
|
||||
logger.info("Loading Plugin info");
|
||||
const pluginInfos = await lib.loadPluginInfos(pluginList);
|
||||
const pluginInfos = await lib.loadPluginInfos(pluginList, skippedNpmPlugins);
|
||||
lib.registerPluginMessages(pluginInfos);
|
||||
lib.registerPluginPermissions(pluginInfos);
|
||||
lib.addPluginConfigFields(pluginInfos);
|
||||
@@ -474,13 +478,14 @@ async function startup() {
|
||||
return;
|
||||
}
|
||||
|
||||
controller = new Controller(
|
||||
clusterLogger,
|
||||
pluginInfos,
|
||||
// Loaded here as the server is not needed by the other commands
|
||||
const { default: Controller } = await import("./src/Controller.js");
|
||||
controller = await Controller.fromConfig(
|
||||
controllerConfig,
|
||||
pluginInfos,
|
||||
clusterLogger,
|
||||
Boolean(args.canRestart),
|
||||
Boolean(args.recovery),
|
||||
...await Controller.bootstrap(controllerConfig)
|
||||
);
|
||||
|
||||
// Refuse to start if there are no users loaded
|
||||
|
||||
@@ -25,14 +25,14 @@
|
||||
"type": "module",
|
||||
"main": "dist/node/index.js",
|
||||
"scripts": {
|
||||
"prepare": "tsc --build && webpack-cli --mode production",
|
||||
"prepare": "tsc --build && vite build",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"bin": {
|
||||
"clusteriocontroller": "./dist/node/controller.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
"node": "^22.18.0 || >=24.2.0"
|
||||
},
|
||||
|
||||
"dependencies": {
|
||||
@@ -51,7 +51,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@clusterio/web_ui": "workspace:^",
|
||||
"@swc/core": "catalog:",
|
||||
"@types/busboy": "^1.5.5",
|
||||
"@types/compression": "^1.8.1",
|
||||
"@types/express": "catalog:",
|
||||
@@ -61,13 +60,12 @@
|
||||
"@types/semver": "catalog:",
|
||||
"@types/ws": "catalog:",
|
||||
"@types/yargs": "catalog:",
|
||||
"antd": "catalog:",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"react-router": "catalog:",
|
||||
"react-router-dom": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"webpack": "catalog:",
|
||||
"webpack-cli": "catalog:",
|
||||
"webpack-dev-middleware": "^8.3.0",
|
||||
"webpack-merge": "catalog:"
|
||||
"vite": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -63,7 +63,7 @@ export default class BaseConnection extends lib.Link {
|
||||
let { id } = request;
|
||||
let modPack = this._controller.modPacks.get(id);
|
||||
if (!modPack) {
|
||||
throw new lib.RequestError(`Mod pack with ID ${id} does not exist`);
|
||||
throw new lib.NotFoundError(`Mod pack with ID ${id} does not exist`);
|
||||
}
|
||||
return modPack;
|
||||
}
|
||||
@@ -71,11 +71,11 @@ export default class BaseConnection extends lib.Link {
|
||||
async handleModPackGetDefaultRequest(): Promise<lib.ModPack> {
|
||||
let id = this._controller.config.get("controller.default_mod_pack_id");
|
||||
if (id === null) {
|
||||
throw new lib.RequestError("Default mod pack not set on controller");
|
||||
throw new lib.ConflictError("Default mod pack not set on controller");
|
||||
}
|
||||
let modPack = this._controller.modPacks.get(id);
|
||||
if (!modPack) {
|
||||
throw new lib.RequestError(`Default mod pack configured (${id}) does not exist`);
|
||||
throw new lib.NotFoundError(`Default mod pack configured (${id}) does not exist`);
|
||||
}
|
||||
return modPack;
|
||||
}
|
||||
@@ -84,10 +84,10 @@ export default class BaseConnection extends lib.Link {
|
||||
let filename = lib.ModInfo.filename(mod.name, mod.version);
|
||||
let modInfo = this._controller.modStore.files.get(filename);
|
||||
if (!modInfo) {
|
||||
throw new lib.RequestError(`Mod ${filename} does not exist on controller`);
|
||||
throw new lib.NotFoundError(`Mod ${filename} does not exist on controller`);
|
||||
}
|
||||
if (mod.sha1 && mod.sha1 !== modInfo.sha1) {
|
||||
throw new lib.RequestError(`Mod ${filename} checksum does not match controller's checksum`);
|
||||
throw new lib.ConflictError(`Mod ${filename} checksum does not match controller's checksum`);
|
||||
}
|
||||
return modInfo;
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@ export class ControllerHooks extends lib.AsyncHookCollection {
|
||||
* @param curr - The current value of the field.
|
||||
* @param prev - The previous value of the field.
|
||||
*/
|
||||
// eslint-disable-next-line max-len
|
||||
// eslint-disable-next-line @stylistic/max-len
|
||||
readonly instanceConfigFieldChanged: lib.AsyncHook<[instance: InstanceRecord, field: string, curr: unknown, prev: unknown]>;
|
||||
|
||||
/**
|
||||
@@ -165,7 +165,7 @@ export class ControllerHooks extends lib.AsyncHookCollection {
|
||||
* The connection the event occured on.
|
||||
* @param event - one of connect, drop, resume and close
|
||||
*/
|
||||
// eslint-disable-next-line max-len
|
||||
// eslint-disable-next-line @stylistic/max-len
|
||||
readonly hostConnectionEvent: lib.AsyncHook<[connection: HostConnection, event: "connect" | "drop" | "resume" | "close"]>;
|
||||
|
||||
/**
|
||||
@@ -202,7 +202,7 @@ export class ControllerHooks extends lib.AsyncHookCollection {
|
||||
* The connection the event occured on.
|
||||
* @param event - one of connect, drop, resume, and close.
|
||||
*/
|
||||
// eslint-disable-next-line max-len
|
||||
// eslint-disable-next-line @stylistic/max-len
|
||||
readonly controlConnectionEvent: lib.AsyncHook<[connection: ControlConnection, event: "connect" | "drop" | "resume" | "close"]>;
|
||||
|
||||
/**
|
||||
|
||||
@@ -135,6 +135,8 @@ export default class ControlConnection extends BaseConnection {
|
||||
this.handle(lib.PluginListRequest, this.handlePluginListRequest.bind(this));
|
||||
this.handle(lib.PluginUpdateRequest, this.handlePluginUpdateRequest.bind(this));
|
||||
this.handle(lib.PluginInstallRequest, this.handlePluginInstallRequest.bind(this));
|
||||
this.handle(lib.PluginSearchRequest, this.handlePluginSearchRequest.bind(this));
|
||||
this.handle(lib.PluginLatestVersionsRequest, this.handlePluginLatestVersionsRequest.bind(this));
|
||||
this.handle(lib.UpdateAllRequest, this.handleUpdateAllRequest.bind(this));
|
||||
this.handle(lib.DebugDumpWsRequest, this.handleDebugDumpWsRequest.bind(this));
|
||||
}
|
||||
@@ -153,7 +155,7 @@ export default class ControlConnection extends BaseConnection {
|
||||
try {
|
||||
this.checkPermission(message, entry);
|
||||
} catch (err: any) {
|
||||
this.connector.sendResponseError(new lib.ResponseError(err.message, err.code), message.src);
|
||||
this.connector.sendResponseError(lib.Link.errorToResponse(err, false), message.src);
|
||||
logger.audit(`Permission denied for ${message.name} by ${this.user.id} from ${this.connector.dst}`);
|
||||
throw err;
|
||||
}
|
||||
@@ -195,12 +197,15 @@ export default class ControlConnection extends BaseConnection {
|
||||
|
||||
async handleControllerRestartRequest() {
|
||||
if (!this._controller.canRestart) {
|
||||
throw new lib.RequestError("Cannot restart, controller does not have a process monitor to restart it.");
|
||||
throw new lib.ConflictError("Cannot restart, controller does not have a process monitor to restart it.");
|
||||
}
|
||||
if (await lib.packageOperationRunning()) {
|
||||
throw new lib.ConflictError("Cannot restart while a plugin install or update is running on this machine.");
|
||||
}
|
||||
const downgrade = await this._controller.checkRestartDowngrade();
|
||||
if (downgrade) {
|
||||
const { installedVersion, runningVersion } = downgrade;
|
||||
throw new lib.RequestError(
|
||||
throw new lib.ConflictError(
|
||||
`Cannot restart controller with installed Clusterio version ${installedVersion} because it ` +
|
||||
`is older than running version ${runningVersion}. ` +
|
||||
"Stop the controller before starting the older version manually."
|
||||
@@ -298,13 +303,13 @@ export default class ControlConnection extends BaseConnection {
|
||||
|
||||
async handleInstanceCreateRequest(request: lib.InstanceCreateRequest) {
|
||||
if ((request.config["instance.assigned_host"] ?? null) !== null) {
|
||||
throw new lib.RequestError("instance.assigned_host must be set through the assign-host interface");
|
||||
throw new lib.ValidationError("instance.assigned_host must be set through the assign-host interface");
|
||||
}
|
||||
const instanceConfig = new lib.InstanceConfig("controller");
|
||||
if (request.cloneFromId) {
|
||||
const baseInstance = this._controller.instances.get(request.cloneFromId);
|
||||
if (!baseInstance) {
|
||||
throw new lib.RequestError(`Instance with ID ${request.cloneFromId} does not exist`);
|
||||
throw new lib.NotFoundError(`Instance with ID ${request.cloneFromId} does not exist`);
|
||||
}
|
||||
instanceConfig.update(baseInstance.config.toJSON(), false);
|
||||
instanceConfig.set("instance.assigned_host", null); // New instances are unassigned
|
||||
@@ -325,12 +330,12 @@ export default class ControlConnection extends BaseConnection {
|
||||
|
||||
private static validateInstanceConfigSetField(fieldName: string) {
|
||||
if (fieldName === "instance.assigned_host") {
|
||||
throw new lib.RequestError("instance.assigned_host must be set through the assign-host interface");
|
||||
throw new lib.ValidationError("instance.assigned_host must be set through the assign-host interface");
|
||||
}
|
||||
|
||||
if (fieldName === "instance.id") {
|
||||
// XXX is this worth implementing? It's race condition galore.
|
||||
throw new lib.RequestError("Setting instance.id is not supported");
|
||||
throw new lib.ValidationError("Setting instance.id is not supported");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -388,7 +393,7 @@ export default class ControlConnection extends BaseConnection {
|
||||
let ready = new Promise<void>((resolve, reject) => {
|
||||
stream.events.on("source", resolve);
|
||||
stream.events.on("timeout", () => reject(
|
||||
new lib.RequestError("Timed out establishing stream from host")
|
||||
new lib.TimeoutError("Timed out establishing stream from host")
|
||||
));
|
||||
});
|
||||
ready.catch(() => {});
|
||||
@@ -405,17 +410,17 @@ export default class ControlConnection extends BaseConnection {
|
||||
|
||||
async handleInstanceTransferSaveRequest(request: lib.InstanceTransferSaveRequest) {
|
||||
if (request.sourceInstanceId === request.targetInstanceId) {
|
||||
throw new lib.RequestError("Source and target instance may not be the same");
|
||||
throw new lib.ValidationError("Source and target instance may not be the same");
|
||||
}
|
||||
let sourceInstance = this._controller.instances.getForRequest(request.sourceInstanceId);
|
||||
let targetInstance = this._controller.instances.getForRequest(request.targetInstanceId);
|
||||
let sourceHostId = sourceInstance.config.get("instance.assigned_host");
|
||||
let targetHostId = targetInstance.config.get("instance.assigned_host");
|
||||
if (sourceHostId === null) {
|
||||
throw new lib.RequestError("Source instance is not assigned a host");
|
||||
throw new lib.ConflictError("Source instance is not assigned a host");
|
||||
}
|
||||
if (targetHostId === null) {
|
||||
throw new lib.RequestError("Target instance is not assigned a host");
|
||||
throw new lib.ConflictError("Target instance is not assigned a host");
|
||||
}
|
||||
|
||||
// Let host handle request if source and target is on the same host.
|
||||
@@ -426,12 +431,12 @@ export default class ControlConnection extends BaseConnection {
|
||||
// Check connectivity
|
||||
let sourceHostConnection = this._controller.wsServer.hostConnections.get(sourceHostId);
|
||||
if (!sourceHostConnection || sourceHostConnection.connector.closing) {
|
||||
throw new lib.RequestError("Source host is not connected to the controller");
|
||||
throw new lib.UnavailableError("Source host is not connected to the controller");
|
||||
}
|
||||
|
||||
let targetHostConnection = this._controller.wsServer.hostConnections.get(targetHostId);
|
||||
if (!targetHostConnection || targetHostConnection.connector.closing) {
|
||||
throw new lib.RequestError("Target host is not connected to the controller");
|
||||
throw new lib.UnavailableError("Target host is not connected to the controller");
|
||||
}
|
||||
|
||||
// Create stream to proxy from target to source
|
||||
@@ -440,7 +445,7 @@ export default class ControlConnection extends BaseConnection {
|
||||
if (stream.source) {
|
||||
stream.source.destroy();
|
||||
}
|
||||
stream.events.emit("error", new lib.RequestError("Timed out establishing transfer stream"));
|
||||
stream.events.emit("error", new lib.TimeoutError("Timed out establishing transfer stream"));
|
||||
});
|
||||
|
||||
// Ignore errors if not listening for them to avoid crash.
|
||||
@@ -480,10 +485,10 @@ export default class ControlConnection extends BaseConnection {
|
||||
async handleModPackCreateRequest(request: lib.ModPackCreateRequest) {
|
||||
let modPack = request.modPack;
|
||||
if (modPack.id === undefined) {
|
||||
throw new lib.RequestError("Mod pack need an ID to be created");
|
||||
throw new lib.ValidationError("Mod pack need an ID to be created");
|
||||
}
|
||||
if (this._controller.modPacks.has(modPack.id)) {
|
||||
throw new lib.RequestError(`Mod pack with ID ${modPack.id} already exist`);
|
||||
throw new lib.ConflictError(`Mod pack with ID ${modPack.id} already exist`);
|
||||
}
|
||||
this._controller.modPacks.set(modPack);
|
||||
}
|
||||
@@ -491,7 +496,7 @@ export default class ControlConnection extends BaseConnection {
|
||||
async handleModPackUpdateRequest(request: lib.ModPackUpdateRequest) {
|
||||
let modPack = request.modPack;
|
||||
if (modPack.id === undefined || !this._controller.modPacks.has(modPack.id)) {
|
||||
throw new lib.RequestError(`Mod pack with ID ${modPack.id} does not exist`);
|
||||
throw new lib.NotFoundError(`Mod pack with ID ${modPack.id} does not exist`);
|
||||
}
|
||||
this._controller.modPacks.set(modPack);
|
||||
}
|
||||
@@ -500,7 +505,7 @@ export default class ControlConnection extends BaseConnection {
|
||||
let { id } = request;
|
||||
let modPack = this._controller.modPacks.getMutable(id);
|
||||
if (!modPack) {
|
||||
throw new lib.RequestError(`Mod pack with ID ${id} does not exist`);
|
||||
throw new lib.NotFoundError(`Mod pack with ID ${id} does not exist`);
|
||||
}
|
||||
this._controller.modPacks.delete(modPack);
|
||||
this._controller.deleteNote("mod_pack", id);
|
||||
@@ -582,7 +587,7 @@ export default class ControlConnection extends BaseConnection {
|
||||
author: (a:ModVersions, b:ModVersions) => strcmp(a.versions[0].author, b.versions[0].author),
|
||||
};
|
||||
if (!Object.prototype.hasOwnProperty.call(sorters, sort)) {
|
||||
throw new lib.RequestError(`Invalid value for sort: ${sort}`);
|
||||
throw new lib.ValidationError(`Invalid value for sort: ${sort}`);
|
||||
}
|
||||
resultList.sort(sorters[sort as keyof typeof sorters]);
|
||||
let order = request.sortOrder;
|
||||
@@ -629,7 +634,7 @@ export default class ControlConnection extends BaseConnection {
|
||||
} catch (error: any) {
|
||||
logger.error(`Error fetching all mods from portal (${request.factorioVersion}): ${error}`);
|
||||
// Propagate a user-friendly error back to the client
|
||||
throw new lib.RequestError(`Portal mod fetch failed: ${error}`);
|
||||
throw new lib.InternalServerError(`Portal mod fetch failed: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -647,7 +652,7 @@ export default class ControlConnection extends BaseConnection {
|
||||
const token = this._controller.config.get("controller.factorio_token");
|
||||
|
||||
if (!username || username === "" || !token || token === "") {
|
||||
throw new lib.RequestError("Factorio credentials (username, token) not configured on the controller.");
|
||||
throw new lib.ConflictError("Factorio credentials (username, token) not configured on the controller.");
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -656,18 +661,16 @@ export default class ControlConnection extends BaseConnection {
|
||||
|
||||
} catch (error: any) {
|
||||
logger.error(`Error downloading mods from portal: ${error.message}`);
|
||||
// Improve error message clarity
|
||||
let errorMessage = "Mod portal download failed";
|
||||
if (error instanceof lib.RequestError) {
|
||||
errorMessage += `: ${error.message}`;
|
||||
} else if (error.message.includes("401") || error.message.includes("Unauthorized")) {
|
||||
errorMessage += ": Authentication failed. Check Factorio username/token in controller config.";
|
||||
} else if (error.message.includes("404") || error.message.includes("Not Found")) {
|
||||
errorMessage += ": Mod version not found on the portal.";
|
||||
} else {
|
||||
errorMessage += `: ${error.message}`;
|
||||
const errorMessage = "Mod portal download failed";
|
||||
if (error.message.includes("401") || error.message.includes("Unauthorized")) {
|
||||
throw new lib.ConflictError(
|
||||
`${errorMessage}: Authentication failed. Check Factorio username/token in controller config.`
|
||||
);
|
||||
}
|
||||
throw new lib.RequestError(errorMessage);
|
||||
if (error.message.includes("404") || error.message.includes("Not Found")) {
|
||||
throw new lib.NotFoundError(`${errorMessage}: Mod version not found on the portal.`);
|
||||
}
|
||||
throw new lib.InternalServerError(`${errorMessage}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -677,7 +680,7 @@ export default class ControlConnection extends BaseConnection {
|
||||
for (const mod of request.mods) {
|
||||
const modInfo = installedMods.find(m => m.name === mod.name && mod.version.testVersion(m.version));
|
||||
if (!modInfo) {
|
||||
throw new lib.RequestError(`Failed to find mod ${mod.name} in store after download attempt.`);
|
||||
throw new lib.InternalServerError(`Failed to find mod ${mod.name} in store after download attempt.`);
|
||||
}
|
||||
modInfos.push(modInfo);
|
||||
}
|
||||
@@ -711,7 +714,7 @@ export default class ControlConnection extends BaseConnection {
|
||||
} catch (error: any) {
|
||||
if (!error.message.includes("404") && !error.message.includes("Not Found")) {
|
||||
logger.error(`Error fetching mod from portal: ${error.message}`);
|
||||
throw new lib.RequestError(`Dependency resolution failed: ${error.message}`);
|
||||
throw new lib.InternalServerError(`Dependency resolution failed: ${error.message}`);
|
||||
} else {
|
||||
releases = null;
|
||||
}
|
||||
@@ -951,7 +954,7 @@ export default class ControlConnection extends BaseConnection {
|
||||
let { id, name, description, permissions } = request;
|
||||
let role = this._controller.roles.getMutable(id);
|
||||
if (!role) {
|
||||
throw new lib.RequestError(`Role with ID ${id} does not exist`);
|
||||
throw new lib.NotFoundError(`Role with ID ${id} does not exist`);
|
||||
}
|
||||
|
||||
role.name = name;
|
||||
@@ -963,7 +966,7 @@ export default class ControlConnection extends BaseConnection {
|
||||
async handleRoleGrantDefaultPermissionsRequest(request: lib.RoleGrantDefaultPermissionsRequest) {
|
||||
let role = this._controller.roles.get(request.id);
|
||||
if (!role) {
|
||||
throw new lib.RequestError(`Role with ID ${request.id} does not exist`);
|
||||
throw new lib.NotFoundError(`Role with ID ${request.id} does not exist`);
|
||||
}
|
||||
|
||||
role.grantDefaultPermissions();
|
||||
@@ -975,7 +978,7 @@ export default class ControlConnection extends BaseConnection {
|
||||
|
||||
const role = this._controller.roles.get(id);
|
||||
if (!role) {
|
||||
throw new lib.RequestError(`Role with ID ${id} does not exist`);
|
||||
throw new lib.NotFoundError(`Role with ID ${id} does not exist`);
|
||||
}
|
||||
this._controller.roles.delete(role);
|
||||
this._controller.deleteNote("role", id);
|
||||
@@ -994,7 +997,7 @@ export default class ControlConnection extends BaseConnection {
|
||||
async handleNoteSetRequest(request: lib.NoteSetRequest) {
|
||||
const { resourceType, resourceId, content } = request;
|
||||
if (!resourceType || !resourceId) {
|
||||
throw new lib.RequestError("resourceType and resourceId must not be empty");
|
||||
throw new lib.ValidationError("resourceType and resourceId must not be empty");
|
||||
}
|
||||
this._controller.setNote(resourceType, resourceId, content, this.user.name);
|
||||
}
|
||||
@@ -1003,7 +1006,7 @@ export default class ControlConnection extends BaseConnection {
|
||||
let name = request.name;
|
||||
let user = this._controller.users.getByName(name);
|
||||
if (!user) {
|
||||
throw new lib.RequestError(`User ${name} does not exist`);
|
||||
throw new lib.NotFoundError(`User ${name} does not exist`);
|
||||
}
|
||||
|
||||
return user.toUserDetails();
|
||||
@@ -1020,7 +1023,7 @@ export default class ControlConnection extends BaseConnection {
|
||||
async handleUserRevokeTokenRequest(request: lib.UserRevokeTokenRequest) {
|
||||
let user = this._controller.users.getByName(request.name);
|
||||
if (!user) {
|
||||
throw new lib.RequestError(`User '${request.name}' does not exist`);
|
||||
throw new lib.NotFoundError(`User '${request.name}' does not exist`);
|
||||
}
|
||||
if (user.id !== this.user.id) {
|
||||
this.user.checkPermission("core.user.revoke_other_token");
|
||||
@@ -1037,12 +1040,12 @@ export default class ControlConnection extends BaseConnection {
|
||||
async handleUserUpdateRolesRequest(request: lib.UserUpdateRolesRequest) {
|
||||
let user = this._controller.users.getByName(request.name);
|
||||
if (!user) {
|
||||
throw new lib.RequestError(`User '${request.name}' does not exist`);
|
||||
throw new lib.NotFoundError(`User '${request.name}' does not exist`);
|
||||
}
|
||||
|
||||
for (let roleId of request.roles) {
|
||||
if (!this._controller.roles.has(roleId)) {
|
||||
throw new lib.RequestError(`Role with ID ${roleId} does not exist`);
|
||||
throw new lib.NotFoundError(`Role with ID ${roleId} does not exist`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1058,7 +1061,7 @@ export default class ControlConnection extends BaseConnection {
|
||||
this.user.checkPermission("core.user.create");
|
||||
user = this._controller.users.createUser(name);
|
||||
} else {
|
||||
throw new lib.RequestError(`User '${name}' does not exist`);
|
||||
throw new lib.NotFoundError(`User '${name}' does not exist`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1074,7 +1077,7 @@ export default class ControlConnection extends BaseConnection {
|
||||
this.user.checkPermission("core.user.create");
|
||||
user = this._controller.users.createUser(name);
|
||||
} else {
|
||||
throw new lib.RequestError(`User '${name}' does not exist`);
|
||||
throw new lib.NotFoundError(`User '${name}' does not exist`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1092,7 +1095,7 @@ export default class ControlConnection extends BaseConnection {
|
||||
this.user.checkPermission("core.user.create");
|
||||
user = this._controller.users.createUser(name);
|
||||
} else {
|
||||
throw new lib.RequestError(`User '${name}' does not exist`);
|
||||
throw new lib.NotFoundError(`User '${name}' does not exist`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1104,7 +1107,7 @@ export default class ControlConnection extends BaseConnection {
|
||||
let name = request.name;
|
||||
let user = this._controller.users.getByName(name);
|
||||
if (!user) {
|
||||
throw new lib.RequestError(`User '${name}' does not exist`);
|
||||
throw new lib.NotFoundError(`User '${name}' does not exist`);
|
||||
}
|
||||
|
||||
this._controller.users.deleteUser(user);
|
||||
@@ -1335,47 +1338,54 @@ export default class ControlConnection extends BaseConnection {
|
||||
|
||||
async handleControllerUpdateRequest(request: lib.ControllerUpdateRequest) {
|
||||
if (!this._controller.config.get("controller.allow_remote_updates")) {
|
||||
throw new lib.RequestError("Remote updates are disabled on this machine");
|
||||
throw new lib.ForbiddenError("Remote updates are disabled on this machine");
|
||||
}
|
||||
return await lib.updatePackage("@clusterio/controller");
|
||||
}
|
||||
|
||||
async handlePluginUpdateRequest(request: lib.PluginUpdateRequest) {
|
||||
if (!this._controller.config.get("controller.allow_plugin_updates")) {
|
||||
throw new lib.RequestError("Plugin updates are disabled on this machine");
|
||||
throw new lib.ForbiddenError("Plugin updates are disabled on this machine");
|
||||
}
|
||||
return await lib.handlePluginUpdate(request.pluginPackage, this._controller.pluginInfos);
|
||||
}
|
||||
|
||||
async handlePluginInstallRequest(request: lib.PluginInstallRequest) {
|
||||
if (!this._controller.config.get("controller.allow_plugin_install")) {
|
||||
throw new lib.RequestError("Plugin installs are disabled on this machine");
|
||||
throw new lib.ForbiddenError("Plugin installs are disabled on this machine");
|
||||
}
|
||||
await lib.handlePluginInstall(request.pluginPackage);
|
||||
// A new plugin is not in pluginInfos, so checkRestartRequired cannot notice it
|
||||
if (!this._controller.pluginInfos.some(pluginInfo => pluginInfo.npmPackage === request.pluginPackage)) {
|
||||
const npmPackage = await lib.handlePluginInstall(request.pluginPackage);
|
||||
// A new plugin is not in pluginInfos, so checkRestartRequired cannot notice it. Version
|
||||
// changes to loaded plugins are noticed there.
|
||||
if (!this._controller.pluginInfos.some(pluginInfo => pluginInfo.npmPackage === npmPackage)) {
|
||||
this._controller.config.restartRequired = true;
|
||||
}
|
||||
}
|
||||
|
||||
async handleUpdateAllRequest(request: lib.UpdateAllRequest) {
|
||||
if (!this._controller.config.get("controller.allow_remote_updates")) {
|
||||
throw new lib.RequestError("Remote updates are disabled on this machine");
|
||||
throw new lib.ForbiddenError("Remote updates are disabled on this machine");
|
||||
}
|
||||
if (!this._controller.config.get("controller.allow_plugin_updates")) {
|
||||
throw new lib.RequestError("Plugin updates are disabled on this machine");
|
||||
throw new lib.ForbiddenError("Plugin updates are disabled on this machine");
|
||||
}
|
||||
return await lib.handleUpdateAll("@clusterio/controller", this._controller.pluginInfos);
|
||||
}
|
||||
|
||||
async handlePluginListRequest(request: lib.PluginListRequest) {
|
||||
return this._controller.pluginInfos.map(pluginInfo => lib.PluginDetails.fromNodeEnvInfo(
|
||||
pluginInfo,
|
||||
this._controller.loadedPlugins.has(pluginInfo),
|
||||
this._controller.config.get(`${pluginInfo.name}.load_plugin`),
|
||||
async handlePluginSearchRequest(request: lib.PluginSearchRequest) {
|
||||
return await lib.searchPlugins(request.query, request.page, request.pageSize);
|
||||
}
|
||||
|
||||
async handlePluginLatestVersionsRequest(request: lib.PluginLatestVersionsRequest) {
|
||||
return new lib.PluginLatestVersionsRequest.Response(await lib.fetchLatestVersions(
|
||||
request.packages, this._controller.pluginLatestVersionCache,
|
||||
));
|
||||
}
|
||||
|
||||
async handlePluginListRequest(request: lib.PluginListRequest) {
|
||||
return await this._controller.listPlugins();
|
||||
}
|
||||
|
||||
async handleDebugDumpWsRequest(request: lib.DebugDumpWsRequest) {
|
||||
if (this.ws_dumper) {
|
||||
return; // Already dumping
|
||||
|
||||
@@ -52,7 +52,7 @@ type ControllerDebugEvents = {
|
||||
|
||||
type ControllerEvents = {
|
||||
"stop": [],
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Manages all controller related operations
|
||||
@@ -75,6 +75,8 @@ export default class Controller {
|
||||
hooks = new ControllerHooks(logger);
|
||||
/** Plugins which are currently loaded */
|
||||
loadedPlugins: Set<lib.PluginNodeEnvInfo> = new Set();
|
||||
/** Latest versions of plugins on npm, as shown on the plugins page */
|
||||
pluginLatestVersionCache: lib.LatestVersionCache = new Map();
|
||||
|
||||
/** WebSocket server */
|
||||
wsServer: WsServer;
|
||||
@@ -104,7 +106,7 @@ export default class Controller {
|
||||
_registeredEvents = new Map<lib.EventClass<unknown>, lib.EventHandler<unknown>>();
|
||||
_snoopedEvents = new Map();
|
||||
|
||||
devMiddleware: any | null = null;
|
||||
devServer: import("vite").ViteDevServer | null = null;
|
||||
|
||||
startedAtMs = Date.now();
|
||||
autosaveInterval?: ReturnType<typeof setInterval>;
|
||||
@@ -148,7 +150,14 @@ export default class Controller {
|
||||
}
|
||||
});
|
||||
|
||||
static async bootstrap(config: lib.ControllerConfig) {
|
||||
/** Create a controller with its datastores and mod store loaded from disk */
|
||||
static async fromConfig(
|
||||
config: lib.ControllerConfig,
|
||||
pluginInfos: lib.PluginNodeEnvInfo[],
|
||||
clusterLogger: winston.Logger,
|
||||
canRestart = false,
|
||||
recoveryMode = false,
|
||||
) {
|
||||
let databaseDirectory = config.get("controller.database_directory");
|
||||
await fs.mkdir(databaseDirectory, { recursive: true });
|
||||
|
||||
@@ -158,23 +167,23 @@ export default class Controller {
|
||||
path.join(databaseDirectory, "roles.json"),
|
||||
);
|
||||
|
||||
const systems = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
|
||||
const systems = await lib.SubscribableDatastore.fromProvider(new lib.JsonIdDatastoreProvider(
|
||||
path.join(databaseDirectory, "systems.json"),
|
||||
lib.SystemInfo.fromJSON.bind(lib.SystemInfo),
|
||||
this.migrateSystems,
|
||||
).bootstrap());
|
||||
));
|
||||
|
||||
const hosts = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
|
||||
const hosts = await lib.SubscribableDatastore.fromProvider(new lib.JsonIdDatastoreProvider(
|
||||
path.join(databaseDirectory, "hosts.json"),
|
||||
HostRecord.fromJSON.bind(HostRecord),
|
||||
this.migrateHosts, this.finaliseHosts,
|
||||
).bootstrap());
|
||||
));
|
||||
|
||||
const instances = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
|
||||
const instances = await lib.SubscribableDatastore.fromProvider(new lib.JsonIdDatastoreProvider(
|
||||
path.join(databaseDirectory, "instances.json"),
|
||||
InstanceRecord.fromJSON.bind(InstanceRecord),
|
||||
this.migrateInstances, this.finaliseInstances,
|
||||
).bootstrap());
|
||||
));
|
||||
// Save plugin versions written while loading
|
||||
for (const instance of instances.values()) {
|
||||
if (instance.config.dirty) {
|
||||
@@ -182,33 +191,33 @@ export default class Controller {
|
||||
}
|
||||
}
|
||||
|
||||
const saves = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
|
||||
const saves = await lib.SubscribableDatastore.fromProvider(new lib.JsonIdDatastoreProvider(
|
||||
path.join(databaseDirectory, "saves.json"),
|
||||
lib.SaveDetails.fromJSON.bind(lib.SaveDetails)
|
||||
).bootstrap());
|
||||
));
|
||||
|
||||
const modPacks = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
|
||||
const modPacks = await lib.SubscribableDatastore.fromProvider(new lib.JsonIdDatastoreProvider(
|
||||
path.join(databaseDirectory, "mod-packs.json"),
|
||||
lib.ModPack.fromJSON.bind(lib.ModPack),
|
||||
this.migrateModPacks,
|
||||
).bootstrap());
|
||||
));
|
||||
|
||||
const roles = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
|
||||
const roles = await lib.SubscribableDatastore.fromProvider(new lib.JsonIdDatastoreProvider(
|
||||
path.join(databaseDirectory, "roles.json"),
|
||||
lib.Role.fromJSON.bind(lib.Role),
|
||||
this.migrateRoles,
|
||||
).bootstrap());
|
||||
));
|
||||
|
||||
const users = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
|
||||
const users = await lib.SubscribableDatastore.fromProvider(new lib.JsonIdDatastoreProvider(
|
||||
path.join(databaseDirectory, "users.json"),
|
||||
UserRecord.fromJSON.bind(UserRecord),
|
||||
undefined, this.finaliseUsers,
|
||||
).bootstrap());
|
||||
));
|
||||
|
||||
const notes = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
|
||||
const notes = await lib.SubscribableDatastore.fromProvider(new lib.JsonIdDatastoreProvider(
|
||||
path.join(databaseDirectory, "notes.json"),
|
||||
lib.Note.fromJSON.bind(lib.Note),
|
||||
).bootstrap());
|
||||
));
|
||||
|
||||
let modsDirectory = config.get("controller.mods_directory");
|
||||
await fs.mkdir(modsDirectory, { recursive: true });
|
||||
@@ -223,7 +232,12 @@ export default class Controller {
|
||||
lib.Role.ensureDefaultAdminRole(roles);
|
||||
lib.Role.ensureDefaultPlayerRole(roles);
|
||||
|
||||
return [
|
||||
return new this(
|
||||
clusterLogger,
|
||||
pluginInfos,
|
||||
config,
|
||||
canRestart,
|
||||
recoveryMode,
|
||||
systems,
|
||||
hosts,
|
||||
instances,
|
||||
@@ -233,7 +247,7 @@ export default class Controller {
|
||||
roles,
|
||||
users,
|
||||
notes,
|
||||
] as const;
|
||||
);
|
||||
}
|
||||
|
||||
constructor(
|
||||
@@ -364,7 +378,7 @@ export default class Controller {
|
||||
}, 600e3);
|
||||
logSizeGauge.callback = async () => { logSizeGauge.set(await this.logSize()); };
|
||||
|
||||
// Start webpack development server if enabled
|
||||
// Start Vite development server if enabled
|
||||
if (args.dev || args.devPlugin) {
|
||||
await this._startDevServer(args);
|
||||
}
|
||||
@@ -402,15 +416,21 @@ export default class Controller {
|
||||
}
|
||||
|
||||
Controller.addAppRoutes(this.app, this.pluginInfos);
|
||||
if (this.devServer) {
|
||||
this.app.use(this.devServer.middlewares);
|
||||
}
|
||||
|
||||
if (!args.dev) {
|
||||
let manifestPath = path.join(import.meta.dirname, "..", "..", "web", "manifest.json");
|
||||
|
||||
let manifest = await Controller.loadJsonObject(manifestPath);
|
||||
if (!manifest["main.js"]) {
|
||||
logger.error("Missing main.js entry in dist/web/manifest.json");
|
||||
if (!manifest.main) {
|
||||
logger.error("Missing main entry in dist/web/manifest.json");
|
||||
}
|
||||
this.app.locals.mainBundle = manifest["main.js"] || "no_web_build";
|
||||
this.app.locals.webBundle = {
|
||||
main: manifest.main || "no_web_build",
|
||||
imports: manifest.imports || {},
|
||||
};
|
||||
}
|
||||
this.app.locals.staticRoot = this.config.get("controller.static_url");
|
||||
|
||||
@@ -478,37 +498,44 @@ export default class Controller {
|
||||
}
|
||||
|
||||
async _startDevServer(args: ControllerArgs) {
|
||||
logger.warn("Webpack development mode enabled");
|
||||
logger.warn("Vite development mode enabled");
|
||||
|
||||
const webpack = (await import("webpack")).default;
|
||||
const webpackDevMiddleware = (await import("webpack-dev-middleware")).default;
|
||||
const webpackConfigs = [];
|
||||
const vite = await import("vite");
|
||||
const webUiVite = await import("@clusterio/web_ui/vite.mjs");
|
||||
|
||||
if (args.dev) {
|
||||
// @ts-expect-error this file is run from a different folder
|
||||
webpackConfigs.push((await import("../../../webpack.config.cjs")).default({})); // Path outside of build
|
||||
}
|
||||
if (args.devPlugin) {
|
||||
let devPlugins = new Map();
|
||||
for (let name of args.devPlugin) {
|
||||
let info = this.pluginInfos.find(i => i.name === name);
|
||||
if (!info) {
|
||||
throw new lib.StartupError(`No plugin named ${name}`);
|
||||
}
|
||||
const webpackConfig = path.posix.join(info.requirePath, "webpack.config.cjs");
|
||||
let config = (await import(
|
||||
path.isAbsolute(webpackConfig) ? pathToFileURL(webpackConfig).href : webpackConfig
|
||||
)).default({});
|
||||
devPlugins.set(name, webpackConfigs.length);
|
||||
webpackConfigs.push(config);
|
||||
const devPlugins = new Map<string, string>();
|
||||
const pluginDirs = new Map<string, string>();
|
||||
for (let name of (args.devPlugin ?? []).map(String)) {
|
||||
let info = this.pluginInfos.find(i => i.name === name);
|
||||
if (!info) {
|
||||
throw new lib.StartupError(`No plugin named ${name}`);
|
||||
}
|
||||
this.app.locals.devPlugins = devPlugins;
|
||||
pluginDirs.set(name, path.dirname(info.packagePath));
|
||||
devPlugins.set(name, webUiVite.devPluginUrl(name));
|
||||
}
|
||||
this.app.locals.devPlugins = devPlugins;
|
||||
|
||||
|
||||
const compiler = webpack(webpackConfigs);
|
||||
this.devMiddleware = webpackDevMiddleware(compiler, { serverSideRender: true });
|
||||
this.app.use(this.devMiddleware);
|
||||
const controllerDir = path.join(import.meta.dirname, "..", "..", "..");
|
||||
this.devServer = await vite.createServer({
|
||||
configFile: path.join(controllerDir, "vite.config.js"),
|
||||
root: controllerDir,
|
||||
base: "/static/",
|
||||
appType: "custom",
|
||||
server: {
|
||||
middlewareMode: true,
|
||||
preTransformRequests: false,
|
||||
},
|
||||
plugins: [webUiVite.clusterioDevPlugins(pluginDirs)],
|
||||
});
|
||||
if (args.dev) {
|
||||
this.app.locals.webBundle = {
|
||||
main: this.devServer.config.plugins.find(p => p.name === "clusterio:app")!.api.entry,
|
||||
imports: Object.fromEntries(webUiVite.sharedModules.map(
|
||||
name => [name, webUiVite.devSharedUrl(name)]
|
||||
)),
|
||||
};
|
||||
}
|
||||
this.app.locals.devServer = this.devServer;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -559,8 +586,8 @@ export default class Controller {
|
||||
await this.clusterLogIndex.save();
|
||||
}
|
||||
|
||||
if (this.devMiddleware) {
|
||||
await new Promise((resolve, reject) => { this.devMiddleware.close(resolve); });
|
||||
if (this.devServer) {
|
||||
await this.devServer.close();
|
||||
}
|
||||
|
||||
await this.hooks.shutdown.invoke();
|
||||
@@ -634,7 +661,9 @@ export default class Controller {
|
||||
requests.push(hostConnection.send(new lib.SystemInfoRequest()));
|
||||
}
|
||||
const restartRequired = await this.checkRestartRequired();
|
||||
requests.push(lib.gatherSystemInfo("controller", this.canRestart, restartRequired, this.startedAtMs));
|
||||
requests.push(lib.gatherSystemInfo(
|
||||
"controller", this.canRestart, restartRequired, this.startedAtMs, await this.listPlugins(),
|
||||
));
|
||||
const newMetrics = await Promise.all(requests);
|
||||
for (const metric of newMetrics) {
|
||||
this.systems.set(metric);
|
||||
@@ -644,6 +673,15 @@ export default class Controller {
|
||||
}
|
||||
}
|
||||
|
||||
/** Plugins on the controller, including those installed or updated since it started */
|
||||
async listPlugins() {
|
||||
return await lib.listPluginDetails(
|
||||
this.pluginInfos,
|
||||
pluginInfo => this.loadedPlugins.has(pluginInfo),
|
||||
pluginInfo => this.config.get(`${pluginInfo.name}.load_plugin`),
|
||||
);
|
||||
}
|
||||
|
||||
async checkRestartRequired() {
|
||||
if (this.config.restartRequired) {
|
||||
return true;
|
||||
@@ -1332,26 +1370,29 @@ export default class Controller {
|
||||
let depth = routeDepth + Number(req.path.slice(-1) === "/");
|
||||
let webRoot = "../".repeat(depth) || "./";
|
||||
let staticRoot = res.app.locals.staticRoot ?? `${webRoot}static/`;
|
||||
let mainBundle: string = "";
|
||||
if (res.app.locals.mainBundle) {
|
||||
mainBundle = res.app.locals.mainBundle;
|
||||
} else {
|
||||
let stats = res.locals.webpack.devMiddleware.stats.stats[0];
|
||||
mainBundle = stats.toJson().assetsByChunkName["main"][0];
|
||||
}
|
||||
mainBundle = routes.stripStaticPrefix(mainBundle);
|
||||
const { main, imports } = res.app.locals.webBundle;
|
||||
const importMap = JSON.stringify({
|
||||
imports: Object.fromEntries(Object.entries(imports).map(
|
||||
([name, file]) => [name, `${staticRoot}${routes.stripStaticPrefix(file)}`]
|
||||
)),
|
||||
}).replace(/</g, "\\u003c");
|
||||
|
||||
fs.readFile(
|
||||
path.join(import.meta.dirname, "..", "..", "..", "web", "index.html"),
|
||||
"utf8",
|
||||
).then((content) => {
|
||||
res.type("text/html");
|
||||
res.send(content
|
||||
).then(async (content) => {
|
||||
content = content
|
||||
.replace(/__CLUSTER_NAME__/g, res.app.locals.controller.config.get("controller.name"))
|
||||
.replace(/__WEB_ROOT__/g, webRoot)
|
||||
.replace(/__STATIC_ROOT__/g, staticRoot)
|
||||
.replace(/__MAIN_BUNDLE__/g, mainBundle)
|
||||
);
|
||||
.replace(/__IMPORT_MAP__/g, () => importMap)
|
||||
.replace(/__MAIN_BUNDLE__/g, () => routes.stripStaticPrefix(main))
|
||||
;
|
||||
if (res.app.locals.devServer) {
|
||||
content = await res.app.locals.devServer.transformIndexHtml(req.originalUrl, content);
|
||||
}
|
||||
res.type("text/html");
|
||||
res.send(content);
|
||||
}).catch(err => {
|
||||
next(err);
|
||||
});
|
||||
@@ -1440,24 +1481,24 @@ export default class Controller {
|
||||
} else if (dst.type === lib.Address.control) {
|
||||
connection = this.wsServer.controlConnections.get(dst.id);
|
||||
if (!connection) {
|
||||
throw new lib.RequestError("Target control connection does not exist.");
|
||||
throw new lib.NotFoundError("Target control connection does not exist.");
|
||||
}
|
||||
|
||||
} else if (dst.type === lib.Address.instance) {
|
||||
const instance = this.instances.getForRequest(dst.id);
|
||||
let hostId = instance.config.get("instance.assigned_host");
|
||||
if (hostId === null) {
|
||||
throw new lib.RequestError("Instance is not assigned to a host");
|
||||
throw new lib.ConflictError("Instance is not assigned to a host");
|
||||
}
|
||||
connection = this.wsServer.hostConnections.get(hostId);
|
||||
if (!connection) {
|
||||
throw new lib.RequestError("Host containing instance is not connected");
|
||||
throw new lib.UnavailableError("Host containing instance is not connected");
|
||||
}
|
||||
|
||||
} else if (dst.type === lib.Address.host) {
|
||||
connection = this.wsServer.hostConnections.get(dst.id);
|
||||
if (!connection) {
|
||||
throw new lib.RequestError("Host is not connected");
|
||||
throw new lib.UnavailableError("Host is not connected");
|
||||
}
|
||||
|
||||
} else {
|
||||
@@ -1541,11 +1582,11 @@ export default class Controller {
|
||||
let instance = this.instances.getForRequest(request.instanceId);
|
||||
let hostId = instance.config.get("instance.assigned_host");
|
||||
if (hostId === null) {
|
||||
throw new lib.RequestError("Instance is not assigned to a host");
|
||||
throw new lib.ConflictError("Instance is not assigned to a host");
|
||||
}
|
||||
let connection = this.wsServer.hostConnections.get(hostId);
|
||||
if (!connection) {
|
||||
throw new lib.RequestError("Host containing instance is not connected");
|
||||
throw new lib.UnavailableError("Host containing instance is not connected");
|
||||
}
|
||||
return await connection.send(request);
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ export default class ControllerRouter {
|
||||
return false;
|
||||
}
|
||||
origin.connector.sendResponseError(
|
||||
new lib.ResponseError(msg || "Unroutable destination"), message.src
|
||||
new lib.ResponseError(msg || "Unroutable destination", "UnavailableError"), message.src
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -65,12 +65,12 @@ export default class InstanceManager {
|
||||
*
|
||||
* @param instanceId - ID of instance to get.
|
||||
* @returns The instance record.
|
||||
* @throws {module:lib.RequestError} if the instance does not exist.
|
||||
* @throws {module:lib.NotFoundError} if the instance does not exist.
|
||||
*/
|
||||
getForRequest(instanceId: number): InstanceRecord {
|
||||
const instance = this.records.get(instanceId);
|
||||
if (!instance) {
|
||||
throw new lib.RequestError(`Instance with ID ${instanceId} does not exist`);
|
||||
throw new lib.NotFoundError(`Instance with ID ${instanceId} does not exist`);
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
@@ -125,7 +125,7 @@ export default class InstanceManager {
|
||||
* @param instanceConfig - Config to base the newly created instance on.
|
||||
* @param _suppressChanges - When true, side effects are suppressed, caller takes ownership.
|
||||
* @returns The created instance record.
|
||||
* @throws {module:lib.RequestError} if an instance with the same ID already exists.
|
||||
* @throws {module:lib.ConflictError} if an instance with the same ID already exists.
|
||||
*/
|
||||
async createInstance(
|
||||
instanceConfig: lib.InstanceConfig,
|
||||
@@ -133,7 +133,7 @@ export default class InstanceManager {
|
||||
): Promise<InstanceRecord> {
|
||||
const instanceId = instanceConfig.get("instance.id");
|
||||
if (this.records.has(instanceId)) {
|
||||
throw new lib.RequestError(`Instance with ID ${instanceId} already exists`);
|
||||
throw new lib.ConflictError(`Instance with ID ${instanceId} already exists`);
|
||||
}
|
||||
|
||||
const controllerName = this._controller.config.get("controller.name");
|
||||
@@ -172,8 +172,8 @@ export default class InstanceManager {
|
||||
* @param hostId - ID of host to assign the instance to. If undefined,
|
||||
* the instance will be unassigned.
|
||||
*
|
||||
* @throws {module:lib.RequestError} if the instance does not exist.
|
||||
* @throws {module:lib.RequestError} if the target host is not connected.
|
||||
* @throws {module:lib.NotFoundError} if the instance does not exist.
|
||||
* @throws {module:lib.UnavailableError} if the target host is not connected.
|
||||
*/
|
||||
async assignInstance(
|
||||
instanceId: number,
|
||||
@@ -191,7 +191,7 @@ export default class InstanceManager {
|
||||
if (hostId !== null) {
|
||||
newHostConnection = hostConnections.get(hostId);
|
||||
if (!newHostConnection) {
|
||||
throw new lib.RequestError("Target host is not connected to the controller");
|
||||
throw new lib.UnavailableError("Target host is not connected to the controller");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,7 +247,7 @@ export default class InstanceManager {
|
||||
*
|
||||
* @param instanceId - ID of instance to delete.
|
||||
*
|
||||
* @throws {module:lib.RequestError} if the instance does not exist.
|
||||
* @throws {module:lib.NotFoundError} if the instance does not exist.
|
||||
*/
|
||||
async deleteInstance(instanceId: number): Promise<void> {
|
||||
const instance = this.getForRequest(instanceId);
|
||||
|
||||
@@ -20,9 +20,10 @@ declare global {
|
||||
namespace Express {
|
||||
export interface Locals {
|
||||
controller: Controller,
|
||||
mainBundle: string,
|
||||
webBundle: { main: string, imports: Record<string, string> },
|
||||
devServer?: import("vite").ViteDevServer,
|
||||
staticRoot: string | null,
|
||||
devPlugins: Map<string, number>,
|
||||
devPlugins?: Map<string, string>,
|
||||
streams: Map<string, ProxyStream>,
|
||||
pendingMetrics?: Promise<string>,
|
||||
}
|
||||
@@ -124,7 +125,7 @@ async function getMetrics(req: Request, res: Response) {
|
||||
|
||||
|
||||
/**
|
||||
* Removes the static/ prefix webpack adds to bundle paths so they can be
|
||||
* Removes the static/ prefix from bundle paths in manifest.json so they can be
|
||||
* resolved relative to staticRoot.
|
||||
*/
|
||||
export function stripStaticPrefix(bundlePath: string) {
|
||||
@@ -140,22 +141,18 @@ function getPlugins(req: Request, res: Response) {
|
||||
// Note: Cast through unknown is needed because load_plugin is
|
||||
// defined at runtime and no other fields in the controller config
|
||||
// currently have the boolean type.
|
||||
let web: { main:any, error:string|undefined } = { main: undefined, error: undefined};
|
||||
let web: { main: any, error: string|undefined } = { main: undefined, error: undefined};
|
||||
let devPlugins = req.app.locals.devPlugins;
|
||||
if (devPlugins && devPlugins.has(name)) {
|
||||
let stats = res.locals.webpack.devMiddleware.stats.stats[devPlugins.get(name)!];
|
||||
web.main = stats.toJson().assetsByChunkName[name][0];
|
||||
web.main = devPlugins.get(name);
|
||||
} else if (pluginInfo.manifest) {
|
||||
web.main = pluginInfo.manifest[`${pluginInfo.name}.js`];
|
||||
web.main = pluginInfo.manifest.main;
|
||||
if (!web.main) {
|
||||
web.error = `Missing ${pluginInfo.name}.js entry in manifest.json`;
|
||||
web.error = "Incompatible dist/web/manifest.json, the plugin must be rebuilt with Vite";
|
||||
}
|
||||
} else if (lib.pluginNeedsWebBuild(pluginInfo)) {
|
||||
web.error = "Missing dist/web/manifest.json";
|
||||
}
|
||||
if (web.main === "remoteEntry.js") {
|
||||
web.error = "Incompatible old remoteEntry.js entrypoint.";
|
||||
}
|
||||
if (web.main) {
|
||||
web.main = stripStaticPrefix(web.main);
|
||||
}
|
||||
@@ -308,7 +305,7 @@ type ProxyStreamEvents = {
|
||||
"close": [],
|
||||
"timeout": [],
|
||||
"source": [],
|
||||
}
|
||||
};
|
||||
|
||||
export interface ProxyStream {
|
||||
id: string;
|
||||
@@ -548,7 +545,7 @@ function checkModName(name: string) {
|
||||
try {
|
||||
lib.checkFilename(name);
|
||||
} catch (err: any) {
|
||||
throw new lib.RequestError(`Mod name ${err.message}`);
|
||||
throw new lib.ValidationError(`Mod name ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
// This needs to be a separate file due to ECMAScript Modules evaluating code in
|
||||
// imported modules before any code in the file that imports it.
|
||||
(global as any).lazySchemaCompilation = true;
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"entryPoints": ["index.ts"],
|
||||
"includeVersion": true
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { defineConfig } from "vite";
|
||||
import { clusterioApp } from "@clusterio/web_ui/vite.mjs";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [clusterioApp({ entry: "web/index.tsx" })],
|
||||
});
|
||||
@@ -5,6 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#2a2a2a" />
|
||||
<title>__CLUSTER_NAME__</title>
|
||||
<script type="importmap">__IMPORT_MAP__</script>
|
||||
<style>
|
||||
html {
|
||||
color: #fff;
|
||||
@@ -26,7 +27,6 @@
|
||||
<div id="root">
|
||||
<div id="bootstrap-msg">Loading web interface...</div>
|
||||
</div>
|
||||
<script id="script-bundle" src="__STATIC_ROOT____MAIN_BUNDLE__" defer></script>
|
||||
<script>
|
||||
webRoot = new URL("__WEB_ROOT__", document.location).pathname;
|
||||
staticRoot = new URL("__STATIC_ROOT__", document.location).href;
|
||||
@@ -41,9 +41,9 @@
|
||||
bootstrapMsg.textContent = `${errorMsg}\nSee web console for details.`;
|
||||
}
|
||||
}
|
||||
const bundleScript = document.getElementById("script-bundle");
|
||||
bundleScript.addEventListener("error", () => scriptError("Main bundle failed to load."));
|
||||
window.addEventListener("error", event => scriptError(event.error));
|
||||
</script>
|
||||
<script id="script-bundle" type="module" src="__STATIC_ROOT____MAIN_BUNDLE__"
|
||||
onerror="scriptError('Main bundle failed to load.')"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,3 +1,4 @@
|
||||
// eslint-disable-next-line no-implicit-globals
|
||||
declare function scriptError(err: any): void;
|
||||
import("@clusterio/web_ui")
|
||||
.then(webUi => webUi.bootstrap())
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
"use strict";
|
||||
const path = require("path");
|
||||
const webpack = require("webpack");
|
||||
const { merge } = require("webpack-merge");
|
||||
|
||||
const common = require("@clusterio/web_ui/webpack.common");
|
||||
|
||||
module.exports = (env = {}, argv = {}) => merge(common(env, argv), {
|
||||
context: __dirname,
|
||||
entry: "./web/index.tsx",
|
||||
devServer: {
|
||||
contentBase: "./dist/web",
|
||||
},
|
||||
output: {
|
||||
path: path.resolve(__dirname, "dist", "web"),
|
||||
},
|
||||
plugins: [
|
||||
new webpack.container.ModuleFederationPlugin({
|
||||
name: "controller",
|
||||
shared: {
|
||||
"@clusterio/lib": { singleton: true },
|
||||
"@clusterio/web_ui": { singleton: true },
|
||||
"antd": { singleton: true },
|
||||
"react": { singleton: true },
|
||||
"react-dom": { singleton: true },
|
||||
"react-router": { singleton: true },
|
||||
"react-router-dom": { singleton: true },
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
@@ -28,7 +28,7 @@
|
||||
"clusteriocreate": "./create.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
"node": "^22.18.0 || >=24.2.0"
|
||||
},
|
||||
|
||||
"dependencies": {
|
||||
|
||||
@@ -88,7 +88,7 @@ export async function copyPluginTemplates(pluginName, templates) {
|
||||
const config = !templates.includes("no_config") && templates.some(value => (
|
||||
["controller", "host", "instance", "ctl"].includes(value)
|
||||
));
|
||||
const webpack = config || templates.includes("controller") || templates.includes("web");
|
||||
const webBuild = config || templates.includes("controller") || templates.includes("web");
|
||||
|
||||
// A count of the number of isolated contexts the plugin runs under
|
||||
let pluginContexts = 0;
|
||||
@@ -113,15 +113,15 @@ export async function copyPluginTemplates(pluginName, templates) {
|
||||
files.set("tsconfig.json", path.join(templatePath, "tsconfig.json"));
|
||||
files.set("tsconfig.node.json", path.join(templatePath, "tsconfig.node.json"));
|
||||
files.set("tsconfig.base.json", path.join(templatePath, "tsconfig.base.json"));
|
||||
if (webpack) {
|
||||
if (webBuild) {
|
||||
files.set("tsconfig.browser.json", path.join(templatePath, "tsconfig.browser.json"));
|
||||
}
|
||||
}
|
||||
|
||||
// Files and dependences to support webpack
|
||||
if (webpack) {
|
||||
prepare.push("webpack-cli --mode production");
|
||||
files.set("webpack.config.cjs", path.join(commonPath, "webpack.config.cjs"));
|
||||
// Files and dependences to support the web build
|
||||
if (webBuild) {
|
||||
prepare.push("vite build");
|
||||
files.set("vite.config.js", path.join(commonPath, "vite.config.js"));
|
||||
if (templates.includes("web")) {
|
||||
files.set(`web/index.${ext}x`, path.join(templatePath, `web/plugin.${ext}x`));
|
||||
pluginContexts += 1;
|
||||
@@ -185,7 +185,7 @@ export async function copyPluginTemplates(pluginName, templates) {
|
||||
ctl: templates.includes("ctl"),
|
||||
web: templates.includes("web"),
|
||||
// Macro flags for context requirements
|
||||
webpack: webpack,
|
||||
web_build: webBuild,
|
||||
config: config,
|
||||
// String values for package json
|
||||
clusterio_version: _package.version,
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
"devDependencies": {
|
||||
//%// Typescript and type declarations
|
||||
//%if typescript
|
||||
"typescript": "^5.9.3",
|
||||
"typescript": "^7.0.2",
|
||||
"@types/node": "^24.13.4",
|
||||
//%endif
|
||||
//%if typescript & web
|
||||
@@ -67,15 +67,13 @@
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
//%endif
|
||||
//%if webpack
|
||||
"webpack": "^5.110.3",
|
||||
"webpack-cli": "^7.2.3",
|
||||
"webpack-merge": "^5.9.0",
|
||||
//%if web_build
|
||||
"vite": "^8.3.4",
|
||||
//%endif
|
||||
//%if controller
|
||||
"@clusterio/controller": "^__clusterio_version__",
|
||||
//%endif
|
||||
//%if webpack
|
||||
//%if web_build
|
||||
"@clusterio/web_ui": "^__clusterio_version__",
|
||||
//%endif
|
||||
//%if host
|
||||
@@ -87,7 +85,7 @@
|
||||
"@clusterio/lib": "^__clusterio_version__"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sinclair/typebox": "^0.30.6"
|
||||
"@sinclair/typebox": "^0.34.52"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
webpack.config.js
|
||||
vite.config.js
|
||||
dist/browser
|
||||
dist/web
|
||||
@@ -0,0 +1,6 @@
|
||||
import { defineConfig } from "vite";
|
||||
import { clusterioPlugin } from "@clusterio/web_ui/vite.mjs";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [clusterioPlugin()],
|
||||
});
|
||||
@@ -1,34 +0,0 @@
|
||||
"use strict";
|
||||
const path = require("path");
|
||||
const webpack = require("webpack");
|
||||
const { merge } = require("webpack-merge");
|
||||
|
||||
const common = require("@clusterio/web_ui/webpack.common");
|
||||
|
||||
module.exports = (env = {}, argv = {}) => merge(common(env, argv), {
|
||||
context: __dirname,
|
||||
entry: "./web/index.__ext__x",
|
||||
output: {
|
||||
path: path.resolve(__dirname, "dist", "web"),
|
||||
},
|
||||
plugins: [
|
||||
new webpack.container.ModuleFederationPlugin({
|
||||
name: "__plugin_name__",
|
||||
library: { type: "window", name: "plugin___plugin_name__" },
|
||||
exposes: {
|
||||
"./": "./index.__ext__",
|
||||
"./package.json": "./package.json",
|
||||
"./web": "./web/index.__ext__x",
|
||||
},
|
||||
shared: {
|
||||
"@clusterio/lib": { import: false },
|
||||
"@clusterio/web_ui": { import: false },
|
||||
//%if web
|
||||
"antd": { import: false },
|
||||
"react": { import: false },
|
||||
"react-dom": { import: false },
|
||||
//%endif
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
@@ -2,6 +2,7 @@
|
||||
"extends": "./tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["es2023"],
|
||||
"types": ["node"],
|
||||
"module": "node16",
|
||||
"moduleResolution": "node16",
|
||||
"target": "es2022",
|
||||
|
||||
+6
-6
@@ -307,12 +307,12 @@ async function startCtl() {
|
||||
logger.error(`Error running command: ${err.message}`);
|
||||
process.exitCode = 1;
|
||||
|
||||
} else if (err instanceof lib.RequestError) {
|
||||
if (err.stack) {
|
||||
logger.error(`Error sending request:\n${err.stack}`);
|
||||
} else {
|
||||
logger.error(`Error sending request: ${err.message}`);
|
||||
}
|
||||
} else if (err instanceof lib.ClientError || err instanceof lib.UnavailableError) {
|
||||
logger.error(`Error sending request: ${err.message}`);
|
||||
process.exitCode = 1;
|
||||
|
||||
} else if (err instanceof lib.ServerError) {
|
||||
logger.error(`Error sending request:\n${err.stack ?? err.message}`);
|
||||
process.exitCode = 1;
|
||||
|
||||
} else {
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"clusterioctl": "./dist/node/ctl.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
"node": "^22.18.0 || >=24.2.0"
|
||||
},
|
||||
|
||||
"dependencies": {
|
||||
|
||||
@@ -180,7 +180,7 @@ controllerPluginCommands.add(new lib.Command({
|
||||
}));
|
||||
controllerPluginCommands.add(new lib.Command({
|
||||
definition: ["update <plugin>", "Update a plugin on the controller", (yargs) => {
|
||||
yargs.positional("plugin", { describe: "Plugin to update", type: "string" });
|
||||
yargs.positional("plugin", { describe: "Plugin to update, optionally as name@version", type: "string" });
|
||||
yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" });
|
||||
}],
|
||||
handler: async function(args: { plugin: string, restart: boolean }, ctl: Ctl) {
|
||||
@@ -192,9 +192,28 @@ controllerPluginCommands.add(new lib.Command({
|
||||
}
|
||||
},
|
||||
}));
|
||||
controllerPluginCommands.add(new lib.Command({
|
||||
definition: ["search [query]", "Search npm for plugins", (yargs) => {
|
||||
yargs.positional("query", { describe: "Text to search for", type: "string", default: "" });
|
||||
yargs.option("page", { type: "number", description: "Page of results to show", default: 1 });
|
||||
yargs.option("page-size", { type: "number", description: "Results per page", default: 20 });
|
||||
}],
|
||||
handler: async function(args: { query: string, page: number, pageSize: number }, ctl: Ctl) {
|
||||
const response = await ctl.sendTo(
|
||||
"controller", new lib.PluginSearchRequest(args.query, args.page, args.pageSize)
|
||||
);
|
||||
print(asTable(response.results.map(plugin => ({
|
||||
name: plugin.name,
|
||||
version: plugin.version,
|
||||
publisher: plugin.publisher ?? "",
|
||||
description: plugin.description ?? "",
|
||||
}))));
|
||||
print(`Showing ${response.results.length} of ${response.total} results`);
|
||||
},
|
||||
}));
|
||||
controllerPluginCommands.add(new lib.Command({
|
||||
definition: ["install <plugin>", "Install a plugin on the controller", (yargs) => {
|
||||
yargs.positional("plugin", { describe: "Plugin to install", type: "string" });
|
||||
yargs.positional("plugin", { describe: "Plugin to install, optionally as name@version", type: "string" });
|
||||
yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" });
|
||||
}],
|
||||
handler: async function(args: { plugin: string, restart: boolean }, ctl: Ctl) {
|
||||
|
||||
@@ -271,14 +271,14 @@ hostPluginCommands.add(new lib.Command({
|
||||
let hostId = await lib.resolveHost(ctl, args.host);
|
||||
const plugins = await ctl.sendTo({ hostId }, new lib.PluginListRequest());
|
||||
print(asTable(plugins.map(p => ({
|
||||
title: p.title, version: p.version, loaded: p.loaded, enabled: p.enabled, npmPackage: p.npmPackage,
|
||||
title: p.title, version: p.version, state: p.state, npmPackage: p.npmPackage,
|
||||
}))));
|
||||
},
|
||||
}));
|
||||
hostPluginCommands.add(new lib.Command({
|
||||
definition: ["update <host> <plugin>", "Update a plugin on a host", (yargs) => {
|
||||
yargs.positional("host", { describe: "Host to update on", type: "string" });
|
||||
yargs.positional("plugin", { describe: "Plugin to update", type: "string" });
|
||||
yargs.positional("plugin", { describe: "Plugin to update, optionally as name@version", type: "string" });
|
||||
yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" });
|
||||
}],
|
||||
handler: async function(args: { host: string, plugin: string, restart: boolean }, ctl: Ctl) {
|
||||
@@ -294,7 +294,7 @@ hostPluginCommands.add(new lib.Command({
|
||||
hostPluginCommands.add(new lib.Command({
|
||||
definition: ["install <host> <plugin>", "Install a plugin on a host", (yargs) => {
|
||||
yargs.positional("host", { describe: "Host to install on", type: "string" });
|
||||
yargs.positional("plugin", { describe: "Plugin to install", type: "string" });
|
||||
yargs.positional("plugin", { describe: "Plugin to install, optionally as name@version", type: "string" });
|
||||
yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" });
|
||||
}],
|
||||
handler: async function(args: { host: string, plugin: string, restart: boolean }, ctl: Ctl) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import * as lib from "@clusterio/lib";
|
||||
|
||||
export async function getEditor(argsEditor: string) {
|
||||
// eslint-disable-next-line
|
||||
return argsEditor || process.env.EDITOR || process.env.VISUAL || undefined
|
||||
return argsEditor || process.env.EDITOR || process.env.VISUAL || undefined;
|
||||
// needed for the process.env statements to not be flagged by eslint
|
||||
// priority for editors is CLI argument > env.EDITOR > env.VISUAL
|
||||
}
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"entryPoints": ["index.ts"],
|
||||
"includeVersion": true
|
||||
}
|
||||
+10
-6
@@ -20,10 +20,12 @@ import winston from "winston";
|
||||
import "winston-daily-rotate-file";
|
||||
|
||||
// internal libraries
|
||||
// Reduce startup time by lazy compiling schemas.
|
||||
import "./src/set_lazy_schema_compilation.js";
|
||||
import * as lib from "@clusterio/lib";
|
||||
import { ConsoleTransport, levels, logger } from "@clusterio/lib";
|
||||
|
||||
import Host from "./src/Host.js";
|
||||
import type Host from "./src/Host.js";
|
||||
import { createScenarioCommand, handleCreateScenarioCommand } from "./src/create_scenario.js";
|
||||
|
||||
let host: Host | undefined;
|
||||
@@ -164,7 +166,8 @@ async function startHost() {
|
||||
}
|
||||
|
||||
logger.info(`Loading available plugins from ${args.pluginList}`);
|
||||
let pluginList = await lib.loadPluginList(args.pluginList);
|
||||
const skippedNpmPlugins = new Map<string, string>();
|
||||
let pluginList = await lib.loadPluginList(args.pluginList, true, skippedNpmPlugins);
|
||||
|
||||
// If the command is plugin management we don't try to load plugins
|
||||
if (command === "plugin") {
|
||||
@@ -173,7 +176,7 @@ async function startHost() {
|
||||
}
|
||||
|
||||
logger.info("Loading Plugin info");
|
||||
let pluginInfos = await lib.loadPluginInfos(pluginList);
|
||||
let pluginInfos = await lib.loadPluginInfos(pluginList, skippedNpmPlugins);
|
||||
lib.registerPluginMessages(pluginInfos);
|
||||
lib.registerPluginPermissions(pluginInfos);
|
||||
lib.addPluginConfigFields(pluginInfos);
|
||||
@@ -232,14 +235,15 @@ async function startHost() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Loaded here as the server is not needed by the other commands
|
||||
const { default: Host } = await import("./src/Host.js");
|
||||
let hostConnector = new HostConnector(hostConfig, pluginInfos);
|
||||
host = new Host(
|
||||
hostConnector,
|
||||
host = await Host.fromConfig(
|
||||
hostConfig,
|
||||
pluginInfos,
|
||||
hostConnector,
|
||||
Boolean(args.canRestart),
|
||||
Boolean(args.recovery),
|
||||
...await Host.bootstrap(hostConfig)
|
||||
);
|
||||
|
||||
// Handle interrupts
|
||||
|
||||
@@ -33,13 +33,17 @@
|
||||
"clusteriohost": "./dist/node/host.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
"node": "^22.18.0 || >=24.2.0"
|
||||
},
|
||||
|
||||
"dependencies": {
|
||||
"@clusterio/lib": "workspace:^",
|
||||
"@jimp/core": "^1.6.1",
|
||||
"@jimp/js-png": "^1.6.1",
|
||||
"@jimp/plugin-crop": "^1.6.1",
|
||||
"@jimp/plugin-resize": "^1.6.1",
|
||||
"@jimp/types": "^1.6.1",
|
||||
"@sinclair/typebox": "catalog:",
|
||||
"jimp": "^1.6.1",
|
||||
"pidusage": "^4.1.0",
|
||||
"rcon-client": "^4.2.5",
|
||||
"semver": "catalog:",
|
||||
|
||||
@@ -23,6 +23,7 @@ export class InstanceHooks extends lib.AsyncHookCollection {
|
||||
this.controllerConnectionEvent = this.newHook();
|
||||
this.prepareControllerDisconnect = this.newHook();
|
||||
this.playerEvent = this.newHook();
|
||||
this.autoPauseChanged = this.newHook();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -147,6 +148,19 @@ export class InstanceHooks extends lib.AsyncHookCollection {
|
||||
* @param event - Information about the event.
|
||||
*/
|
||||
readonly playerEvent: lib.AsyncHook<[event: lib.PlayerEvent]>;
|
||||
|
||||
/**
|
||||
* Called when Factorio auto pauses or unpauses the game
|
||||
*
|
||||
* Invoked when the last player leaves or the first player joins an
|
||||
* instance started with the auto_pause server setting on. Every RCON
|
||||
* command makes a paused game run one tick, so periodic commands
|
||||
* should be skipped while paused. See `Instance.autoPaused`. It is
|
||||
* not invoked on start, read `autoPaused` in the start hook instead.
|
||||
*
|
||||
* @param paused - true if the game is now paused.
|
||||
*/
|
||||
readonly autoPauseChanged: lib.AsyncHook<[paused: boolean]>;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
|
||||
@@ -160,6 +174,7 @@ export interface BaseInstancePlugin {
|
||||
onControllerConnectionEvent?(event: "connect" | "drop" | "resume" | "close"): void;
|
||||
onPrepareControllerDisconnect?(connection: Instance): Promise<void>;
|
||||
onPlayerEvent?(event: lib.PlayerEvent): Promise<void>;
|
||||
onAutoPauseChanged?(paused: boolean): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -222,6 +237,7 @@ export class BaseInstancePlugin {
|
||||
attach(instance.hooks.controllerConnectionEvent, this.onControllerConnectionEvent);
|
||||
attach(instance.hooks.prepareControllerDisconnect, this.onPrepareControllerDisconnect);
|
||||
attach(instance.hooks.playerEvent, this.onPlayerEvent);
|
||||
attach(instance.hooks.autoPauseChanged, this.onAutoPauseChanged);
|
||||
}
|
||||
|
||||
static fromContext(context: InstancePluginContext) {
|
||||
|
||||
+143
-75
@@ -22,7 +22,38 @@ function checkRequestSaveName(name: string) {
|
||||
try {
|
||||
lib.checkFilename(name);
|
||||
} catch (err: any) {
|
||||
throw new lib.RequestError(`Save name ${err.message}`);
|
||||
throw new lib.ValidationError(`Save name ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function copySaveExclusive(sourcePath: string, targetPath: string) {
|
||||
await fs.copyFile(
|
||||
sourcePath,
|
||||
targetPath,
|
||||
fs.constants.COPYFILE_EXCL | fs.constants.COPYFILE_FICLONE
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a file without overwriting an existing target, fails with EEXIST
|
||||
* if the target exists.
|
||||
*/
|
||||
async function moveSaveExclusive(sourcePath: string, targetPath: string) {
|
||||
try {
|
||||
await fs.link(sourcePath, targetPath);
|
||||
} catch (err: any) {
|
||||
// Hard links fail across filesystems and on some filesystems with
|
||||
// varying codes, the copy is still exclusive.
|
||||
if (err.code === "EEXIST") {
|
||||
throw err;
|
||||
}
|
||||
await copySaveExclusive(sourcePath, targetPath);
|
||||
}
|
||||
try {
|
||||
await fs.unlink(sourcePath);
|
||||
} catch (err: any) {
|
||||
await fs.unlink(targetPath).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,7 +203,7 @@ export class HostRouter {
|
||||
return false;
|
||||
}
|
||||
origin.connector.sendResponseError(
|
||||
new lib.ResponseError(msg || "Instance is not running."), message.src
|
||||
new lib.ResponseError(msg || "Instance is not running.", "UnavailableError"), message.src
|
||||
);
|
||||
}
|
||||
return true;
|
||||
@@ -234,13 +265,13 @@ export class HostRouter {
|
||||
let dst = message.dst;
|
||||
if (nextHop) {
|
||||
origin.connector.sendResponseError(
|
||||
new lib.ResponseError("Instance is already running."), message.src
|
||||
new lib.ResponseError("Instance is already running.", "ConflictError"), message.src
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (this.host._shuttingDown) {
|
||||
origin.connector.sendResponseError(
|
||||
new lib.ResponseError("Host is shutting down."), message.src
|
||||
new lib.ResponseError("Host is shutting down.", "UnavailableError"), message.src
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -249,9 +280,7 @@ export class HostRouter {
|
||||
instanceConnection.connector.forward(message);
|
||||
}).catch(err => {
|
||||
logger.error(`Error starting instance:\n${err.stack ?? err.message}`, this.host.instanceLogMeta(dst.id));
|
||||
origin.connector.sendResponseError(
|
||||
new lib.ResponseError(err.message, err.code, err.stack), message.src
|
||||
);
|
||||
origin.connector.sendResponseError(lib.Link.errorToResponse(err), message.src);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -264,9 +293,7 @@ export class HostRouter {
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (message.type === "request") {
|
||||
origin.connector.sendResponseError(
|
||||
new lib.ResponseError(err.message, err.code, err.stack), message.src
|
||||
);
|
||||
origin.connector.sendResponseError(lib.Link.errorToResponse(err), message.src);
|
||||
}
|
||||
logger.warn(`Failed to deliver ${(message as any).name || "message"} (${message.type}): ${err.message}`);
|
||||
if (err instanceof lib.InvalidMessage && err.errors) {
|
||||
@@ -322,6 +349,8 @@ export default class Host extends lib.Link {
|
||||
_startup = true;
|
||||
_disconnecting = false;
|
||||
_shuttingDown = false;
|
||||
/** Instances waiting on {@link Instance.fromConfig} */
|
||||
_startingInstances = new Set<number>();
|
||||
|
||||
startedAtMs = Date.now();
|
||||
|
||||
@@ -329,12 +358,18 @@ export default class Host extends lib.Link {
|
||||
"_warning": "Changes to this file will be overwritten by the controller's copy.",
|
||||
};
|
||||
|
||||
static async bootstrap(hostConfig: lib.HostConfig) {
|
||||
/** Create a host with its mod store loaded from disk */
|
||||
static async fromConfig(
|
||||
hostConfig: lib.HostConfig,
|
||||
pluginInfos: lib.PluginNodeEnvInfo[],
|
||||
connector: HostConnector,
|
||||
canRestart = false,
|
||||
recoveryMode = false,
|
||||
) {
|
||||
const modsDirectory = hostConfig.get("host.mods_directory");
|
||||
await fs.mkdir(modsDirectory, { recursive: true });
|
||||
return [
|
||||
await lib.ModStore.fromDirectory(modsDirectory),
|
||||
] as const;
|
||||
const modStore = await lib.ModStore.fromDirectory(modsDirectory);
|
||||
return new this(connector, hostConfig, pluginInfos, canRestart, recoveryMode, modStore);
|
||||
}
|
||||
|
||||
constructor(
|
||||
@@ -498,8 +533,8 @@ export default class Host extends lib.Link {
|
||||
}
|
||||
|
||||
let instancesDir = this.config.get("host.instances_directory");
|
||||
for (let i = 0; i < 10; i++) { // Limit attempts in case this is somehow an infinite loop
|
||||
let candidateDir = path.join(instancesDir, await lib.findUnusedName(instancesDir, name));
|
||||
for (let i = 1; ; i++) {
|
||||
let candidateDir = path.join(instancesDir, i === 1 ? name : `${name}-${i}`);
|
||||
try {
|
||||
await fs.mkdir(candidateDir);
|
||||
} catch (err: any) {
|
||||
@@ -510,7 +545,6 @@ export default class Host extends lib.Link {
|
||||
}
|
||||
return candidateDir;
|
||||
}
|
||||
throw Error("Unable to create instance dir, retry threshold reached");
|
||||
}
|
||||
|
||||
async broadcastEventToInstance<T>(event: lib.Event<T>) {
|
||||
@@ -528,12 +562,15 @@ export default class Host extends lib.Link {
|
||||
|
||||
async handleHostRestartRequest() {
|
||||
if (!this.canRestart) {
|
||||
throw new lib.RequestError("Cannot restart, host does not have a process monitor to restart it.");
|
||||
throw new lib.ConflictError("Cannot restart, host does not have a process monitor to restart it.");
|
||||
}
|
||||
if (await lib.packageOperationRunning()) {
|
||||
throw new lib.ConflictError("Cannot restart while a plugin install or update is running on this machine.");
|
||||
}
|
||||
const downgrade = await this.checkRestartDowngrade();
|
||||
if (downgrade) {
|
||||
const { installedVersion, runningVersion } = downgrade;
|
||||
throw new lib.RequestError(
|
||||
throw new lib.ConflictError(
|
||||
`Cannot restart host with installed Clusterio version ${installedVersion} because it is older than ` +
|
||||
`running version ${runningVersion}. Stop the host before starting the older version manually.`
|
||||
);
|
||||
@@ -554,7 +591,7 @@ export default class Host extends lib.Link {
|
||||
if (fieldName === "host.id") {
|
||||
// Changing host.id at runtime is not worth the effort to
|
||||
// support and will break a lot of things if it is allowed.
|
||||
throw new lib.RequestError("Setting 'host.id' while host is running is not supported");
|
||||
throw new lib.ConflictError("Setting 'host.id' while host is running is not supported");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -758,7 +795,7 @@ export default class Host extends lib.Link {
|
||||
this.send(
|
||||
new lib.InstanceStatusChangedEvent(
|
||||
instanceId,
|
||||
instanceConnection ? instanceConnection.instance.status : "stopped",
|
||||
instanceConnection ? instanceConnection.instance.status : this._unconnectedStatus(instanceId),
|
||||
instanceConnection ? instanceConnection.instance.server.gamePort : this.gamePort(instanceId),
|
||||
instanceConnection
|
||||
? instanceConnection.instance.server.version
|
||||
@@ -799,7 +836,7 @@ export default class Host extends lib.Link {
|
||||
getRequestInstance(instanceId: number) {
|
||||
let instance = this.assignedInstances.get(instanceId);
|
||||
if (!instance) {
|
||||
throw new lib.RequestError(`Instance with ID ${instanceId} does not exist`);
|
||||
throw new lib.NotFoundError(`Instance with ID ${instanceId} does not exist`);
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
@@ -845,7 +882,7 @@ export default class Host extends lib.Link {
|
||||
}
|
||||
if (!availablePorts.size) {
|
||||
const others = inUse.size ? ` (${inUse.size} in use by other processes)` : "";
|
||||
throw new lib.RequestError(`No available port left to assign from host.factorio_port_range${others}`);
|
||||
throw new lib.ConflictError(`No available port left to assign from host.factorio_port_range${others}`);
|
||||
}
|
||||
const [newPort] = availablePorts;
|
||||
instanceToAssign.config.set("factorio.host_assigned_game_port", newPort);
|
||||
@@ -874,6 +911,11 @@ export default class Host extends lib.Link {
|
||||
}
|
||||
}
|
||||
|
||||
/** Status of an instance without a connection */
|
||||
_unconnectedStatus(instanceId: number) {
|
||||
return this._startingInstances.has(instanceId) ? "starting" as const : "stopped" as const;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize and connect an unloaded instance
|
||||
*
|
||||
@@ -882,29 +924,36 @@ export default class Host extends lib.Link {
|
||||
*/
|
||||
async _connectInstance(instanceId: number) {
|
||||
let unloadedInstance = this.getRequestInstance(instanceId);
|
||||
if (this.instanceConnections.has(instanceId)) {
|
||||
throw new lib.RequestError(`Instance with ID ${instanceId} is running`);
|
||||
if (this.instanceConnections.has(instanceId) || this._startingInstances.has(instanceId)) {
|
||||
throw new lib.ConflictError(`Instance with ID ${instanceId} is running`);
|
||||
}
|
||||
|
||||
let hostAddress = new lib.Address(lib.Address.host, this.config.get("host.id"));
|
||||
let instanceAddress = new lib.Address(lib.Address.instance, instanceId);
|
||||
let [connectionClient, connectionServer] = lib.VirtualConnector.makePair(instanceAddress, hostAddress);
|
||||
let instance = new Instance(
|
||||
this, connectionClient,
|
||||
unloadedInstance.path, this.config.get("host.factorio_directory"),
|
||||
unloadedInstance.config
|
||||
);
|
||||
let instance: Instance;
|
||||
this._startingInstances.add(instanceId);
|
||||
try {
|
||||
instance = await Instance.fromConfig(
|
||||
unloadedInstance.config, this, connectionClient,
|
||||
unloadedInstance.path, this.config.get("host.factorio_directory"),
|
||||
);
|
||||
} finally {
|
||||
this._startingInstances.delete(instanceId);
|
||||
}
|
||||
let instanceConnection = new InstanceConnection(connectionServer, this, instance);
|
||||
|
||||
this.instanceConnections.set(instanceId, instanceConnection);
|
||||
await instance.init(this.pluginInfos);
|
||||
await instance.loadPlugins(this.pluginInfos);
|
||||
|
||||
return instanceConnection;
|
||||
}
|
||||
|
||||
async handleSystemInfoRequest() {
|
||||
const restartRequired = await this.checkRestartRequired();
|
||||
return lib.gatherSystemInfo(this.config.get("host.id"), this.canRestart, restartRequired, this.startedAtMs);
|
||||
return lib.gatherSystemInfo(
|
||||
this.config.get("host.id"), this.canRestart, restartRequired, this.startedAtMs, await this.listPlugins(),
|
||||
);
|
||||
}
|
||||
|
||||
async checkRestartRequired() {
|
||||
@@ -1025,19 +1074,16 @@ export default class Host extends lib.Link {
|
||||
checkRequestSaveName(newName);
|
||||
let instance = this.getRequestInstance(instanceId);
|
||||
try {
|
||||
// Link save in the new location.
|
||||
await fs.link(
|
||||
await moveSaveExclusive(
|
||||
path.join(instance.path, "saves", oldName),
|
||||
path.join(instance.path, "saves", newName),
|
||||
);
|
||||
// Unlink save in old location.
|
||||
await fs.unlink(path.join(instance.path, "saves", oldName));
|
||||
} catch (err: any) {
|
||||
if (err.code === "EEXIST") {
|
||||
throw new lib.RequestError(`${newName} already exists`);
|
||||
throw new lib.ConflictError(`${newName} already exists`);
|
||||
}
|
||||
if (err.code === "ENOENT") {
|
||||
throw new lib.RequestError(`${oldName} does not exist`);
|
||||
throw new lib.NotFoundError(`${oldName} does not exist`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
@@ -1057,13 +1103,32 @@ export default class Host extends lib.Link {
|
||||
);
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
throw new lib.RequestError(`${source} does not exist`);
|
||||
throw new lib.NotFoundError(`${source} does not exist`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
await this.sendSaveListUpdate(instanceId, path.join(instance.path, "saves"));
|
||||
}
|
||||
|
||||
async _moveOrCopySave(sourcePath: string, targetPath: string, copy: boolean) {
|
||||
const { dir, name, ext } = path.parse(targetPath);
|
||||
for (let i = 1; ; i++) {
|
||||
targetPath = path.format({ dir, name: i === 1 ? name : `${name}-${i}`, ext });
|
||||
try {
|
||||
if (copy) {
|
||||
await copySaveExclusive(sourcePath, targetPath);
|
||||
} else {
|
||||
await moveSaveExclusive(sourcePath, targetPath);
|
||||
}
|
||||
return path.basename(targetPath);
|
||||
} catch (err: any) {
|
||||
if (err.code !== "EEXIST") {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async handleInstanceTransferSaveRequest(request: lib.InstanceTransferSaveRequest) {
|
||||
let { sourceName, targetName, copy, sourceInstanceId, targetInstanceId } = request;
|
||||
checkRequestSaveName(sourceName);
|
||||
@@ -1071,31 +1136,27 @@ export default class Host extends lib.Link {
|
||||
let sourceInstance = this.getRequestInstance(sourceInstanceId);
|
||||
let targetInstance = this.getRequestInstance(targetInstanceId);
|
||||
|
||||
if (!targetName.endsWith(".zip")) {
|
||||
targetName += ".zip";
|
||||
}
|
||||
|
||||
// For consistency with remote transfer initiated through pullSave the
|
||||
// target is renamed if it already exists.
|
||||
targetName = await lib.findUnusedName(
|
||||
path.join(targetInstance.path, "saves"), targetName, ".zip"
|
||||
);
|
||||
|
||||
const sourcePath = path.join(sourceInstance.path, "saves", sourceName);
|
||||
try {
|
||||
if (copy) {
|
||||
await fs.copyFile(
|
||||
path.join(sourceInstance.path, "saves", sourceName),
|
||||
path.join(targetInstance.path, "saves", targetName),
|
||||
fs.constants.COPYFILE_FICLONE
|
||||
);
|
||||
} else {
|
||||
await fs.rename(
|
||||
path.join(sourceInstance.path, "saves", sourceName),
|
||||
path.join(targetInstance.path, "saves", targetName),
|
||||
);
|
||||
}
|
||||
targetName = await this._moveOrCopySave(
|
||||
sourcePath,
|
||||
path.join(targetInstance.path, "saves", targetName),
|
||||
copy,
|
||||
);
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
throw new lib.RequestError(`${sourceName} does not exist`);
|
||||
// ENOENT may also come from a missing target saves folder.
|
||||
if (err.code === "ENOENT" && !await fs.access(sourcePath).then(() => true, () => false)) {
|
||||
throw new lib.NotFoundError(`${sourceName} does not exist`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
await this.sendSaveListUpdate(sourceInstanceId, path.join(sourceInstance.path, "saves"));
|
||||
await this.sendSaveListUpdate(targetInstanceId, path.join(targetInstance.path, "saves"));
|
||||
|
||||
@@ -1123,7 +1184,7 @@ export default class Host extends lib.Link {
|
||||
await fs.unlink(path.join(instance.path, "saves", name));
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
throw new lib.RequestError(`${name} does not exist`);
|
||||
throw new lib.NotFoundError(`${name} does not exist`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
@@ -1154,7 +1215,7 @@ export default class Host extends lib.Link {
|
||||
content = (await fs.open(path.join(instance.path, "saves", name))).createReadStream();
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
throw new lib.RequestError(`${name} does not exist`);
|
||||
throw new lib.NotFoundError(`${name} does not exist`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
@@ -1173,12 +1234,12 @@ export default class Host extends lib.Link {
|
||||
async handleInstanceDeleteInternalRequest(request: lib.InstanceDeleteInternalRequest) {
|
||||
let instanceId = request.instanceId;
|
||||
if (this.instanceConnections.has(instanceId)) {
|
||||
throw new lib.RequestError(`Instance with ID ${instanceId} is running`);
|
||||
throw new lib.ConflictError(`Instance with ID ${instanceId} is running`);
|
||||
}
|
||||
|
||||
let instance = this.discoveredInstances.get(instanceId);
|
||||
if (!instance) {
|
||||
throw new lib.RequestError(`Instance with ID ${instanceId} does not exist`);
|
||||
throw new lib.NotFoundError(`Instance with ID ${instanceId} does not exist`);
|
||||
}
|
||||
|
||||
this.discoveredInstances.delete(instanceId);
|
||||
@@ -1188,45 +1249,52 @@ export default class Host extends lib.Link {
|
||||
|
||||
async handleHostUpdateRequest(request: lib.HostUpdateRequest) {
|
||||
if (!this.config.get("host.allow_remote_updates")) {
|
||||
throw new lib.RequestError("Remote updates are disabled on this machine");
|
||||
throw new lib.ForbiddenError("Remote updates are disabled on this machine");
|
||||
}
|
||||
return await lib.updatePackage("@clusterio/host");
|
||||
}
|
||||
|
||||
async handlePluginUpdateRequest(request: lib.PluginUpdateRequest) {
|
||||
if (!this.config.get("host.allow_plugin_updates")) {
|
||||
throw new lib.RequestError("Plugin updates are disabled on this machine");
|
||||
throw new lib.ForbiddenError("Plugin updates are disabled on this machine");
|
||||
}
|
||||
return await lib.handlePluginUpdate(request.pluginPackage, this.pluginInfos);
|
||||
}
|
||||
|
||||
async handlePluginInstallRequest(request: lib.PluginInstallRequest) {
|
||||
if (!this.config.get("host.allow_plugin_install")) {
|
||||
throw new lib.RequestError("Plugin installs are disabled on this machine");
|
||||
throw new lib.ForbiddenError("Plugin installs are disabled on this machine");
|
||||
}
|
||||
await lib.handlePluginInstall(request.pluginPackage);
|
||||
// A new plugin is not in pluginInfos, so checkRestartRequired cannot notice it
|
||||
if (!this.pluginInfos.some(pluginInfo => pluginInfo.npmPackage === request.pluginPackage)) {
|
||||
const npmPackage = await lib.handlePluginInstall(request.pluginPackage);
|
||||
// A new plugin is not in pluginInfos, so checkRestartRequired cannot notice it. Version
|
||||
// changes to loaded plugins are noticed there.
|
||||
if (!this.pluginInfos.some(pluginInfo => pluginInfo.npmPackage === npmPackage)) {
|
||||
this.config.restartRequired = true;
|
||||
}
|
||||
}
|
||||
|
||||
async handleUpdateAllRequest(request: lib.UpdateAllRequest) {
|
||||
if (!this.config.get("host.allow_remote_updates")) {
|
||||
throw new lib.RequestError("Remote updates are disabled on this machine");
|
||||
throw new lib.ForbiddenError("Remote updates are disabled on this machine");
|
||||
}
|
||||
if (!this.config.get("host.allow_plugin_updates")) {
|
||||
throw new lib.RequestError("Plugin updates are disabled on this machine");
|
||||
throw new lib.ForbiddenError("Plugin updates are disabled on this machine");
|
||||
}
|
||||
return await lib.handleUpdateAll("@clusterio/host", this.pluginInfos);
|
||||
}
|
||||
|
||||
async handlePluginListRequest(request: lib.PluginListRequest) {
|
||||
return this.pluginInfos.map(pluginInfo => lib.PluginDetails.fromNodeEnvInfo(
|
||||
pluginInfo,
|
||||
this.loadedPlugins.has(pluginInfo),
|
||||
this.config.get(`${pluginInfo.name}.load_plugin`),
|
||||
));
|
||||
return await this.listPlugins();
|
||||
}
|
||||
|
||||
/** Plugins on this host, including those installed or updated since it started */
|
||||
async listPlugins() {
|
||||
return await lib.listPluginDetails(
|
||||
this.pluginInfos,
|
||||
pluginInfo => this.loadedPlugins.has(pluginInfo),
|
||||
pluginInfo => this.config.get(`${pluginInfo.name}.load_plugin`),
|
||||
pluginInfo => Boolean(pluginInfo.hostEntrypoint || pluginInfo.instanceEntrypoint),
|
||||
);
|
||||
}
|
||||
|
||||
sendHostUpdate() {
|
||||
@@ -1253,7 +1321,7 @@ export default class Host extends lib.Link {
|
||||
let instanceConnection = this.instanceConnections.get(instanceId);
|
||||
list.push(new lib.HostInstanceUpdate(
|
||||
instance.config.toRemote("controller"),
|
||||
instanceConnection ? instanceConnection.instance.status : "stopped",
|
||||
instanceConnection ? instanceConnection.instance.status : this._unconnectedStatus(instanceId),
|
||||
instanceConnection ? instanceConnection.instance.server.gamePort : this.gamePort(instanceId),
|
||||
instanceConnection ? instanceConnection.instance.server.version : undefined,
|
||||
));
|
||||
|
||||
+119
-60
@@ -10,7 +10,7 @@ const execAsync = util.promisify(exec);
|
||||
// internal libraries
|
||||
import * as lib from "@clusterio/lib";
|
||||
|
||||
import { FactorioServer } from "./server.js";
|
||||
import { FactorioServer, checkForUpdates } from "./server.js";
|
||||
import { loadModules, patch } from "./patch.js";
|
||||
import { exportData } from "./export.js";
|
||||
import type Host from "./Host.js";
|
||||
@@ -21,7 +21,7 @@ function checkRequestSaveName(name: string) {
|
||||
try {
|
||||
lib.checkFilename(name);
|
||||
} catch (err: any) {
|
||||
throw new lib.RequestError(`Save name ${err.message}`);
|
||||
throw new lib.ValidationError(`Save name ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,13 @@ const scriptCommands = [
|
||||
"/silent-command", "/sc",
|
||||
];
|
||||
|
||||
function instanceLogger(config: lib.InstanceConfig) {
|
||||
return lib.logger.child({
|
||||
instance_id: config.get("instance.id"),
|
||||
instance_name: config.get("instance.name"),
|
||||
});
|
||||
}
|
||||
|
||||
// Statuses the instance passes through on its way to running or stopped.
|
||||
const transitoryStatuses: ReadonlySet<Instance["_status"]> = new Set([
|
||||
"starting", "stopping", "creating_save", "exporting_data",
|
||||
@@ -183,6 +190,7 @@ export default class Instance extends lib.Link {
|
||||
_loadedSave: string | null = null;
|
||||
_playerCheckInterval: ReturnType<typeof setInterval> | undefined;
|
||||
_hadPlayersOnline = false;
|
||||
_autoPause = false;
|
||||
_playerAutosaveSlot = 1;
|
||||
_expectedUserUpdates: { action: string, name: string, reason: string }[] = [];
|
||||
_settleWaiters: (() => void)[] = [];
|
||||
@@ -191,8 +199,8 @@ export default class Instance extends lib.Link {
|
||||
host: Host,
|
||||
connector: lib.VirtualConnector,
|
||||
dir: string,
|
||||
factorioDir: string,
|
||||
instanceConfig: lib.InstanceConfig
|
||||
instanceConfig: lib.InstanceConfig,
|
||||
server: FactorioServer,
|
||||
) {
|
||||
super(connector);
|
||||
this._host = host;
|
||||
@@ -202,10 +210,7 @@ export default class Instance extends lib.Link {
|
||||
|
||||
this.id = this.config.get("instance.id");
|
||||
|
||||
this.logger = lib.logger.child({
|
||||
instance_id: this.id,
|
||||
instance_name: this.name,
|
||||
});
|
||||
this.logger = instanceLogger(instanceConfig);
|
||||
|
||||
this.hooks = new InstanceHooks(this.logger);
|
||||
|
||||
@@ -225,28 +230,7 @@ export default class Instance extends lib.Link {
|
||||
};
|
||||
this.config.on("fieldChanged", this._configFieldChanged);
|
||||
|
||||
let serverOptions = {
|
||||
logger: this.logger,
|
||||
// TODO: factorio.version is not validated
|
||||
version: this.config.get("factorio.version") as lib.TargetVersion,
|
||||
executablePath: this.config.get("factorio.executable_path") ?? undefined,
|
||||
gamePort: this.config.get("factorio.game_port") ?? host.assignGamePort(this.id),
|
||||
rconPort: this.config.get("factorio.rcon_port") ?? undefined,
|
||||
rconPassword: this.config.get("factorio.rcon_password") ?? undefined,
|
||||
enableLuaUdp: this.config.get("factorio.enable_lua_udp"),
|
||||
luaUdpPort: this.config.get("factorio.lua_udp_port") ?? undefined,
|
||||
enableWhitelist: this.config.get("factorio.enable_whitelist"),
|
||||
enableAuthserverBans: this.config.get("factorio.enable_authserver_bans"),
|
||||
verboseLogging: this.config.get("factorio.verbose_logging"),
|
||||
consoleLogging: this.config.get("factorio.console_logging"),
|
||||
stripPaths: this.config.get("factorio.strip_paths"),
|
||||
maxConcurrentCommands: this.config.get("factorio.max_concurrent_commands"),
|
||||
shutdownTimeoutMs: this.config.get("factorio.shutdown_timeout") * 1000,
|
||||
};
|
||||
|
||||
this.server = new FactorioServer(
|
||||
factorioDir, this._dir, serverOptions
|
||||
);
|
||||
this.server = server;
|
||||
|
||||
this.server.on("output", (parsed, line) => {
|
||||
this.logger.log("server", { message: line, instance_id: this.id, parsed });
|
||||
@@ -405,10 +389,19 @@ export default class Instance extends lib.Link {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True while Factorio has paused the game because auto_pause is on
|
||||
* and no players are online.
|
||||
*/
|
||||
get autoPaused() {
|
||||
return this._autoPause && this.playersOnline.size === 0;
|
||||
}
|
||||
|
||||
_recordPlayerJoin(name: string) {
|
||||
if (this.playersOnline.has(name)) {
|
||||
return;
|
||||
}
|
||||
const wasPaused = this.autoPaused;
|
||||
this.playersOnline.add(name);
|
||||
|
||||
let stats = this.playerStats.get(name);
|
||||
@@ -428,6 +421,9 @@ export default class Instance extends lib.Link {
|
||||
stats,
|
||||
};
|
||||
this.sendTo("controller", new lib.InstancePlayerUpdateEvent("join", name, stats));
|
||||
if (wasPaused) {
|
||||
this.hooks.autoPauseChanged.invoke(false);
|
||||
}
|
||||
this.hooks.playerEvent.invoke(event);
|
||||
}
|
||||
|
||||
@@ -449,6 +445,9 @@ export default class Instance extends lib.Link {
|
||||
stats,
|
||||
};
|
||||
this.sendTo("controller", new lib.InstancePlayerUpdateEvent("leave", name, stats, reason));
|
||||
if (this.autoPaused) {
|
||||
this.hooks.autoPauseChanged.invoke(true);
|
||||
}
|
||||
this.hooks.playerEvent.invoke(event);
|
||||
}
|
||||
|
||||
@@ -645,6 +644,7 @@ end`.replace(/\r?\n/g, " ");
|
||||
|
||||
notifyExit() {
|
||||
this._loadedSave = null;
|
||||
this._autoPause = false;
|
||||
this.notifyStatus("stopped");
|
||||
this.connector.emit("close");
|
||||
|
||||
@@ -670,20 +670,22 @@ end`.replace(/\r?\n/g, " ");
|
||||
this._saveStats().catch(err => this.logger.error(`Error saving stats:\n${err.stack}`));
|
||||
}
|
||||
|
||||
async _loadStats() {
|
||||
static async _readStats(statsPath: string) {
|
||||
let instanceStats;
|
||||
try {
|
||||
instanceStats = JSON.parse(await fs.readFile(this.path("instance-stats.json"), "utf8"));
|
||||
instanceStats = JSON.parse(await fs.readFile(statsPath, "utf8"));
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
return;
|
||||
return { playerStats: new Map<string, lib.PlayerStats>(), playerAutosaveSlot: 1 };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
this.playerStats = new Map(instanceStats["players"].map(
|
||||
([id, stats]: [number, Static<typeof lib.PlayerStats.jsonSchema>]) => [id, new lib.PlayerStats(stats)])
|
||||
);
|
||||
this._playerAutosaveSlot = instanceStats["player_autosave_slot"] || 1;
|
||||
return {
|
||||
playerStats: new Map<string, lib.PlayerStats>(instanceStats["players"].map(
|
||||
([id, stats]: [string, Static<typeof lib.PlayerStats.jsonSchema>]) => [id, new lib.PlayerStats(stats)]
|
||||
)),
|
||||
playerAutosaveSlot: (instanceStats["player_autosave_slot"] as number) || 1,
|
||||
};
|
||||
}
|
||||
|
||||
async _saveStats() {
|
||||
@@ -694,21 +696,35 @@ end`.replace(/\r?\n/g, " ");
|
||||
await lib.safeOutputFile(this.path("instance-stats.json"), content);
|
||||
}
|
||||
|
||||
async init(pluginInfos: lib.PluginNodeEnvInfo[]) {
|
||||
// After changing the status this function is responsible for calling notifyExit if it throws.
|
||||
this.notifyStatus("starting");
|
||||
/**
|
||||
* Create an instance with a Factorio server, downloading Factorio if needed
|
||||
*
|
||||
* The host reports the starting status for the instance while this runs.
|
||||
*/
|
||||
static async fromConfig(
|
||||
config: lib.InstanceConfig,
|
||||
host: Host,
|
||||
connector: lib.VirtualConnector,
|
||||
dir: string,
|
||||
factorioDir: string,
|
||||
) {
|
||||
const id = config.get("instance.id");
|
||||
// TODO: factorio.version is not validated
|
||||
const targetVersion = config.get("factorio.version") as lib.TargetVersion;
|
||||
const gamePort = config.get("factorio.game_port") ?? await host.assignFreeGamePort(id);
|
||||
host.send(new lib.InstanceStatusChangedEvent(id, "starting", gamePort, targetVersion));
|
||||
|
||||
let server: FactorioServer;
|
||||
let stats: Awaited<ReturnType<typeof Instance._readStats>>;
|
||||
try {
|
||||
if (this.config.get("factorio.game_port") === null) {
|
||||
this.server.gamePort = await this._host.assignFreeGamePort(this.id);
|
||||
}
|
||||
const factorioVersions = await this.sendTo("controller", new lib.FactorioVersionsRequest());
|
||||
await this._loadStats();
|
||||
const factorioVersions = await host.sendTo("controller", new lib.FactorioVersionsRequest());
|
||||
stats = await Instance._readStats(path.join(dir, "instance-stats.json"));
|
||||
// Resolve a release channel target (e.g. "stable") to a concrete
|
||||
// version using the controller's cached latest-releases data, so the
|
||||
// existing download/lookup logic can fetch and run it.
|
||||
const targetVersion = this.config.get("factorio.version") as string;
|
||||
let version = targetVersion;
|
||||
if (lib.isReleaseChannel(targetVersion)) {
|
||||
const releases = await this.sendTo("controller", new lib.LatestReleasesRequest());
|
||||
const releases = await host.sendTo("controller", new lib.LatestReleasesRequest());
|
||||
const resolved = lib.resolveReleaseChannel(releases, targetVersion);
|
||||
if (!resolved) {
|
||||
throw new Error(
|
||||
@@ -716,16 +732,46 @@ end`.replace(/\r?\n/g, " ");
|
||||
"latest-releases unavailable and no cached copy on the controller."
|
||||
);
|
||||
}
|
||||
this.server.setTargetVersion(resolved);
|
||||
version = resolved;
|
||||
}
|
||||
await this.server.checkForUpdates(factorioVersions);
|
||||
await this.server.init();
|
||||
const logger = instanceLogger(config);
|
||||
await checkForUpdates(factorioDir, version, factorioVersions, logger);
|
||||
server = await FactorioServer.fromOptions(factorioDir, dir, {
|
||||
logger,
|
||||
version,
|
||||
executablePath: config.get("factorio.executable_path") ?? undefined,
|
||||
gamePort,
|
||||
rconPort: config.get("factorio.rcon_port") ?? undefined,
|
||||
rconPassword: config.get("factorio.rcon_password") ?? undefined,
|
||||
enableLuaUdp: config.get("factorio.enable_lua_udp"),
|
||||
luaUdpPort: config.get("factorio.lua_udp_port") ?? undefined,
|
||||
enableWhitelist: config.get("factorio.enable_whitelist"),
|
||||
enableAuthserverBans: config.get("factorio.enable_authserver_bans"),
|
||||
verboseLogging: config.get("factorio.verbose_logging"),
|
||||
consoleLogging: config.get("factorio.console_logging"),
|
||||
stripPaths: config.get("factorio.strip_paths"),
|
||||
maxConcurrentCommands: config.get("factorio.max_concurrent_commands"),
|
||||
shutdownTimeoutMs: config.get("factorio.shutdown_timeout") * 1000,
|
||||
});
|
||||
} catch (err) {
|
||||
this.notifyExit();
|
||||
await this.sendSaveListUpdate();
|
||||
host.send(new lib.InstanceStatusChangedEvent(id, "stopped", gamePort, targetVersion));
|
||||
await host.sendSaveListUpdate(id, path.join(dir, "saves"));
|
||||
throw err;
|
||||
}
|
||||
|
||||
const instance = new this(host, connector, dir, config, server);
|
||||
instance._status = "starting";
|
||||
instance.playerStats = stats.playerStats;
|
||||
instance._playerAutosaveSlot = stats.playerAutosaveSlot;
|
||||
return instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the instance plugins and report the instance as initialised
|
||||
*
|
||||
* Must be called once the instance is connected to the host.
|
||||
*/
|
||||
async loadPlugins(pluginInfos: lib.PluginNodeEnvInfo[]) {
|
||||
// load plugins
|
||||
const plugins: Record<string, string> = {};
|
||||
for (let pluginInfo of pluginInfos) {
|
||||
@@ -828,6 +874,7 @@ end`.replace(/\r?\n/g, " ");
|
||||
this.server.writePath("server-settings.json"),
|
||||
JSON.stringify(serverSettings, null, "\t")
|
||||
);
|
||||
return serverSettings;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -923,12 +970,12 @@ end`.replace(/\r?\n/g, " ");
|
||||
* Factorio refuses to read it if it is newer than the game. It then
|
||||
* loads default settings instead, which can corrupt the save.
|
||||
*
|
||||
* @throws {lib.RequestError} if the mod pack version is higher than the server version.
|
||||
* @throws {lib.ConflictError} if the mod pack version is higher than the server version.
|
||||
*/
|
||||
checkModPackVersion(modPack: lib.ModPack) {
|
||||
const serverVersion = this.server.version;
|
||||
if (serverVersion && modPack.integerFactorioVersion > lib.integerFullVersion(serverVersion)) {
|
||||
throw new lib.RequestError(
|
||||
throw new lib.ConflictError(
|
||||
`Mod pack ${modPack.name} is for Factorio ${modPack.factorioVersion} ` +
|
||||
`which is newer than the Factorio ${serverVersion} this instance runs`
|
||||
);
|
||||
@@ -942,7 +989,9 @@ end`.replace(/\r?\n/g, " ");
|
||||
*/
|
||||
async prepare() {
|
||||
this.logger.verbose("Writing server-settings.json");
|
||||
await this.writeServerSettings(true);
|
||||
// auto_pause is only read on startup
|
||||
const serverSettings = await this.writeServerSettings(true);
|
||||
this._autoPause = serverSettings.auto_pause === true;
|
||||
this._expectedUserUpdates = [];
|
||||
|
||||
if (this.config.get("factorio.sync_adminlist") !== "disabled") {
|
||||
@@ -1259,6 +1308,16 @@ end`.replace(/\r?\n/g, " ");
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop the instance once a start, stop or save in progress has finished */
|
||||
async _stopWhenSettled() {
|
||||
// Stopping concurrently with these races them and asserts on the server state.
|
||||
// Checked right before stop() as another waiter may have started stopping.
|
||||
while (transitoryStatuses.has(this._status)) {
|
||||
await this._waitForSettledStatus();
|
||||
}
|
||||
await this.stop();
|
||||
}
|
||||
|
||||
async kill() {
|
||||
if (this._status === "stopped") {
|
||||
return;
|
||||
@@ -1331,7 +1390,7 @@ end`.replace(/\r?\n/g, " ");
|
||||
async handleInstanceLoadScenarioRequest(request: lib.InstanceLoadScenarioRequest) {
|
||||
if (this.config.get("factorio.enable_save_patching")) {
|
||||
this.notifyExit();
|
||||
throw new lib.RequestError("Load scenario cannot be used with save patching enabled");
|
||||
throw new lib.ConflictError("Load scenario cannot be used with save patching enabled");
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -1393,7 +1452,7 @@ end`.replace(/\r?\n/g, " ");
|
||||
|
||||
async handleInstanceSaveGameRequest() {
|
||||
if (this._status !== "running") {
|
||||
throw new lib.RequestError("Instance is not running");
|
||||
throw new lib.UnavailableError("Instance is not running");
|
||||
}
|
||||
|
||||
const saved = new Promise<void>((resolve, reject) => {
|
||||
@@ -1403,7 +1462,7 @@ end`.replace(/\r?\n/g, " ");
|
||||
};
|
||||
const onExit = () => {
|
||||
this.server.off("save-finished", onSaved);
|
||||
reject(new lib.RequestError("Instance stopped before the save finished"));
|
||||
reject(new lib.UnavailableError("Instance stopped before the save finished"));
|
||||
};
|
||||
this.server.once("save-finished", onSaved);
|
||||
this.server.once("exit", onExit);
|
||||
@@ -1445,7 +1504,7 @@ end`.replace(/\r?\n/g, " ");
|
||||
}
|
||||
|
||||
async handleInstanceRestartRequest(request: lib.InstanceRestartRequest) {
|
||||
await this.stop();
|
||||
await this._stopWhenSettled();
|
||||
// Stopping invalidates this instance object, a new one must be created by the host
|
||||
// To do this we simulate a waking an instance and sending a start request
|
||||
const instanceConnection = await this._host._connectInstance(this.id);
|
||||
@@ -1453,7 +1512,7 @@ end`.replace(/\r?\n/g, " ");
|
||||
}
|
||||
|
||||
async handleInstanceStopRequest() {
|
||||
await this.stop();
|
||||
await this._stopWhenSettled();
|
||||
}
|
||||
|
||||
async handleInstanceKillRequest() {
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
// Export of item icons and locale
|
||||
import fs from "node:fs/promises";
|
||||
import path from "path";
|
||||
import { Jimp, JimpMime, type JimpInstance } from "jimp";
|
||||
import { createJimp } from "@jimp/core";
|
||||
import png from "@jimp/js-png";
|
||||
import * as crop from "@jimp/plugin-crop";
|
||||
import * as resize from "@jimp/plugin-resize";
|
||||
import type {} from "@jimp/types"; // needed to name jimp types in export.d.ts
|
||||
import yazl from "yazl";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
@@ -147,7 +151,9 @@ async function loadFile(
|
||||
return await modZip.zip.readFile(path.posix.join(modZip.root, filePath));
|
||||
}
|
||||
|
||||
type Image = JimpInstance;
|
||||
// Only the parts of jimp the export uses
|
||||
const Jimp = createJimp({ formats: [png], plugins: [crop.methods, resize.methods] });
|
||||
type Image = InstanceType<typeof Jimp>;
|
||||
type IconCache = Map<string, Image | null>;
|
||||
async function loadIcon(
|
||||
server: FactorioServer,
|
||||
@@ -618,7 +624,7 @@ async function buildExport(server: FactorioServer, zipCache: ZipCache) {
|
||||
// Crop sheet to actual used height and write single spritesheet + metadata.
|
||||
const usedRows = Math.ceil(state.pos / (SHEET_WIDTH / SHEET_ICON_SIZE));
|
||||
state.sheet.crop({ x: 0, y: 0, w: SHEET_WIDTH, h: Math.max(usedRows * SHEET_ICON_SIZE, 1) });
|
||||
zip.addBuffer(await state.sheet.getBuffer(JimpMime.png), "export/spritesheet.png");
|
||||
zip.addBuffer(await state.sheet.getBuffer("image/png"), "export/spritesheet.png");
|
||||
addJson("export/metadata.json", state.metadata satisfies lib.ExportMetadata);
|
||||
zip.end();
|
||||
|
||||
|
||||
+98
-94
@@ -185,6 +185,73 @@ async function downloadAndExtractTar(url: string, targetDir: string) {
|
||||
// Factorio downloads in progress by target directory, shared by every server in the process
|
||||
const downloadsInProgress = new Map<string, Promise<void>>();
|
||||
|
||||
/**
|
||||
* Download the newest Factorio version matching the target if not installed
|
||||
*
|
||||
* Call before {@link FactorioServer.fromOptions} for it to find the new version.
|
||||
*
|
||||
* @param factorioDir - Path to Factorio installation dirs.
|
||||
* @param targetVersion - Version to look for, or "latest".
|
||||
* @param availableVersions - Versions available for download.
|
||||
* @param logger - Logger to report the download on.
|
||||
*/
|
||||
export async function checkForUpdates(
|
||||
factorioDir: string,
|
||||
targetVersion: lib.TargetVersion,
|
||||
availableVersions: lib.ExternalFactorioVersion[],
|
||||
logger: lib.Logger = lib.logger,
|
||||
) {
|
||||
|
||||
const latestVersion = availableVersions
|
||||
.filter(v => (
|
||||
targetVersion === "latest" || v.version === targetVersion || v.version.startsWith(targetVersion)
|
||||
))
|
||||
.sort((v1, v2) => versionOrder(v1.version, v2.version))
|
||||
[0];
|
||||
|
||||
if (!latestVersion) {
|
||||
return;
|
||||
}
|
||||
|
||||
// A version that is still being extracted can already look installed
|
||||
const dst = path.join(factorioDir, latestVersion.version);
|
||||
const installedVersions = await listFactorioVersions(factorioDir);
|
||||
if (!installedVersions.versions.has(latestVersion.version) || downloadsInProgress.has(dst)) {
|
||||
const v = latestVersion.version;
|
||||
// Can't download if not linux or this is a direct install
|
||||
if (installedVersions.direct) {
|
||||
logger.info(
|
||||
`A newer version of factorio is available (${v}) but cannot be downloaded automatically ` +
|
||||
`because ${factorioDir} is a direct (single-version) Factorio install. Use a versioned ` +
|
||||
`layout, where ${factorioDir} contains a subdirectory per version, to enable automatic ` +
|
||||
"downloads."
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (process.platform !== "linux") {
|
||||
logger.info(
|
||||
`A newer version of factorio is available (${v}) but must be manually downloaded ` +
|
||||
"(automatic downloads are only supported on Linux)."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Download the newer version, or wait for another server already downloading it
|
||||
let download = downloadsInProgress.get(dst);
|
||||
if (download) {
|
||||
logger.info(`A newer version of factorio is available (${v}), waiting for download...`);
|
||||
} else {
|
||||
logger.info(`A newer version of factorio is available (${v}), starting download...`);
|
||||
download = downloadAndExtractTar(latestVersion.headlessUrl, dst).finally(() => {
|
||||
downloadsInProgress.delete(dst);
|
||||
});
|
||||
downloadsInProgress.set(dst, download);
|
||||
}
|
||||
await download;
|
||||
logger.info(`Downloaded factorio version ${v}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find factorio data directory of the given version
|
||||
*
|
||||
@@ -670,9 +737,8 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
|
||||
_factorioDir: string;
|
||||
_writeDir: string;
|
||||
|
||||
// Resolved in init
|
||||
_version: lib.FullVersion | null = null;
|
||||
_dataDir: string | null = null;
|
||||
_version: lib.FullVersion;
|
||||
_dataDir: string;
|
||||
|
||||
// Due to inconsistencies in the factorio api, we must manually watch the whitelist
|
||||
// https://forums.factorio.com/viewtopic.php?t=123673
|
||||
@@ -680,8 +746,7 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
|
||||
_whitelist = new Set<string>();
|
||||
|
||||
_logger: lib.Logger;
|
||||
_targetVersion: lib.TargetVersion;
|
||||
_state: "new" | "init" | "create" | "running" | "stopping" = "new";
|
||||
_state: "init" | "create" | "running" | "stopping" = "init";
|
||||
_server: child_process.ChildProcessWithoutNullStreams | null = null;
|
||||
_rconClient: Rcon | null= null;
|
||||
_rconReady = false;
|
||||
@@ -697,27 +762,36 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
|
||||
|
||||
|
||||
/**
|
||||
* Create a Factorio server interface
|
||||
* Create a Factorio server interface for the version found by {@link fromOptions}
|
||||
*
|
||||
* @param factorioDir - Directory of the Factorio install(s).
|
||||
* @param writeDir - Directory to write runtime data to.
|
||||
* @param options - Optional parameters.
|
||||
* @param dataDir - Data directory of the Factorio version to run.
|
||||
* @param version - Factorio version to run.
|
||||
* @param options - Optional parameters, `version` is ignored.
|
||||
*/
|
||||
constructor(factorioDir: string, writeDir: string, options: FactorioServerOptions) {
|
||||
constructor(
|
||||
factorioDir: string,
|
||||
writeDir: string,
|
||||
dataDir: string,
|
||||
version: lib.FullVersion,
|
||||
options: FactorioServerOptions & { rconPassword: string },
|
||||
) {
|
||||
super();
|
||||
|
||||
this._factorioDir = factorioDir;
|
||||
this._writeDir = writeDir;
|
||||
this._dataDir = dataDir;
|
||||
this._version = version;
|
||||
|
||||
this._logger = options.logger || lib.logger;
|
||||
this._targetVersion = options.version || "latest";
|
||||
this.executablePath = options.executablePath;
|
||||
/** UDP port used for hosting the Factorio game server on */
|
||||
this.gamePort = options.gamePort || randomDynamicPort();
|
||||
/** TCP port used for RCON on the Factorio game server */
|
||||
this.rconPort = options.rconPort || randomDynamicPort();
|
||||
/** Password used for RCON on the Factorio game server */
|
||||
this.rconPassword = options.rconPassword as string; // init will generate one if not there
|
||||
this.rconPassword = options.rconPassword;
|
||||
/** Enable UDP messaging with the Factorio game server */
|
||||
this.enableLuaUdp = options.enableLuaUdp || false;
|
||||
/** UDP port the Factorio game server receives Lua UDP messages on */
|
||||
@@ -886,7 +960,7 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
|
||||
|
||||
// Factorio before 2.1.10 crashes when a packet is received on a headless server
|
||||
_checkLuaUdpVersion() {
|
||||
if (this.enableLuaUdp && lib.integerFullVersion(this._version!) < lib.integerFullVersion("2.1.10")) {
|
||||
if (this.enableLuaUdp && lib.integerFullVersion(this._version) < lib.integerFullVersion("2.1.10")) {
|
||||
throw new Error("Lua UDP requires Factorio 2.1.10 or later");
|
||||
}
|
||||
}
|
||||
@@ -1075,94 +1149,25 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Override the target version of Factorio to run
|
||||
* Create a Factorio server interface for an installed version
|
||||
*
|
||||
* Used to substitute a concrete version for a release channel target
|
||||
* (e.g. "stable" or "experimental") before {@link checkForUpdates} and
|
||||
* {@link init} run. Must be called before the server is initialized.
|
||||
*/
|
||||
setTargetVersion(version: lib.TargetVersion) {
|
||||
this._check(["new"]);
|
||||
this._targetVersion = version;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize class instance
|
||||
*
|
||||
* Must be called before instances of this class can be used.
|
||||
* @param factorioDir - Directory of the Factorio install(s).
|
||||
* @param writeDir - Directory to write runtime data to.
|
||||
* @param options - Optional parameters.
|
||||
* @throws {Error} if the requested version of Factorio was not found.
|
||||
*/
|
||||
async init() {
|
||||
this._check(["new"]);
|
||||
[this._dataDir, this._version] = await findVersion(this._factorioDir, this._targetVersion);
|
||||
this.rconPassword = this.rconPassword as string | undefined ?? await generatePassword(10);
|
||||
this._state = "init";
|
||||
}
|
||||
|
||||
/**
|
||||
* Check of updated versions to download
|
||||
*
|
||||
* Should be called before init for the new version to be detected during init
|
||||
*/
|
||||
async checkForUpdates(availableVersions: lib.ExternalFactorioVersion[]) {
|
||||
const targetVersion = this._targetVersion;
|
||||
const latestVersion = availableVersions
|
||||
.filter(v => (
|
||||
targetVersion === "latest" || v.version === targetVersion || v.version.startsWith(targetVersion)
|
||||
))
|
||||
.sort((v1, v2) => versionOrder(v1.version, v2.version))
|
||||
[0];
|
||||
|
||||
if (!latestVersion) {
|
||||
return;
|
||||
}
|
||||
|
||||
// A version that is still being extracted can already look installed
|
||||
const dst = path.join(this._factorioDir, latestVersion.version);
|
||||
const installedVersions = await listFactorioVersions(this._factorioDir);
|
||||
if (!installedVersions.versions.has(latestVersion.version) || downloadsInProgress.has(dst)) {
|
||||
const v = latestVersion.version;
|
||||
// Can't download if not linux or this is a direct install
|
||||
if (installedVersions.direct) {
|
||||
this._logger.info(
|
||||
`A newer version of factorio is available (${v}) but cannot be downloaded automatically ` +
|
||||
`because ${this._factorioDir} is a direct (single-version) Factorio install. Use a versioned ` +
|
||||
`layout, where ${this._factorioDir} contains a subdirectory per version, to enable automatic ` +
|
||||
"downloads."
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (process.platform !== "linux") {
|
||||
this._logger.info(
|
||||
`A newer version of factorio is available (${v}) but must be manually downloaded ` +
|
||||
"(automatic downloads are only supported on Linux)."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Download the newer version, or wait for another server already downloading it
|
||||
let download = downloadsInProgress.get(dst);
|
||||
if (download) {
|
||||
this._logger.info(`A newer version of factorio is available (${v}), waiting for download...`);
|
||||
} else {
|
||||
this._logger.info(`A newer version of factorio is available (${v}), starting download...`);
|
||||
download = downloadAndExtractTar(latestVersion.headlessUrl, dst).finally(() => {
|
||||
downloadsInProgress.delete(dst);
|
||||
});
|
||||
downloadsInProgress.set(dst, download);
|
||||
}
|
||||
await download;
|
||||
this._logger.info(`Downloaded factorio version ${v}`);
|
||||
}
|
||||
static async fromOptions(factorioDir: string, writeDir: string, options: FactorioServerOptions) {
|
||||
const [dataDir, version] = await findVersion(factorioDir, options.version || "latest");
|
||||
const rconPassword = options.rconPassword ?? await generatePassword(10);
|
||||
return new this(factorioDir, writeDir, dataDir, version, { ...options, rconPassword });
|
||||
}
|
||||
|
||||
/**
|
||||
* The version of Factorio in use. This will be the actual version if
|
||||
* "latest" (the default) was specified as `factorioVersion` to the constructor.
|
||||
* This will be undefined before the server is initialized, or if init failed.
|
||||
* "latest" (the default) was passed to {@link fromOptions}.
|
||||
*/
|
||||
get version(): lib.FullVersion | undefined {
|
||||
return this._version ?? undefined;
|
||||
get version(): lib.FullVersion {
|
||||
return this._version;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1681,14 +1686,13 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
|
||||
* Return path in data directory
|
||||
*
|
||||
* Creates a path using path.join with the given parts that's relative to
|
||||
* the data directory of the Factorio server. Not valid before init has
|
||||
* been called.
|
||||
* the data directory of the Factorio server.
|
||||
*
|
||||
* @param parts - Extra parts to add to the data path.
|
||||
* @returns Data directory path.
|
||||
*/
|
||||
dataPath(...parts: string[]) {
|
||||
return path.join(this._dataDir!, ...parts);
|
||||
return path.join(this._dataDir, ...parts);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
// This needs to be a separate file due to ECMAScript Modules evaluating code in
|
||||
// imported modules before any code in the file that imports it.
|
||||
(global as any).lazySchemaCompilation = true;
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"entryPoints": ["index.ts"],
|
||||
"includeVersion": true
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import "./src/browser_polyfills";
|
||||
export * from "./src/api";
|
||||
export * from "./src/AsyncHook";
|
||||
export * from "./src/SyncHook";
|
||||
|
||||
Vendored
+1
-1
@@ -6,5 +6,5 @@ export interface BuildArgs {
|
||||
build?: boolean;
|
||||
outputDir: string;
|
||||
sourceDir: string;
|
||||
};
|
||||
}
|
||||
export function build(args: BuildArgs): Promise<void>;
|
||||
@@ -35,6 +35,7 @@ export * from "./src/subscriptions.js";
|
||||
export * from "./src/datastore.js";
|
||||
export * from "./src/load_plugin_list.js";
|
||||
export * from "./src/rce_ops.js";
|
||||
export * from "./src/npm_registry.js";
|
||||
export * from "./src/LockFile.js";
|
||||
export * from "./src/AsyncHook.js";
|
||||
export * from "./src/SyncHook.js";
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
"compile_validator": "node scripts/compile_validator.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
"node": "^22.18.0 || >=24.2.0"
|
||||
},
|
||||
|
||||
"dependencies": {
|
||||
|
||||
+144
-31
@@ -1,5 +1,5 @@
|
||||
import { FileHandle, open, unlink, readFile } from "node:fs/promises";
|
||||
import { unlinkSync } from "node:fs";
|
||||
import fs, { type FileHandle } from "node:fs/promises";
|
||||
import { readFileSync, unlinkSync } from "node:fs";
|
||||
import assert from "assert";
|
||||
|
||||
/**
|
||||
@@ -24,23 +24,99 @@ export class LockFileBusyError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when a process exited while clearing a stale lock, which needs the recovery file removed by hand.
|
||||
*/
|
||||
export class LockFileRecoveryError extends Error {
|
||||
constructor(
|
||||
readonly recoveryPath: string
|
||||
) {
|
||||
super(`Lock file recovery was interrupted, remove ${recoveryPath} to allow the lock to be acquired`);
|
||||
this.name = "LockFileRecoveryError";
|
||||
}
|
||||
}
|
||||
|
||||
type LockState = "idle" | "acquiring" | "acquired" | "releasing";
|
||||
type LockSnapshot = { content: string, ino: bigint, mtimeNs: bigint };
|
||||
|
||||
/**
|
||||
* Cross-platform lock file utility for Node.js.
|
||||
*/
|
||||
export class LockFile {
|
||||
/**
|
||||
* Content written to the lock file. The start time tells this process apart from an earlier one with
|
||||
* the same pid, and is the same for every copy of lib loaded in this process.
|
||||
*/
|
||||
private static readonly content = `${process.pid} ${performance.timeOrigin}\n`;
|
||||
// A lock file without a valid pid is left alone for this long, as it may still be being written
|
||||
private static readonly writeGraceMs = 10e3;
|
||||
|
||||
private handle: FileHandle | null = null;
|
||||
private state: LockState = "idle";
|
||||
|
||||
/**
|
||||
* @param filePath - Path of the lock file
|
||||
* @param handleSignals -
|
||||
* Release the lock on SIGINT, SIGTERM, SIGHUP and uncaught exceptions. Listening to these
|
||||
* changes how the process exits, so without it the lock is only removed on exit.
|
||||
*/
|
||||
constructor(
|
||||
private readonly filePath: string
|
||||
private readonly filePath: string,
|
||||
private readonly handleSignals = true,
|
||||
) {}
|
||||
|
||||
/** Content and identity of a lock file, read through one handle so both belong to the same file */
|
||||
private static async readLock(filePath: string): Promise<LockSnapshot | undefined> {
|
||||
let handle;
|
||||
try {
|
||||
handle = await fs.open(filePath, "r");
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
return undefined;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
try {
|
||||
const stat = await handle.stat({ bigint: true });
|
||||
return { content: await handle.readFile("utf8"), ino: stat.ino, mtimeNs: stat.mtimeNs };
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
private static isSameLock(a: LockSnapshot, b: LockSnapshot) {
|
||||
return a.content === b.content && a.ino === b.ino && a.mtimeNs === b.mtimeNs;
|
||||
}
|
||||
|
||||
private static lockPid(lock: LockSnapshot) {
|
||||
const pid = Number(lock.content.split(" ")[0]);
|
||||
return Number.isInteger(pid) && pid > 0 ? pid : undefined;
|
||||
}
|
||||
|
||||
/** True if the lock names a process that is still running, or may still be written to */
|
||||
private static lockIsLive(lock: LockSnapshot) {
|
||||
const pid = LockFile.lockPid(lock);
|
||||
if (pid === undefined) {
|
||||
return Date.now() - Number(lock.mtimeNs / 1000000n) < LockFile.writeGraceMs;
|
||||
}
|
||||
if (pid === process.pid) {
|
||||
// Held by this process, or left by an earlier process with the same pid
|
||||
return lock.content === LockFile.content;
|
||||
}
|
||||
try {
|
||||
// Signal 0 only checks that the process exists
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
return err.code !== "ESRCH";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquire the lock file.
|
||||
* Throws LockFileExistsError if already locked.
|
||||
* Throws LockFileBusyError if not in idle state.
|
||||
* Throws LockFileRecoveryError if clearing a stale lock was interrupted.
|
||||
*/
|
||||
async acquire(): Promise<void> {
|
||||
if (this.state !== "idle") {
|
||||
@@ -49,13 +125,10 @@ export class LockFile {
|
||||
|
||||
this.state = "acquiring";
|
||||
|
||||
if (await this.isStale()) {
|
||||
await unlink(this.filePath).catch(() => {}); // ignore errors, may already be removed
|
||||
}
|
||||
|
||||
try {
|
||||
this.handle = await open(this.filePath, "wx");
|
||||
await this.handle.write(`${process.pid}\n`);
|
||||
await this.removeStale();
|
||||
this.handle = await fs.open(this.filePath, "wx");
|
||||
await this.handle.write(LockFile.content);
|
||||
this.attachExitHandlers();
|
||||
this.state = "acquired";
|
||||
} catch (err: any) {
|
||||
@@ -81,7 +154,9 @@ export class LockFile {
|
||||
|
||||
// Ignored: file may have already been removed by another process
|
||||
try { await this.handle?.close(); } catch {}
|
||||
try { await unlink(this.filePath); } catch {}
|
||||
if (await this.isHeldFile()) {
|
||||
await fs.unlink(this.filePath).catch(() => {});
|
||||
}
|
||||
|
||||
this.handle = null;
|
||||
this.state = "idle";
|
||||
@@ -89,19 +164,20 @@ export class LockFile {
|
||||
|
||||
/**
|
||||
* Check whether the lock is currently held by this process.
|
||||
* True only if the lock is acquired and the PID in the file
|
||||
* matches the current process.
|
||||
* True only if the lock is acquired and the lock file was written by this process.
|
||||
*/
|
||||
async isHeld(): Promise<boolean> {
|
||||
if (this.state !== "acquired") {
|
||||
return false;
|
||||
}
|
||||
return await this.isHeldFile();
|
||||
}
|
||||
|
||||
private async isHeldFile() {
|
||||
try {
|
||||
const pidInLock = Number(await readFile(this.filePath, "utf8"));
|
||||
return pidInLock === process.pid;
|
||||
return (await LockFile.readLock(this.filePath))?.content === LockFile.content;
|
||||
} catch {
|
||||
// Ignored: file may not exist or be unreadable, so lock is not held
|
||||
// Ignored: file may be unreadable, so lock is not held
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -110,14 +186,45 @@ export class LockFile {
|
||||
* Determine whether an existing lock is stale.
|
||||
*/
|
||||
async isStale(): Promise<boolean> {
|
||||
const lock = await LockFile.readLock(this.filePath);
|
||||
return lock === undefined || !LockFile.lockIsLive(lock);
|
||||
}
|
||||
|
||||
/** Pid of the process holding the lock, if it has written it */
|
||||
async holderPid(): Promise<number | undefined> {
|
||||
const lock = await LockFile.readLock(this.filePath);
|
||||
return lock && LockFile.lockPid(lock);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a stale lock file if it is still the same file. Clearing is done while holding a separate
|
||||
* recovery lock, so a lock another process took after the stale check is never removed.
|
||||
*/
|
||||
private async removeStale(): Promise<void> {
|
||||
const stale = await LockFile.readLock(this.filePath);
|
||||
if (!stale || LockFile.lockIsLive(stale)) {
|
||||
return;
|
||||
}
|
||||
const recoveryPath = `${this.filePath}.recovery`;
|
||||
try {
|
||||
const pidInLock = Number(await readFile(this.filePath, "utf8"));
|
||||
if (!Number.isFinite(pidInLock)) { return true; }
|
||||
// signal 0 is a special case to test if a process exists
|
||||
process.kill(pidInLock, 0);
|
||||
return false;
|
||||
} catch {
|
||||
return true;
|
||||
await fs.writeFile(recoveryPath, LockFile.content, { flag: "wx" });
|
||||
} catch (err: any) {
|
||||
if (err.code !== "EEXIST") {
|
||||
throw err;
|
||||
}
|
||||
const holder = await LockFile.readLock(recoveryPath);
|
||||
if (holder && !LockFile.lockIsLive(holder)) {
|
||||
throw new LockFileRecoveryError(recoveryPath);
|
||||
}
|
||||
return; // Another process is clearing it
|
||||
}
|
||||
try {
|
||||
const current = await LockFile.readLock(this.filePath);
|
||||
if (current && LockFile.isSameLock(current, stale)) {
|
||||
await fs.unlink(this.filePath);
|
||||
}
|
||||
} finally {
|
||||
await fs.unlink(recoveryPath).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,10 +233,12 @@ export class LockFile {
|
||||
*/
|
||||
private attachExitHandlers(): void {
|
||||
assert(this.state === "acquiring");
|
||||
process.on("SIGINT", this.handleExit);
|
||||
process.on("SIGTERM", this.handleExit);
|
||||
process.on("SIGHUP", this.handleExit);
|
||||
process.on("uncaughtException", this.handleExit);
|
||||
if (this.handleSignals) {
|
||||
process.on("SIGINT", this.handleExit);
|
||||
process.on("SIGTERM", this.handleExit);
|
||||
process.on("SIGHUP", this.handleExit);
|
||||
process.on("uncaughtException", this.handleExit);
|
||||
}
|
||||
// eslint-disable-next-line node/no-sync
|
||||
process.on("exit", this.handleExitSync);
|
||||
}
|
||||
@@ -137,7 +246,7 @@ export class LockFile {
|
||||
/**
|
||||
* Detach process handlers, called when transitioning to "idle"
|
||||
*/
|
||||
private async detachExitHandlers(): Promise<void> {
|
||||
private detachExitHandlers(): void {
|
||||
assert(this.state === "releasing");
|
||||
process.off("SIGINT", this.handleExit);
|
||||
process.off("SIGTERM", this.handleExit);
|
||||
@@ -147,17 +256,21 @@ export class LockFile {
|
||||
process.off("exit", this.handleExitSync);
|
||||
}
|
||||
|
||||
// Event handler for async signals / exceptions
|
||||
// Event handler for async signals / exceptions
|
||||
private handleExit = async (): Promise<void> => {
|
||||
await this.release();
|
||||
};
|
||||
|
||||
// Event handler for synchronous process exit
|
||||
// Event handler for synchronous process exit, readLock can't be used as it is async
|
||||
private handleExitSync = (): void => {
|
||||
try {
|
||||
unlinkSync(this.filePath);
|
||||
// eslint-disable-next-line node/no-sync
|
||||
if (readFileSync(this.filePath, "utf8") === LockFile.content) {
|
||||
// eslint-disable-next-line node/no-sync
|
||||
unlinkSync(this.filePath);
|
||||
}
|
||||
} catch (err) {
|
||||
// Ignored: file may have already been removed by another process
|
||||
// Ignored: file may have already been removed by another process
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -14,4 +14,4 @@ export type PluginWebApi = {
|
||||
* package.
|
||||
*/
|
||||
npmPackage?: string;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
// Node.js globals used by lib and its dependencies when running in a browser.
|
||||
import process from "process";
|
||||
import { Buffer } from "buffer";
|
||||
|
||||
// Vite's development server may already have created process.env
|
||||
if (typeof globalThis.process?.nextTick !== "function") {
|
||||
globalThis.process = Object.assign(process, globalThis.process);
|
||||
}
|
||||
globalThis.Buffer ??= Buffer as any;
|
||||
@@ -9,43 +9,12 @@ import * as libSchema from "../schema.js";
|
||||
import { StringEnum } from "../data/composites.js";
|
||||
import { safeOutputFile } from "../file_ops.js";
|
||||
import * as validators from "./validators.js";
|
||||
import { RequestError } from "../errors.js";
|
||||
import { InvalidAccessError, InvalidFieldError, InvalidValueError } from "../errors.js";
|
||||
import type { SyncHook } from "../SyncHook.js";
|
||||
|
||||
const ConfigLocation = StringEnum(["controller", "host", "control"]);
|
||||
export type ConfigLocation = Static<typeof ConfigLocation>;
|
||||
|
||||
/**
|
||||
* Invalid Access exception
|
||||
*
|
||||
* Exception class for when an access attempt is made to a field that
|
||||
* does not permitt this access is done.
|
||||
*
|
||||
* @extends Error
|
||||
*/
|
||||
export class InvalidAccess extends RequestError { };
|
||||
|
||||
/**
|
||||
* Invalid Value exception
|
||||
*
|
||||
* Exception class for when invalid values are attempted to be set on a
|
||||
* field.
|
||||
*
|
||||
* @extends Error
|
||||
*/
|
||||
export class InvalidValue extends RequestError { };
|
||||
|
||||
/**
|
||||
* Invalid Field exception
|
||||
*
|
||||
* Exception class for when a group or field that does not exist is
|
||||
* attempted to be accessed.
|
||||
*
|
||||
* @extends Error
|
||||
*/
|
||||
export class InvalidField extends RequestError { };
|
||||
|
||||
|
||||
const OldConfigGroupSchema = Type.Object({
|
||||
"name": Type.String(),
|
||||
"fields": Type.Record(Type.String(), Type.Unknown()),
|
||||
@@ -55,7 +24,7 @@ type OldConfigGroupSchema = Static<typeof OldConfigGroupSchema>;
|
||||
const OldConfigSchema = Type.Object({
|
||||
"groups": Type.Array(OldConfigGroupSchema),
|
||||
});
|
||||
export type OldConfigSchema = Static<typeof OldConfigSchema>
|
||||
export type OldConfigSchema = Static<typeof OldConfigSchema>;
|
||||
|
||||
export const ConfigSchema = Type.Record(Type.String(), Type.Union([
|
||||
Type.Null(),
|
||||
@@ -69,11 +38,11 @@ export type ConfigSchema = Static<typeof ConfigSchema>;
|
||||
type FieldValue = null | boolean | string | number | object;
|
||||
|
||||
type FieldType<T extends FieldValue> =
|
||||
T extends boolean ? "boolean" :
|
||||
T extends string ? "string" :
|
||||
T extends number ? "number" :
|
||||
T extends object ? "object" :
|
||||
never;
|
||||
T extends boolean ? "boolean"
|
||||
: T extends string ? "string"
|
||||
: T extends number ? "number"
|
||||
: T extends object ? "object"
|
||||
: never;
|
||||
|
||||
export interface FieldDefinition<
|
||||
Type extends FieldValue = any,
|
||||
@@ -165,7 +134,7 @@ export type ConfigDefs<
|
||||
Fields extends Record<keyof Fields, FieldValue>
|
||||
> = {
|
||||
[Prop in keyof Fields]: FieldDefinition<Fields[Prop], Fields>;
|
||||
}
|
||||
};
|
||||
|
||||
function defaultValue(def: FieldDefinition) {
|
||||
if (def.initialValue === undefined) {
|
||||
@@ -179,7 +148,7 @@ function defaultValue(def: FieldDefinition) {
|
||||
|
||||
export type ConfigEvents = {
|
||||
fieldChanged: [ name: string, curr: FieldValue, prev: FieldValue ],
|
||||
}
|
||||
};
|
||||
|
||||
export enum ConfigAccess {
|
||||
read = 0x1,
|
||||
@@ -368,7 +337,7 @@ export class Config<
|
||||
* @param name - Name of the field being validated.
|
||||
* @param def - Definition describing the field type and constraints.
|
||||
* @param value - Value to validate and coerce.
|
||||
* @throws {InvalidValue} if the value cannot be coerced to the correct type
|
||||
* @throws {InvalidValueError} if the value cannot be coerced to the correct type
|
||||
* @returns The validated and coerced value.
|
||||
* @private
|
||||
*/
|
||||
@@ -380,7 +349,7 @@ export class Config<
|
||||
|
||||
// If def is an enum then it must be a value within it
|
||||
if (def.enum && !def.enum.includes(value)) {
|
||||
throw new InvalidValue(`Expected one of [${def.enum.join(", ")}], not ${value}`);
|
||||
throw new InvalidValueError(`Expected one of [${def.enum.join(", ")}], not ${value}`);
|
||||
}
|
||||
|
||||
// If it is a string, then attempt to coerce it
|
||||
@@ -402,7 +371,7 @@ export class Config<
|
||||
try {
|
||||
value = JSON.parse(value);
|
||||
} catch (err: any) {
|
||||
throw new InvalidValue(`Error parsing value for ${name}: ${err.message}`);
|
||||
throw new InvalidValueError(`Error parsing value for ${name}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -410,12 +379,12 @@ export class Config<
|
||||
// Check if it is allowed to be null
|
||||
if (value === null) {
|
||||
if (!def.optional) {
|
||||
throw new InvalidValue(`Field ${name} cannot be null`);
|
||||
throw new InvalidValueError(`Field ${name} cannot be null`);
|
||||
}
|
||||
|
||||
// Check if is the correct type
|
||||
} else if (basicType(value) !== def.type) {
|
||||
throw new InvalidValue(`Expected type of ${name} to be ${def.type}, not ${basicType(value)}`);
|
||||
throw new InvalidValueError(`Expected type of ${name} to be ${def.type}, not ${basicType(value)}`);
|
||||
}
|
||||
|
||||
return value as FieldType<T["type"]>;
|
||||
@@ -429,7 +398,7 @@ export class Config<
|
||||
* @param location - location to check access for.
|
||||
* @param mode - type of access to check for.
|
||||
* @param error - throw on failure to pass check.
|
||||
* @throws {InvalidAccess}
|
||||
* @throws {InvalidAccessError}
|
||||
* if access is not granted and error is true.
|
||||
* @returns true if access is granted
|
||||
* @private
|
||||
@@ -448,7 +417,7 @@ export class Config<
|
||||
)
|
||||
) {
|
||||
if (error) {
|
||||
throw new InvalidAccess(`Field '${name}' is not accessible from ${location}`);
|
||||
throw new InvalidAccessError(`Field '${name}' is not accessible from ${location}`);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
@@ -648,7 +617,7 @@ export class Config<
|
||||
}
|
||||
let def = this.constructor.fieldDefinitions[name];
|
||||
if (!def) {
|
||||
throw new InvalidField(`No field named '${name}'`);
|
||||
throw new InvalidFieldError(`No field named '${name}'`);
|
||||
}
|
||||
return this._checkAccess(name, def, location, mode, false);
|
||||
}
|
||||
@@ -663,7 +632,7 @@ export class Config<
|
||||
get<Field extends keyof Fields & string>(name: Field, remote = this.location) {
|
||||
let def = this.constructor.fieldDefinitions[name];
|
||||
if (!def) {
|
||||
throw new InvalidField(`No field named '${name}'`);
|
||||
throw new InvalidFieldError(`No field named '${name}'`);
|
||||
}
|
||||
// If remote access is requested require it to be able to read the field
|
||||
if (remote !== this.location) {
|
||||
@@ -675,7 +644,7 @@ export class Config<
|
||||
!this._checkAccess(name, def, this.location, ConfigAccess.write, false)
|
||||
&& !this._checkAccess(name, def, this.location, ConfigAccess.read, false)
|
||||
) {
|
||||
throw new InvalidAccess(`Field '${name}' is not accessible from ${this.location}`);
|
||||
throw new InvalidAccessError(`Field '${name}' is not accessible from ${this.location}`);
|
||||
}
|
||||
|
||||
return this.fields[name];
|
||||
@@ -690,13 +659,13 @@ export class Config<
|
||||
* @param name - Name of field to set.
|
||||
* @param newValue - Value to set for field.
|
||||
* @param remote - Location this field is being set from.
|
||||
* @throws {InvalidField} if field is not defined.
|
||||
* @throws {InvalidValue} if value is not allowed for the field.
|
||||
* @throws {InvalidFieldError} if field is not defined.
|
||||
* @throws {InvalidValueError} if value is not allowed for the field.
|
||||
*/
|
||||
set<Field extends keyof Fields & string>(name: Field, newValue: Fields[Field], remote = this.location) {
|
||||
const def = this.constructor.fieldDefinitions[name];
|
||||
if (!def) {
|
||||
throw new InvalidField(`No field named '${name}'`);
|
||||
throw new InvalidFieldError(`No field named '${name}'`);
|
||||
}
|
||||
|
||||
this._checkAccess(name, def, remote, ConfigAccess.write, true);
|
||||
@@ -714,7 +683,7 @@ export class Config<
|
||||
try {
|
||||
def.validator(value, this);
|
||||
} catch (err: any) {
|
||||
throw new InvalidValue(`Failed validation of ${name}: ${err.message}`);
|
||||
throw new InvalidValueError(`Failed validation of ${name}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -732,7 +701,7 @@ export class Config<
|
||||
try {
|
||||
dependent.def.validator(this.get(dependent.name as any, remote), this.staging);
|
||||
} catch (err: any) {
|
||||
throw new InvalidValue(`Failed validation of dependent ${dependent.name}: ${err.message}`);
|
||||
throw new InvalidValueError(`Failed validation of dependent ${dependent.name}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -753,8 +722,8 @@ export class Config<
|
||||
* @param prop - Name of property to set on field.
|
||||
* @param value - the value to set the property to.
|
||||
* @param remote - Location this property is being set from
|
||||
* @throws {InvalidField} if field is not defined.
|
||||
* @throws {InvalidValue} if field is not an object.
|
||||
* @throws {InvalidFieldError} if field is not defined.
|
||||
* @throws {InvalidValueError} if field is not an object.
|
||||
*/
|
||||
setProp<Field extends keyof Fields & string>(
|
||||
name: Field,
|
||||
@@ -764,7 +733,7 @@ export class Config<
|
||||
) {
|
||||
let def = this.constructor.fieldDefinitions[name];
|
||||
if (!def) {
|
||||
throw new InvalidField(`No field named '${name}'`);
|
||||
throw new InvalidFieldError(`No field named '${name}'`);
|
||||
}
|
||||
|
||||
this._checkAccess(name, def, remote, ConfigAccess.write, true);
|
||||
@@ -773,7 +742,7 @@ export class Config<
|
||||
}
|
||||
|
||||
if (def.type !== "object") {
|
||||
throw new InvalidValue(`Cannot set property on non-object field '${name}'`);
|
||||
throw new InvalidValueError(`Cannot set property on non-object field '${name}'`);
|
||||
}
|
||||
|
||||
let prev = this.fields[name] as Record<string, unknown>;
|
||||
@@ -793,7 +762,7 @@ export class Config<
|
||||
try {
|
||||
def.validator(updated, this);
|
||||
} catch (err: any) {
|
||||
throw new InvalidValue(`Failed validation of ${name}: ${err.message}`);
|
||||
throw new InvalidValueError(`Failed validation of ${name}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -811,7 +780,7 @@ export class Config<
|
||||
try {
|
||||
dependent.def.validator(this.get(dependent.name as any, remote), this.staging);
|
||||
} catch (err: any) {
|
||||
throw new InvalidValue(`Failed validation of dependent ${dependent.name}: ${err.message}`);
|
||||
throw new InvalidValueError(`Failed validation of dependent ${dependent.name}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -829,8 +798,8 @@ export class Config<
|
||||
* @param name - Name of field to set.
|
||||
* @param newValue - Value to set for field.
|
||||
* @param remote - Location this field is being set from.
|
||||
* @throws {InvalidField} if field is not defined.
|
||||
* @throws {InvalidValue} if value is not allowed for the field.
|
||||
* @throws {InvalidFieldError} if field is not defined.
|
||||
* @throws {InvalidValueError} if value is not allowed for the field.
|
||||
*/
|
||||
stage<Field extends keyof Fields & string>(name: Field, newValue: Fields[Field], remote = this.location) {
|
||||
if (this._readonly) {
|
||||
@@ -839,7 +808,7 @@ export class Config<
|
||||
|
||||
const def = this.constructor.fieldDefinitions[name];
|
||||
if (!def) {
|
||||
throw new InvalidField(`No field named '${name}'`);
|
||||
throw new InvalidFieldError(`No field named '${name}'`);
|
||||
}
|
||||
|
||||
this._checkAccess(name, def, remote, ConfigAccess.write, true);
|
||||
@@ -859,8 +828,8 @@ export class Config<
|
||||
* @param prop - Name of property to set on field.
|
||||
* @param value - the value to set the property to.
|
||||
* @param remote - Location this property is being set from
|
||||
* @throws {InvalidField} if field is not defined.
|
||||
* @throws {InvalidValue} if field is not an object.
|
||||
* @throws {InvalidFieldError} if field is not defined.
|
||||
* @throws {InvalidValueError} if field is not an object.
|
||||
*/
|
||||
stageProp<Field extends keyof Fields & string>(
|
||||
name: Field,
|
||||
@@ -874,7 +843,7 @@ export class Config<
|
||||
|
||||
const def = this.constructor.fieldDefinitions[name];
|
||||
if (!def) {
|
||||
throw new InvalidField(`No field named '${name}'`);
|
||||
throw new InvalidFieldError(`No field named '${name}'`);
|
||||
}
|
||||
|
||||
this._checkAccess(name, def, remote, ConfigAccess.write, true);
|
||||
@@ -883,7 +852,7 @@ export class Config<
|
||||
}
|
||||
|
||||
if (def.type !== "object") {
|
||||
throw new InvalidValue(`Cannot set property on non-object field '${name}'`);
|
||||
throw new InvalidValueError(`Cannot set property on non-object field '${name}'`);
|
||||
}
|
||||
|
||||
const prev = this.staging.get(name) as Record<string, unknown>;
|
||||
@@ -901,7 +870,7 @@ export class Config<
|
||||
/**
|
||||
* Commit all changes in stating after they have been validated.
|
||||
*
|
||||
* @throws {InvalidValue} if a value is not allowed for its field.
|
||||
* @throws {InvalidValueError} if a value is not allowed for its field.
|
||||
*/
|
||||
commitStaging() {
|
||||
if (this._readonly) {
|
||||
@@ -923,7 +892,7 @@ export class Config<
|
||||
try {
|
||||
fieldDef.validator(config.fields[fieldName as keyof Fields], config);
|
||||
} catch (err: any) {
|
||||
throw new InvalidValue(`Failed validation of ${fieldName}: ${err.message}`);
|
||||
throw new InvalidValueError(`Failed validation of ${fieldName}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Core definitions for the configuration system
|
||||
import * as classes from "./classes.js";
|
||||
import * as validators from "./validators.js";
|
||||
import type { PluginNodeEnvInfo, PluginWebpackEnvInfo } from "../plugin.js";
|
||||
import type { PluginNodeEnvInfo, PluginWebEnvInfo } from "../plugin.js";
|
||||
import { Static } from "@sinclair/typebox";
|
||||
import { isTargetVersion } from "../data/version.js";
|
||||
import { SyncHook } from "../SyncHook.js";
|
||||
@@ -151,7 +151,7 @@ export class ControllerConfig extends classes.Config<ControllerConfigFields> {
|
||||
optional: true,
|
||||
validator: (value) => {
|
||||
if (value) {
|
||||
// This is unfortunately a limitiation of how the webpack build is set up right now.
|
||||
// This is unfortunately a limitiation of how the web build is set up right now.
|
||||
const pathname = new URL(value, "http://localhost/").pathname;
|
||||
if (pathname !== "/static/") {
|
||||
throw new Error(`Path component of static_url must be /static/ not ${pathname}.`);
|
||||
@@ -786,7 +786,7 @@ function validateFieldNames(
|
||||
* Add the config fields defined by the by a specific plugin infos
|
||||
*/
|
||||
export function addPluginFieldDefinitions(
|
||||
pluginInfo: PluginNodeEnvInfo | PluginWebpackEnvInfo,
|
||||
pluginInfo: PluginNodeEnvInfo | PluginWebEnvInfo,
|
||||
kind: "controllerConfigFields" | "hostConfigFields" | "instanceConfigFields",
|
||||
Config: typeof classes.Config<any>
|
||||
) {
|
||||
@@ -858,7 +858,7 @@ export function addPluginFieldDefinitions(
|
||||
*
|
||||
* @param {Array<Object>} pluginInfos - Array of plugin info objects.
|
||||
*/
|
||||
export function addPluginConfigFields(pluginInfos: PluginNodeEnvInfo[] | PluginWebpackEnvInfo[]) {
|
||||
export function addPluginConfigFields(pluginInfos: PluginNodeEnvInfo[] | PluginWebEnvInfo[]) {
|
||||
for (let pluginInfo of pluginInfos) {
|
||||
addPluginFieldDefinitions(pluginInfo, "controllerConfigFields", ControllerConfig);
|
||||
if (pluginInfo.hostEntrypoint || pluginInfo.instanceEntrypoint) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { JsonString, StringEnum, StringKey, plainJson } from "./composites.js";
|
||||
import { type LogEntry, levels } from "../logging.js";
|
||||
import { ControllerConfig, HostConfig } from "../config/index.js";
|
||||
import { ExternalFactorioVersionSchema, LatestReleasesSchema } from "../external/index.js";
|
||||
import { PluginDetails } from "./messages_plugin.js";
|
||||
|
||||
const LogEntrySchema = Type.Object({
|
||||
"level": StringKey(levels),
|
||||
@@ -298,6 +299,8 @@ export class SystemInfo {
|
||||
/** Millisecond Unix timestamp this entry was last updated at */
|
||||
public updatedAtMs: number,
|
||||
public isDeleted: boolean,
|
||||
/** Plugins on the machine when last collected, kept while it is offline */
|
||||
public plugins?: PluginDetails[],
|
||||
) { }
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
@@ -318,6 +321,7 @@ export class SystemInfo {
|
||||
"processStartedAtMs": Type.Number(),
|
||||
"updatedAtMs": Type.Number(),
|
||||
"isDeleted": Type.Boolean(),
|
||||
"plugins": Type.Optional(Type.Array(PluginDetails.jsonSchema)),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
@@ -339,6 +343,7 @@ export class SystemInfo {
|
||||
json.processStartedAtMs,
|
||||
json.updatedAtMs,
|
||||
json.isDeleted,
|
||||
json.plugins?.map(plugin => PluginDetails.fromJSON(plugin)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -435,7 +440,7 @@ export class DebugWsMessageEvent {
|
||||
export class FactorioVersionsRequest {
|
||||
declare ["constructor"]: typeof FactorioVersionsRequest;
|
||||
static type = "request" as const;
|
||||
static src = ["control", "instance"] as const;
|
||||
static src = ["control", "host", "instance"] as const;
|
||||
static dst = "controller" as const;
|
||||
static permission = "core.external.get_factorio_versions" as const;
|
||||
|
||||
@@ -457,7 +462,7 @@ export class FactorioVersionsRequest {
|
||||
export class LatestReleasesRequest {
|
||||
declare ["constructor"]: typeof LatestReleasesRequest;
|
||||
static type = "request" as const;
|
||||
static src = ["control", "instance"] as const;
|
||||
static src = ["control", "host", "instance"] as const;
|
||||
static dst = "controller" as const;
|
||||
static permission = "core.external.get_latest_releases" as const;
|
||||
|
||||
|
||||
@@ -251,7 +251,6 @@ export class ModPortalGetAllRequest {
|
||||
|
||||
// Define the Response class inline
|
||||
static Response = class ModPortalGetAllResponse {
|
||||
declare ["constructor"]: typeof ModPortalGetAllResponse;
|
||||
constructor(
|
||||
public mods: Static<typeof ModPortalDetailsSchema>[],
|
||||
) { }
|
||||
|
||||
@@ -4,41 +4,121 @@ import type { PluginNodeEnvInfo } from "../plugin.js";
|
||||
import { Address, MessageRequest } from "./messages_core.js";
|
||||
import type { IUser } from "./UserDetails.js";
|
||||
|
||||
export const pluginStateSchema = Type.Union([
|
||||
Type.Literal("loaded"),
|
||||
Type.Literal("disabled"),
|
||||
Type.Literal("unloadOnRestart"),
|
||||
Type.Literal("loadOnRestart"),
|
||||
Type.Literal("noEntrypoint"),
|
||||
Type.Literal("pendingInstall"),
|
||||
Type.Literal("removed"),
|
||||
Type.Literal("removedLoaded"),
|
||||
Type.Literal("nameTaken"),
|
||||
]);
|
||||
/**
|
||||
* State of a plugin on a machine
|
||||
*
|
||||
* - loaded: Running and loaded again on the next restart
|
||||
* - disabled: Not loaded as `<plugin>.load_plugin` is false
|
||||
* - unloadOnRestart: Running, but disabled since startup
|
||||
* - loadOnRestart: Not running, enabled since startup or skipped by recovery mode
|
||||
* - noEntrypoint: Has no entrypoint for this machine, so it is never loaded here
|
||||
* - pendingInstall: Installed since startup, loaded on the next restart
|
||||
* - removed: Uninstalled since startup while not running
|
||||
* - removedLoaded: Uninstalled since startup, running until the next restart
|
||||
* - nameTaken: Not loaded as another package provides a plugin with the same name
|
||||
*/
|
||||
export type PluginState = Static<typeof pluginStateSchema>;
|
||||
|
||||
/* This is similar to other plugin definitions but with lots removed */
|
||||
export class PluginDetails {
|
||||
constructor(
|
||||
public name: string,
|
||||
public title: string,
|
||||
public version: string,
|
||||
public loaded: boolean,
|
||||
public enabled: boolean,
|
||||
public state: PluginState,
|
||||
public description?: string,
|
||||
public npmPackage?: string,
|
||||
/** Version installed on disk, differs from version when an update is waiting for a restart */
|
||||
public installedVersion?: string,
|
||||
/** Name in the root package.json dependencies, differs from npmPackage for aliases */
|
||||
public dependencyName?: string,
|
||||
) {}
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
name: Type.String(),
|
||||
title: Type.String(),
|
||||
version: Type.String(),
|
||||
// Missing from 2.0.3 and older, which require loaded and enabled instead
|
||||
state: Type.Optional(pluginStateSchema),
|
||||
loaded: Type.Boolean(),
|
||||
enabled: Type.Boolean(),
|
||||
description: Type.Optional(Type.String()),
|
||||
npmPackage: Type.Optional(Type.String()),
|
||||
installedVersion: Type.Optional(Type.String()),
|
||||
dependencyName: Type.Optional(Type.String()),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(
|
||||
json.name, json.title, json.version, json.loaded, json.enabled,
|
||||
json.description, json.npmPackage
|
||||
json.name, json.title, json.version, json.state ?? this.loadState(json.loaded, json.enabled),
|
||||
json.description, json.npmPackage, json.installedVersion, json.dependencyName,
|
||||
);
|
||||
}
|
||||
|
||||
static fromNodeEnvInfo(pluginInfo: PluginNodeEnvInfo, loaded: boolean, enabled: boolean) {
|
||||
toJSON(): Static<typeof PluginDetails.jsonSchema> {
|
||||
return {
|
||||
name: this.name,
|
||||
title: this.title,
|
||||
version: this.version,
|
||||
state: this.state,
|
||||
loaded: this.loaded,
|
||||
enabled: this.loadsOnRestart,
|
||||
description: this.description,
|
||||
npmPackage: this.npmPackage,
|
||||
installedVersion: this.installedVersion,
|
||||
dependencyName: this.dependencyName,
|
||||
};
|
||||
}
|
||||
|
||||
/** State of a plugin known since startup */
|
||||
static loadState(loaded: boolean, enabled: boolean): PluginState {
|
||||
if (loaded) {
|
||||
return enabled ? "loaded" : "unloadOnRestart";
|
||||
}
|
||||
return enabled ? "loadOnRestart" : "disabled";
|
||||
}
|
||||
|
||||
static fromNodeEnvInfo(pluginInfo: PluginNodeEnvInfo, state: PluginState) {
|
||||
return new this(
|
||||
pluginInfo.name, pluginInfo.title, pluginInfo.version, loaded, enabled,
|
||||
pluginInfo.name, pluginInfo.title, pluginInfo.version, state,
|
||||
pluginInfo.description, pluginInfo.npmPackage,
|
||||
);
|
||||
}
|
||||
|
||||
/** Plugin package installed since startup, it is loaded on the next restart */
|
||||
static fromPackageJson(packageName: string, packageJson: any, npmPackage?: string, dependencyName?: string) {
|
||||
const description = typeof packageJson.description === "string" ? packageJson.description : undefined;
|
||||
return new this(
|
||||
packageName, packageName, packageJson.version, "pendingInstall",
|
||||
description, npmPackage, packageJson.version, dependencyName,
|
||||
);
|
||||
}
|
||||
|
||||
/** Running on the machine now */
|
||||
get loaded() {
|
||||
return ["loaded", "unloadOnRestart", "removedLoaded"].includes(this.state);
|
||||
}
|
||||
|
||||
/** Running on the machine after the next restart */
|
||||
get loadsOnRestart() {
|
||||
return ["loaded", "loadOnRestart", "pendingInstall"].includes(this.state);
|
||||
}
|
||||
|
||||
/** Uninstalled since startup */
|
||||
get removed() {
|
||||
return this.state === "removed" || this.state === "removedLoaded";
|
||||
}
|
||||
}
|
||||
|
||||
export class PluginListRequest {
|
||||
@@ -94,6 +174,114 @@ export class PluginInstallRequest {
|
||||
}
|
||||
}
|
||||
|
||||
/* Plugin package listed on the npm registry */
|
||||
export class PluginSearchResult {
|
||||
constructor(
|
||||
public name: string,
|
||||
public version: string,
|
||||
public description?: string,
|
||||
public publisher?: string,
|
||||
public date?: string,
|
||||
public homepage?: string,
|
||||
public repository?: string,
|
||||
public npm?: string,
|
||||
) {}
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
name: Type.String(),
|
||||
version: Type.String(),
|
||||
description: Type.Optional(Type.String()),
|
||||
publisher: Type.Optional(Type.String()),
|
||||
date: Type.Optional(Type.String()),
|
||||
homepage: Type.Optional(Type.String()),
|
||||
repository: Type.Optional(Type.String()),
|
||||
npm: Type.Optional(Type.String()),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(
|
||||
json.name, json.version, json.description, json.publisher,
|
||||
json.date, json.homepage, json.repository, json.npm,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class PluginSearchRequest {
|
||||
declare ["constructor"]: typeof PluginSearchRequest;
|
||||
static type = "request" as const;
|
||||
static src = "control" as const;
|
||||
static dst = "controller" as const;
|
||||
static permission = "core.plugin.search";
|
||||
|
||||
constructor(
|
||||
public query: string = "",
|
||||
public page: number = 1,
|
||||
public pageSize: number = 20,
|
||||
) {}
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
query: Type.String(),
|
||||
page: Type.Integer(),
|
||||
pageSize: Type.Integer(),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json.query, json.page, json.pageSize);
|
||||
}
|
||||
|
||||
static Response = class Response {
|
||||
constructor(
|
||||
public total: number,
|
||||
public results: PluginSearchResult[],
|
||||
) {}
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
total: Type.Integer(),
|
||||
results: Type.Array(PluginSearchResult.jsonSchema),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json.total, json.results.map(r => PluginSearchResult.fromJSON(r)));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/* Latest version of each package published on npm, used to offer plugin updates */
|
||||
export class PluginLatestVersionsRequest {
|
||||
declare ["constructor"]: typeof PluginLatestVersionsRequest;
|
||||
static type = "request" as const;
|
||||
static src = "control" as const;
|
||||
static dst = "controller" as const;
|
||||
static permission = "core.plugin.list";
|
||||
|
||||
constructor(
|
||||
public packages: string[],
|
||||
) {}
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
packages: Type.Array(Type.String()),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json.packages);
|
||||
}
|
||||
|
||||
static Response = class Response {
|
||||
constructor(
|
||||
/** Package name to its latest version, packages not found on npm are left out */
|
||||
public versions: Record<string, string>,
|
||||
) {}
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
versions: Type.Record(Type.String(), Type.String()),
|
||||
});
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json.versions);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export class UpdateAllRequest {
|
||||
declare ["constructor"]: typeof UpdateAllRequest;
|
||||
static type = "request" as const;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Type, Static } from "@sinclair/typebox";
|
||||
import UserDetails, { IUser } from "./UserDetails.js";
|
||||
import { StringEnum, jsonArray, plainJson } from "./composites.js";
|
||||
import { MessageRequest } from "./messages_core.js";
|
||||
import { PermissionError } from "../errors.js";
|
||||
import { ValidationError } from "../errors.js";
|
||||
|
||||
export class UserGetRequest {
|
||||
declare ["constructor"]: typeof UserGetRequest;
|
||||
@@ -277,9 +277,9 @@ export class UserBulkImportRequest {
|
||||
user.checkPermission("core.user.set_whitelisted");
|
||||
break;
|
||||
default:
|
||||
// Unvalidated input during permission check: deny, don't throw a bare Error
|
||||
// Unvalidated input during permission check: reject, don't throw a bare Error
|
||||
// @ts-expect-error Unreachable
|
||||
throw new PermissionError(`Unknown import / restore type: ${data.importType}`);
|
||||
throw new ValidationError(`Unknown import / restore type: ${data.importType}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,13 +15,8 @@ export abstract class DatastoreProvider<
|
||||
K extends DatastoreKey,
|
||||
V extends DatastoreValue,
|
||||
> {
|
||||
abstract save(data: Map<K, V>): Promise<void>
|
||||
abstract load(): Promise<Map<K, V>>
|
||||
|
||||
// Load data from the source and return it, this should be passed to the constructor of a datastore
|
||||
async bootstrap() {
|
||||
return [this, await this.load()] as const;
|
||||
}
|
||||
abstract save(data: Map<K, V>): Promise<void>;
|
||||
abstract load(): Promise<Map<K, V>>;
|
||||
}
|
||||
|
||||
// Store all data in memory, used as a default and should be avoided
|
||||
@@ -251,6 +246,11 @@ export class KeyValueDatastore<
|
||||
K extends DatastoreKey,
|
||||
V extends DatastoreValue,
|
||||
> extends Datastore<K, V, [key: K, value: V, isDeleted?: boolean]> {
|
||||
// Create a datastore populated with the data loaded from the provider
|
||||
static async fromProvider<K extends DatastoreKey, V extends DatastoreValue>(provider: DatastoreProvider<K, V>) {
|
||||
return new this(provider, await provider.load());
|
||||
}
|
||||
|
||||
// Set the value in the datastore, be careful of race conditions if you await any functions before calling set
|
||||
set(key: K, value: V) {
|
||||
this.data.set(key, value);
|
||||
@@ -316,6 +316,11 @@ export class SubscribableDatastore<
|
||||
this.lastUpdatedAtMs = SubscribableDatastore.maxUpdatedAtMs(this.data.values());
|
||||
}
|
||||
|
||||
// Create a datastore populated with the data loaded from the provider
|
||||
static async fromProvider<V extends SubscribableValue>(provider: DatastoreProvider<V["id"], V>) {
|
||||
return new this(provider, await provider.load());
|
||||
}
|
||||
|
||||
async load() {
|
||||
await super.load();
|
||||
this.lastUpdatedAtMs = SubscribableDatastore.maxUpdatedAtMs(this.data.values());
|
||||
|
||||
+67
-25
@@ -18,40 +18,82 @@ export class InstallationError extends Error {
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown from requests sent when an error occured handling it
|
||||
* Base class for errors caused by the request, such as invalid input,
|
||||
* missing permissions or a missing resource. Corresponds to the HTTP 4xx
|
||||
* range.
|
||||
*
|
||||
* Link does not log these as unexpected when a request handler throws them.
|
||||
*/
|
||||
export class RequestError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public code = "RequestError",
|
||||
public stack?: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
export class ClientError extends Error { }
|
||||
|
||||
/**
|
||||
* Thrown when a permission check fails.
|
||||
*
|
||||
* Is a subclass of RequestError to prevent logging stack traces when
|
||||
* requests fail due to permission denied.
|
||||
*
|
||||
* Thrown when the request contains invalid data (HTTP 400)
|
||||
*/
|
||||
export class PermissionError extends RequestError {
|
||||
constructor(
|
||||
message: string,
|
||||
code = "PermissionError",
|
||||
stack?: string,
|
||||
) {
|
||||
super(message, code, stack);
|
||||
}
|
||||
export class ValidationError extends ClientError { }
|
||||
|
||||
}
|
||||
/**
|
||||
* Thrown when a config value cannot be used for its field
|
||||
*/
|
||||
export class InvalidValueError extends ValidationError { }
|
||||
|
||||
/**
|
||||
* Thrown when a config field does not exist
|
||||
*/
|
||||
export class InvalidFieldError extends ValidationError { }
|
||||
|
||||
/**
|
||||
* Thrown when the request is not allowed (HTTP 403)
|
||||
*/
|
||||
export class ForbiddenError extends ClientError { }
|
||||
|
||||
/**
|
||||
* Thrown when a permission check fails
|
||||
*/
|
||||
export class PermissionError extends ForbiddenError { }
|
||||
|
||||
/**
|
||||
* Thrown when a config field is not accessible from the current location
|
||||
*/
|
||||
export class InvalidAccessError extends ForbiddenError { }
|
||||
|
||||
/**
|
||||
* Thrown when a resource the request refers to does not exist (HTTP 404)
|
||||
*/
|
||||
export class NotFoundError extends ClientError { }
|
||||
|
||||
/**
|
||||
* Thrown when an operation for a request timed out (HTTP 408)
|
||||
*/
|
||||
export class TimeoutError extends ClientError { }
|
||||
|
||||
/**
|
||||
* Thrown when the request conflicts with the current state, such as an
|
||||
* instance that is running or a name that is taken (HTTP 409)
|
||||
*/
|
||||
export class ConflictError extends ClientError { }
|
||||
|
||||
/**
|
||||
* Base class for errors caused by the side handling the request.
|
||||
* Corresponds to the HTTP 5xx range.
|
||||
*/
|
||||
export class ServerError extends Error { }
|
||||
|
||||
/**
|
||||
* Received in place of an error response whose class is not registered,
|
||||
* such as a TypeError thrown by a request handler (HTTP 500)
|
||||
*/
|
||||
export class InternalServerError extends ServerError { }
|
||||
|
||||
/**
|
||||
* Thrown when the host or instance that should handle the request is not
|
||||
* connected, not running or shutting down (HTTP 503)
|
||||
*/
|
||||
export class UnavailableError extends ServerError { }
|
||||
|
||||
/**
|
||||
* Signal for messages that fail validation
|
||||
*/
|
||||
export class InvalidMessage extends Error {
|
||||
export class InvalidMessage extends ValidationError {
|
||||
code = "InvalidMessage";
|
||||
constructor(
|
||||
message: string,
|
||||
|
||||
@@ -35,7 +35,7 @@ export type FactorioModSettingType = "startup" | "runtime-global" | "runtime-per
|
||||
* Boolean mod setting prototype.
|
||||
* @see [bool-setting](https://wiki.factorio.com/Tutorial:Mod_settings#bool-setting)
|
||||
*/
|
||||
export interface FactorioBoolSettingPrototype extends FactorioPrototypeBase{
|
||||
export interface FactorioBoolSettingPrototype extends FactorioPrototypeBase {
|
||||
type: "bool-setting",
|
||||
setting_type?: FactorioModSettingType,
|
||||
default_value: boolean,
|
||||
@@ -46,7 +46,7 @@ export interface FactorioBoolSettingPrototype extends FactorioPrototypeBase{
|
||||
* Integer mod setting prototype.
|
||||
* @see [int-setting](https://wiki.factorio.com/Tutorial:Mod_settings#int-setting)
|
||||
*/
|
||||
export interface FactorioIntSettingPrototype extends FactorioPrototypeBase{
|
||||
export interface FactorioIntSettingPrototype extends FactorioPrototypeBase {
|
||||
type: "int-setting",
|
||||
setting_type?: FactorioModSettingType,
|
||||
default_value: number,
|
||||
@@ -59,7 +59,7 @@ export interface FactorioIntSettingPrototype extends FactorioPrototypeBase{
|
||||
* Real number mod setting prototype.
|
||||
* @see [double-setting](https://wiki.factorio.com/Tutorial:Mod_settings#double-setting)
|
||||
*/
|
||||
export interface FactorioDoubleSettingPrototype extends FactorioPrototypeBase{
|
||||
export interface FactorioDoubleSettingPrototype extends FactorioPrototypeBase {
|
||||
type: "double-setting",
|
||||
setting_type?: FactorioModSettingType,
|
||||
default_value: number,
|
||||
@@ -72,7 +72,7 @@ export interface FactorioDoubleSettingPrototype extends FactorioPrototypeBase{
|
||||
* String mod setting prototype.
|
||||
* @see [string-setting](https://wiki.factorio.com/Tutorial:Mod_settings#string-setting)
|
||||
*/
|
||||
export interface FactorioStringSettingPrototype extends FactorioPrototypeBase{
|
||||
export interface FactorioStringSettingPrototype extends FactorioPrototypeBase {
|
||||
type: "string-setting",
|
||||
setting_type?: FactorioModSettingType,
|
||||
default_value: string,
|
||||
@@ -85,7 +85,7 @@ export interface FactorioStringSettingPrototype extends FactorioPrototypeBase{
|
||||
* Color mod setting prototype.
|
||||
* @see [color-setting](https://wiki.factorio.com/Tutorial:Mod_settings#color-setting)
|
||||
*/
|
||||
export interface FactorioColorSettingPrototype extends FactorioPrototypeBase{
|
||||
export interface FactorioColorSettingPrototype extends FactorioPrototypeBase {
|
||||
type: "color-setting",
|
||||
setting_type?: FactorioModSettingType,
|
||||
default_value: FactorioColor,
|
||||
@@ -104,7 +104,7 @@ export type FactorioModSettingPrototype =
|
||||
* A list of prototypes as it appears in Factorio's Lua API.
|
||||
* @see [Data.raw](https://lua-api.factorio.com/stable/types/Data.html#raw)
|
||||
*/
|
||||
export type FactorioPrototypes<Prototype = FactorioPrototypeBase> = Record<string, Record<string, Prototype>>
|
||||
export type FactorioPrototypes<Prototype = FactorioPrototypeBase> = Record<string, Record<string, Prototype>>;
|
||||
export type FactorioDefinesValue = number | string | { [index: string]: FactorioDefinesValue };
|
||||
export type FactorioDefines = {
|
||||
[index: string]: FactorioDefinesValue,
|
||||
|
||||
+2
-2
@@ -12,7 +12,7 @@ export const ExternalFactorioVersionSchema = Type.Object({
|
||||
headlessUrl: Type.String(),
|
||||
});
|
||||
|
||||
export type ExternalFactorioVersion = Static<typeof ExternalFactorioVersionSchema>
|
||||
export type ExternalFactorioVersion = Static<typeof ExternalFactorioVersionSchema>;
|
||||
|
||||
/**
|
||||
* Fetch all factorio versions that support headless
|
||||
@@ -47,7 +47,7 @@ function parseFactorioVersions(html: string): ExternalFactorioVersion[] {
|
||||
const versions = new Map<string, ExternalFactorioVersion>();
|
||||
|
||||
// Match <a class=" slot-button-inline version-button-stable " href="/download/archive/2.0.72">2.0.72</a>
|
||||
// eslint-disable-next-line max-len
|
||||
// eslint-disable-next-line @stylistic/max-len
|
||||
const anchorRegex = /<a[^>]*class="[^"]*version-button-(?<kind>stable|experimental)[^"]*"[^>]*href="[^"]*\/download\/archive\/(?<version>\d+\.\d+.\d+)"[^>]*>/g;
|
||||
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ export const LatestReleaseBuildsSchema = Type.Record(Type.String(), FullVersionS
|
||||
*/
|
||||
export const LatestReleasesSchema = Type.Record(Type.String(), LatestReleaseBuildsSchema);
|
||||
|
||||
export type LatestReleases = Static<typeof LatestReleasesSchema>
|
||||
export type LatestReleases = Static<typeof LatestReleasesSchema>;
|
||||
|
||||
/**
|
||||
* Fetch the latest stable and experimental factorio releases
|
||||
|
||||
@@ -70,48 +70,7 @@ export async function directorySize(directory: string) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Modifies name in case it already exisist at the given directory
|
||||
*
|
||||
* Checks the directory passed if it already contains a file or folder with
|
||||
* the given name, if it does not returns name unmodified, otherwise it
|
||||
* returns a modified name that does not exist in the directory.
|
||||
*
|
||||
* Warning: this function should not be relied upon to be accurate for
|
||||
* security sensitive applications. The selection process is inherently
|
||||
* racy and a file or folder may have been created in the folder by the time
|
||||
* this function returns.
|
||||
*
|
||||
* @param directory - directory to check in.
|
||||
* @param name - file name to check for, may have extension.
|
||||
* @param extension - dot extension used for the file name.
|
||||
* @returns modified name with extension that likely does
|
||||
* not exist in the folder
|
||||
*/
|
||||
export async function findUnusedName(directory: string, name: string, extension = "") {
|
||||
if (extension && name.endsWith(extension)) {
|
||||
name = name.slice(0, -extension.length);
|
||||
}
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
await fs.access(path.join(directory, `${name}${extension}`));
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
return `${name}${extension}`;
|
||||
}
|
||||
}
|
||||
|
||||
let match = /^(.*?)(-(\d+))?$/.exec(name)!;
|
||||
if (!match[2]) {
|
||||
name = `${match[1]}-2`;
|
||||
} else {
|
||||
name = `${match[1]}-${Number.parseInt(match[3], 10) + 1}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate collision resistante temporary file name
|
||||
* Generate collision resistant temporary file name
|
||||
*
|
||||
* Warning: this function should not be relied upon to be accurate for
|
||||
* security sensitive applications. The selection process is inherently
|
||||
@@ -125,8 +84,7 @@ export async function findUnusedName(directory: string, name: string, extension
|
||||
*/
|
||||
export async function getTempFile(prefix = "tmp.", suffix = "", tmpdir = "./") {
|
||||
let fileName = path.join(prefix + crypto.randomBytes(16).toString("hex") + suffix);
|
||||
let freeFile = await findUnusedName(tmpdir, fileName);
|
||||
let fullPath = path.join(tmpdir, freeFile);
|
||||
let fullPath = path.join(tmpdir, fileName);
|
||||
return fullPath;
|
||||
}
|
||||
|
||||
|
||||
@@ -321,6 +321,57 @@ export function redactUrl(url: URL | string) {
|
||||
return isPath ? parsed.pathname + parsed.search + parsed.hash : parsed.toString();
|
||||
}
|
||||
|
||||
const gitProtocols = new Set(["git:", "git+http:", "git+https:", "git+ssh:", "ssh:"]);
|
||||
const repositoryShorthands = new Map([
|
||||
["github:", "https://github.com/"],
|
||||
["gitlab:", "https://gitlab.com/"],
|
||||
["bitbucket:", "https://bitbucket.org/"],
|
||||
]);
|
||||
|
||||
/**
|
||||
* Convert a link from untrusted metadata, like a package's homepage or repository, into a URL
|
||||
* that is safe to use as the href of a link
|
||||
*
|
||||
* Only http and https URLs are returned, so links like `javascript:` can't run code when
|
||||
* clicked. Accepts the formats npm uses for repositories: a string or an object with a `url`
|
||||
* property, `git+https://`, `git://` and `git+ssh://` URLs, `git@host:path` and shorthands
|
||||
* like `github:owner/repo`, which are converted to https URLs. Credentials are removed.
|
||||
*
|
||||
* @param link - URL string or object with a `url` property.
|
||||
* @returns http(s) URL, or undefined if the link can't be converted to one.
|
||||
*/
|
||||
export function safeExternalUrl(link: unknown): string | undefined {
|
||||
const value = typeof link === "object" && link !== null ? (link as { url?: unknown }).url : link;
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
let url = value.trim();
|
||||
const scpLike = /^[\w.-]+@([\w.-]+):(?!\/)(.+)$/.exec(url);
|
||||
if (scpLike) {
|
||||
url = `ssh://${scpLike[1]}/${scpLike[2]}`;
|
||||
}
|
||||
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
const shorthand = repositoryShorthands.get(parsed.protocol);
|
||||
if (shorthand) {
|
||||
parsed = new URL(url.slice(parsed.protocol.length).replace(/^\/+/, ""), shorthand);
|
||||
} else if (gitProtocols.has(parsed.protocol)) {
|
||||
// The protocol setter can't change a URL into an https URL, so parse it again
|
||||
parsed = new URL(`https://${parsed.host}${parsed.pathname.replace(/\.git$/, "")}`);
|
||||
}
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
return undefined;
|
||||
}
|
||||
parsed.username = "";
|
||||
parsed.password = "";
|
||||
return parsed.href;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy of parsed query parameters with credentials replaced by `REDACTED`
|
||||
*
|
||||
|
||||
+102
-27
@@ -3,10 +3,10 @@ import * as libData from "../data/index.js";
|
||||
import * as libErrors from "../errors.js";
|
||||
import { logger } from "../logging.js";
|
||||
import * as libSchema from "../schema.js";
|
||||
import { dataClasses } from "./messages.js";
|
||||
import { dataClasses, errorClasses } from "./messages.js";
|
||||
import { BaseConnector, WebSocketBaseConnector } from "./connectors.js";
|
||||
import { strict as assert } from "assert";
|
||||
import type { PluginNodeEnvInfo, PluginWebpackEnvInfo } from "../plugin.js";
|
||||
import type { PluginNodeEnvInfo, PluginWebEnvInfo } from "../plugin.js";
|
||||
import type {
|
||||
AddressType, JSONDeserialisable, MessageRoutable, MessageRequest, MessageEvent, IUser,
|
||||
} from "../data/index.js";
|
||||
@@ -38,6 +38,12 @@ export interface Event<T> {
|
||||
}
|
||||
export type EventClass<T> = Event<T>["constructor"];
|
||||
|
||||
export interface ErrorClass {
|
||||
new (message: string): Error;
|
||||
name: string;
|
||||
plugin?: string;
|
||||
}
|
||||
|
||||
export interface RequestEntry {
|
||||
Request: RequestClass<unknown, unknown>;
|
||||
name: string,
|
||||
@@ -156,7 +162,7 @@ export class Link {
|
||||
return this._pendingRequests.size + this._forwardedRequests.size;
|
||||
}
|
||||
|
||||
_clearPendingRequests(err: Error & { code?: string }) {
|
||||
_clearPendingRequests(err: Error) {
|
||||
for (let pending of this._pendingRequests.values()) {
|
||||
pending.reject(err);
|
||||
}
|
||||
@@ -170,9 +176,7 @@ export class Link {
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
pending.origin.connector.sendResponseError(
|
||||
new libData.ResponseError(err.message, err.code), pending.src, pending.dst
|
||||
);
|
||||
pending.origin.connector.sendResponseError(Link.errorToResponse(err, false), pending.src, pending.dst);
|
||||
}
|
||||
this._pendingRequests.clear();
|
||||
this._forwardedRequests.clear();
|
||||
@@ -200,10 +204,10 @@ export class Link {
|
||||
try {
|
||||
this.validatePermission(permissionMessage, entry); // Somewhat hacky, defined in ControlConnection
|
||||
} catch (err) {
|
||||
// A non-PermissionError means the permission check itself is
|
||||
// A non-ClientError means the permission check itself is
|
||||
// broken. validatePermission (control connections only) already
|
||||
// sent a response error, so log and drop instead of crashing.
|
||||
if (!(err instanceof libErrors.PermissionError)) {
|
||||
if (!(err instanceof libErrors.ClientError)) {
|
||||
logger.error(
|
||||
`Unexpected error checking permission for ${permissionMessage.name}:\n` +
|
||||
`${(err as Error).stack ?? (err as Error).message}`
|
||||
@@ -280,7 +284,7 @@ export class Link {
|
||||
this.validateIngress(message);
|
||||
} catch (err: any) {
|
||||
if (message.type === "request") {
|
||||
this.connector.sendResponseError(new libData.ResponseError(err.message, err.code), message.src);
|
||||
this.connector.sendResponseError(Link.errorToResponse(err, false), message.src);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
@@ -290,17 +294,17 @@ export class Link {
|
||||
let entry = Link._requestsByName.get(message.name);
|
||||
if (!entry) {
|
||||
let err = new libErrors.InvalidMessage(`Unrecognized request ${message.name}`);
|
||||
this.connector.sendResponseError(new libData.ResponseError(err.message, err.code), message.src);
|
||||
this.connector.sendResponseError(Link.errorToResponse(err, false), message.src);
|
||||
throw err;
|
||||
}
|
||||
if (!entry.allowedSrcTypes.has(message.src.type)) {
|
||||
let err = new libErrors.InvalidMessage(`Source ${message.src} is not allowed for ${message.name}`);
|
||||
this.connector.sendResponseError(new libData.ResponseError(err.message, err.code), message.src);
|
||||
this.connector.sendResponseError(Link.errorToResponse(err, false), message.src);
|
||||
throw err;
|
||||
}
|
||||
if (!entry.allowedDstTypes.has(message.dst.type)) {
|
||||
let err = new libErrors.InvalidMessage(`Destination ${message.dst} is not allowed for ${message.name}`);
|
||||
this.connector.sendResponseError(new libData.ResponseError(err.message, err.code), message.src);
|
||||
this.connector.sendResponseError(Link.errorToResponse(err, false), message.src);
|
||||
throw err;
|
||||
}
|
||||
return entry;
|
||||
@@ -356,7 +360,7 @@ export class Link {
|
||||
);
|
||||
if (message.type === "request") {
|
||||
this.connector.sendResponseError(
|
||||
new libData.ResponseError(err.message, err.code), (message as libData.MessageRequest).src
|
||||
Link.errorToResponse(err, false), (message as libData.MessageRequest).src
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
@@ -399,13 +403,7 @@ export class Link {
|
||||
try {
|
||||
response = handler(entry.requestFromJSON(message.data), message.src, message.dst);
|
||||
} catch (err: any) {
|
||||
if (err.errors) {
|
||||
logger.error(JSON.stringify(err.errors, null, "\t"));
|
||||
}
|
||||
this.connector.sendResponseError(
|
||||
new libData.ResponseError(err.message, err.code, err.stack), message.src
|
||||
);
|
||||
return;
|
||||
response = Promise.reject(err);
|
||||
}
|
||||
|
||||
if (!(response instanceof Promise)) {
|
||||
@@ -424,19 +422,17 @@ export class Link {
|
||||
this.connector.sendResponse(result, message.src, spoofedSrc);
|
||||
}
|
||||
).catch(
|
||||
(err: Error & { code?: string }) => {
|
||||
(err: Error) => {
|
||||
if (err instanceof libErrors.InvalidMessage) {
|
||||
logger.error(err.message);
|
||||
if (err.errors) {
|
||||
logger.error(JSON.stringify(err.errors, null, "\t"));
|
||||
}
|
||||
} else if (!(err instanceof libErrors.RequestError)) {
|
||||
} else if (Link.errorName(err) === undefined) {
|
||||
logger.error(`Unexpected error responding to ${message.name}:\n${err.stack}`);
|
||||
}
|
||||
try {
|
||||
this.connector.sendResponseError(
|
||||
new libData.ResponseError(err.message, err.code, err.stack), message.src, spoofedSrc
|
||||
);
|
||||
this.connector.sendResponseError(Link.errorToResponse(err), message.src, spoofedSrc);
|
||||
} catch (subErr: any) {
|
||||
logger.error(`Unexpected error sending error response for ${message.name}:\n${subErr.stack}`);
|
||||
}
|
||||
@@ -474,7 +470,7 @@ export class Link {
|
||||
}
|
||||
|
||||
this._pendingRequests.delete(message.dst.requestId!);
|
||||
pending.reject(new libErrors.RequestError(message.data.message, message.data.code, message.data.stack));
|
||||
pending.reject(Link.errorFromResponse(message.data));
|
||||
}
|
||||
|
||||
_processEvent(message: libData.MessageEvent, entry: EventEntry) {
|
||||
@@ -823,13 +819,83 @@ export class Link {
|
||||
this._eventsByName.set(name, entry);
|
||||
this._eventsByClass.set(Event as EventClass<unknown>, entry);
|
||||
}
|
||||
|
||||
static _errorsByName = new Map<string, ErrorClass>();
|
||||
static _errorNamesByClass = new Map<ErrorClass, string>();
|
||||
|
||||
/**
|
||||
* Register an error class to be sent and received over links
|
||||
*
|
||||
* Error responses carry the registered name of the thrown error and the
|
||||
* receiving side rejects the request with an instance of the same class.
|
||||
*
|
||||
* @param Class - Error class, must take the message as the first
|
||||
* constructor argument.
|
||||
*/
|
||||
static registerError(Class: ErrorClass) {
|
||||
const name = Class.plugin ? `${Class.plugin}:${Class.name}` : Class.name;
|
||||
if (this._errorsByName.has(name)) {
|
||||
throw new Error(`Error ${name} is already registered`);
|
||||
}
|
||||
this._errorsByName.set(name, Class);
|
||||
this._errorNamesByClass.set(Class, name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registered name of the closest registered class of an error
|
||||
*
|
||||
* @param err - Error to look up.
|
||||
* @returns name or undefined if no class in its prototype chain is registered.
|
||||
*/
|
||||
static errorName(err: Error) {
|
||||
for (let proto = Object.getPrototypeOf(err); proto; proto = Object.getPrototypeOf(proto)) {
|
||||
const name = this._errorNamesByClass.get(proto.constructor);
|
||||
if (name) {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an error into the data of an error response
|
||||
*
|
||||
* Errors with no registered class are sent as InternalServerError.
|
||||
*
|
||||
* @param err - Error to convert.
|
||||
* @param withStack - Include the stack trace of the error.
|
||||
*/
|
||||
static errorToResponse(err: Error, withStack = true) {
|
||||
return new libData.ResponseError(
|
||||
err.message, this.errorName(err) ?? "InternalServerError", withStack ? err.stack : undefined
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the data of an error response back into an error
|
||||
*
|
||||
* @param response - Received error response data.
|
||||
* @returns instance of the registered class, or InternalServerError if
|
||||
* the class is not registered on this side.
|
||||
*/
|
||||
static errorFromResponse(response: libData.ResponseError) {
|
||||
const Class = response.code && this._errorsByName.get(response.code) || libErrors.InternalServerError;
|
||||
const err = new Class(response.message);
|
||||
if (response.stack !== undefined) {
|
||||
err.stack = response.stack;
|
||||
}
|
||||
return err;
|
||||
}
|
||||
}
|
||||
|
||||
for (let Class of dataClasses) {
|
||||
Link.register(Class as RequestClass<unknown, unknown>);
|
||||
}
|
||||
for (let Class of errorClasses) {
|
||||
Link.registerError(Class);
|
||||
}
|
||||
|
||||
export function registerPluginMessages(pluginInfos: PluginNodeEnvInfo[]|PluginWebpackEnvInfo[]) {
|
||||
export function registerPluginMessages(pluginInfos: PluginNodeEnvInfo[]|PluginWebEnvInfo[]) {
|
||||
for (let pluginInfo of pluginInfos) {
|
||||
for (let Class of pluginInfo.messages || []) {
|
||||
if (Class.plugin !== pluginInfo.name) {
|
||||
@@ -840,5 +906,14 @@ export function registerPluginMessages(pluginInfos: PluginNodeEnvInfo[]|PluginWe
|
||||
}
|
||||
Link.register(Class);
|
||||
}
|
||||
for (let Class of pluginInfo.errors || []) {
|
||||
if (Class.plugin !== pluginInfo.name) {
|
||||
throw new Error(
|
||||
`Expected error ${Class.name} from ${pluginInfo.name} to have a static ` +
|
||||
`plugin property set to "${pluginInfo.name}"`
|
||||
);
|
||||
}
|
||||
Link.registerError(Class);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,8 @@ import * as role from "../data/messages_role.js";
|
||||
import * as note from "../data/messages_note.js";
|
||||
import * as plugin from "../data/messages_plugin.js";
|
||||
import * as subscriptions from "../subscriptions.js";
|
||||
import type { RequestClass, EventClass } from "./link.js";
|
||||
import * as errors from "../errors.js";
|
||||
import type { RequestClass, EventClass, ErrorClass } from "./link.js";
|
||||
|
||||
/**
|
||||
* Event and Request payloads registered with the Link class. Used to decode
|
||||
@@ -147,5 +148,30 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
|
||||
plugin.PluginListRequest,
|
||||
plugin.PluginUpdateRequest,
|
||||
plugin.PluginInstallRequest,
|
||||
plugin.PluginSearchRequest,
|
||||
plugin.PluginLatestVersionsRequest,
|
||||
plugin.UpdateAllRequest,
|
||||
];
|
||||
|
||||
/**
|
||||
* Error classes registered with the Link class. Used to send errors thrown
|
||||
* by request handlers back as instances of the same class.
|
||||
* @internal
|
||||
*/
|
||||
export const errorClasses: ErrorClass[] = [
|
||||
errors.ClientError,
|
||||
errors.ValidationError,
|
||||
errors.InvalidValueError,
|
||||
errors.InvalidFieldError,
|
||||
errors.ForbiddenError,
|
||||
errors.PermissionError,
|
||||
errors.InvalidAccessError,
|
||||
errors.NotFoundError,
|
||||
errors.TimeoutError,
|
||||
errors.ConflictError,
|
||||
errors.ServerError,
|
||||
errors.InternalServerError,
|
||||
errors.UnavailableError,
|
||||
errors.InvalidMessage,
|
||||
errors.SessionLost,
|
||||
];
|
||||
@@ -5,6 +5,96 @@ import { logger } from "./logging.js";
|
||||
import * as libFileOps from "./file_ops.js";
|
||||
import vm from "vm";
|
||||
|
||||
const corePackages = new Set([
|
||||
"@clusterio/controller",
|
||||
"@clusterio/ctl",
|
||||
"@clusterio/host",
|
||||
"@clusterio/lib",
|
||||
"@clusterio/web_ui",
|
||||
]);
|
||||
|
||||
function isPluginPackageJson(packageJson: any): boolean {
|
||||
return Boolean(packageJson?.keywords?.includes?.("clusterio-plugin"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the package.json in a package directory.
|
||||
* @param packagePath - Directory of the package
|
||||
* @param tolerant - Return undefined instead of throwing if the file can't be parsed, as npm may be writing it
|
||||
* @returns The parsed package.json, or undefined if it does not exist
|
||||
*/
|
||||
export async function readPackageJson(packagePath: string, tolerant: boolean): Promise<any> {
|
||||
let content;
|
||||
try {
|
||||
content = await fs.readFile(path.join(packagePath, "package.json"), "utf8");
|
||||
} catch (err: any) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(content);
|
||||
} catch (err: any) {
|
||||
if (tolerant) {
|
||||
return undefined;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Dependencies npm installs, an optionalDependencies entry wins over a dependencies entry of the same name */
|
||||
export function installedDependencies(packageJson: any): Record<string, unknown> {
|
||||
return { ...packageJson?.dependencies, ...packageJson?.optionalDependencies };
|
||||
}
|
||||
|
||||
/** Whether a dependency spec installs from the registry, as opposed to a path, git, url, tarball or alias */
|
||||
export function isRegistrySpec(spec: unknown) {
|
||||
return typeof spec === "string" && !/[:/\\]/.test(spec) && !spec.startsWith(".")
|
||||
&& !/\.(?:tgz|tar\.gz|tar)$/i.test(spec);
|
||||
}
|
||||
|
||||
/** Real path of a directory, used to compare packages as npm and pnpm install them behind symlinks */
|
||||
export async function realDir(dir: string) {
|
||||
try {
|
||||
return await fs.realpath(dir);
|
||||
} catch {
|
||||
// Removed since, nothing else can be at this path
|
||||
return path.resolve(dir);
|
||||
}
|
||||
}
|
||||
|
||||
/** Directory of a package as require resolves it from fromPath, or undefined if not found */
|
||||
export function resolvePackagePath(packageName: string, fromPath: string, tolerant: boolean): string | undefined {
|
||||
const packageRequire = createRequire(path.join(fromPath, "package.json"));
|
||||
try {
|
||||
return path.dirname(packageRequire.resolve(`${packageName}/package.json`));
|
||||
} catch (err: any) {
|
||||
// A package.json npm is still writing fails with ERR_INVALID_PACKAGE_CONFIG
|
||||
if (tolerant || err.code === "MODULE_NOT_FOUND" || err.code === "ERR_PACKAGE_PATH_NOT_EXPORTED") {
|
||||
return undefined;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function pluginPathExists(pluginPath: string) {
|
||||
if (!path.isAbsolute(pluginPath)) {
|
||||
return resolvePackagePath(pluginPath, process.cwd(), true) !== undefined;
|
||||
}
|
||||
try {
|
||||
await fs.access(path.join(pluginPath, "package.json"));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
function getPluginName(requireSpec: string) {
|
||||
const context = vm.createContext({ require: require, pluginInfo: null });
|
||||
const code = `pluginInfo = require(${JSON.stringify(requireSpec)}).plugin;`;
|
||||
vm.runInContext(code, context);
|
||||
return context.pluginInfo.name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches for local plugins in the plugins/ and external_plugins/ directories
|
||||
* and adds them to the plugin list if they contain a package.json file.
|
||||
@@ -49,7 +139,7 @@ async function findLocalPlugins(pluginList: Map<string, string>): Promise<boolea
|
||||
const stats = await fs.stat(pluginPath);
|
||||
if (stats.isDirectory()) {
|
||||
// Check for package.json and plugin keyword
|
||||
if (await checkPackageJson(pluginPath)) {
|
||||
if (isPluginPackageJson(await readPackageJson(pluginPath, false))) {
|
||||
pluginList.set(pluginName, path.resolve(pluginPath));
|
||||
logger.info(`Added ${pluginName} from ${pluginFolder}`);
|
||||
hasChanged += 1;
|
||||
@@ -67,24 +157,85 @@ async function findLocalPlugins(pluginList: Map<string, string>): Promise<boolea
|
||||
return hasChanged > 0;
|
||||
}
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
function getPluginName(requireSpec: string) {
|
||||
const context = vm.createContext({ require: require, pluginInfo: null });
|
||||
const code = `pluginInfo = require(${JSON.stringify(requireSpec)}).plugin;`;
|
||||
vm.runInContext(code, context);
|
||||
return context.pluginInfo.name;
|
||||
type NpmPluginPackage = {
|
||||
packageName: string,
|
||||
packageVersion: unknown,
|
||||
packagePath: string,
|
||||
packageJson: any,
|
||||
};
|
||||
|
||||
/**
|
||||
* Walk the dependencies of the root package.json and of every plugin package found, without
|
||||
* traversing packages that aren't plugins. Does not execute any plugin code.
|
||||
*
|
||||
* @param dependencies - Dependencies of the root package.json
|
||||
* @param tolerant - Skip packages with a package.json that can't be parsed instead of throwing,
|
||||
* for use while npm may be rewriting them
|
||||
* @returns Plugin packages found, and root dependencies that are not plugins
|
||||
*/
|
||||
export async function discoverNpmPluginPackages(dependencies: Record<string, unknown>, tolerant = false) {
|
||||
const plugins: NpmPluginPackage[] = [];
|
||||
const nonPluginRoots: { packageName: string, packageVersion: unknown }[] = [];
|
||||
const pendingPackages = Object.entries(dependencies).map(([packageName, packageVersion]) => ({
|
||||
packageName,
|
||||
packageVersion,
|
||||
parentPath: process.cwd(),
|
||||
isRootDependency: true,
|
||||
}));
|
||||
const visitedPackages = new Set<string>();
|
||||
// Directories some dependent installs from a path, git, url, tarball or alias
|
||||
const nonRegistryDirs = new Set<string>();
|
||||
|
||||
while (pendingPackages.length) {
|
||||
const { packageName, packageVersion, parentPath, isRootDependency } = pendingPackages.shift()!;
|
||||
const packagePath = resolvePackagePath(packageName, parentPath, tolerant);
|
||||
if (!packagePath) {
|
||||
continue;
|
||||
}
|
||||
if (!isRegistrySpec(packageVersion)) {
|
||||
nonRegistryDirs.add(await realDir(packagePath));
|
||||
}
|
||||
if (visitedPackages.has(packageName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
visitedPackages.add(packageName);
|
||||
|
||||
const packageJson = await readPackageJson(packagePath, tolerant);
|
||||
if (packageJson && isPluginPackageJson(packageJson)) {
|
||||
plugins.push({ packageName, packageVersion, packagePath, packageJson });
|
||||
for (const [dependencyName, dependencyVersion] of Object.entries(installedDependencies(packageJson))) {
|
||||
pendingPackages.push({
|
||||
packageName: dependencyName,
|
||||
packageVersion: dependencyVersion,
|
||||
parentPath: packagePath,
|
||||
isRootDependency: false,
|
||||
});
|
||||
}
|
||||
} else if (isRootDependency) {
|
||||
nonPluginRoots.push({ packageName, packageVersion });
|
||||
}
|
||||
}
|
||||
return { plugins, nonPluginRoots, nonRegistryDirs };
|
||||
}
|
||||
|
||||
function resolvePackagePath(packageName: string, fromPath: string): string | undefined {
|
||||
const packageRequire = createRequire(path.join(fromPath, "package.json"));
|
||||
try {
|
||||
return path.dirname(packageRequire.resolve(`${packageName}/package.json`));
|
||||
} catch (err: any) {
|
||||
if (err.code === "MODULE_NOT_FOUND" || err.code === "ERR_PACKAGE_PATH_NOT_EXPORTED") {
|
||||
return undefined;
|
||||
}
|
||||
throw err;
|
||||
/** Real paths of plugin packages some dependent installs from a path, git, url, tarball or alias */
|
||||
export async function findNonRegistryDirs() {
|
||||
const dependencies = installedDependencies(await readPackageJson(process.cwd(), true));
|
||||
return (await discoverNpmPluginPackages(dependencies, true)).nonRegistryDirs;
|
||||
}
|
||||
|
||||
/** Whether npm install name would replace a plugin package not installed from the registry */
|
||||
export async function isNonRegistryPackage(name: string) {
|
||||
const dependencies = installedDependencies(await readPackageJson(process.cwd(), true));
|
||||
if (Object.hasOwn(dependencies, name) && !isRegistrySpec(dependencies[name])) {
|
||||
return true;
|
||||
}
|
||||
const packagePath = resolvePackagePath(name, process.cwd(), true);
|
||||
if (!packagePath) {
|
||||
return false;
|
||||
}
|
||||
return (await findNonRegistryDirs()).has(await realDir(packagePath));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,79 +243,46 @@ function resolvePackagePath(packageName: string, fromPath: string): string | und
|
||||
* Adds discovered plugins to the plugin list.
|
||||
*
|
||||
* @param pluginList - Map of plugin names to their file paths
|
||||
* @param skippedNpmPlugins - Filled with the package directory by plugin name for packages
|
||||
* skipped because their plugin name was already in the list
|
||||
* @returns Promise that resolves to true if any plugins were added to the list, false otherwise
|
||||
*/
|
||||
async function findNpmPlugins(pluginList: Map<string, string>): Promise<boolean> {
|
||||
let dependencies;
|
||||
try {
|
||||
const rootPackageJson = JSON.parse(await fs.readFile(path.resolve("package.json"), { encoding: "utf8" }));
|
||||
dependencies = rootPackageJson.dependencies;
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
// Skip if package.json doesn't exist
|
||||
return false;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (!dependencies) {
|
||||
async function findNpmPlugins(
|
||||
pluginList: Map<string, string>,
|
||||
skippedNpmPlugins?: Map<string, string>,
|
||||
): Promise<boolean> {
|
||||
const rootPackageJson = await readPackageJson(process.cwd(), false);
|
||||
if (!rootPackageJson) {
|
||||
return false;
|
||||
}
|
||||
const dependencies = installedDependencies(rootPackageJson);
|
||||
|
||||
let hasChanged = 0;
|
||||
const pendingPackages = Object.entries(dependencies).map(([packageName, packageVersion]) => ({
|
||||
packageName,
|
||||
packageVersion,
|
||||
parentPath: process.cwd(),
|
||||
isRootDependency: true,
|
||||
}));
|
||||
const corePackages = new Set([
|
||||
"@clusterio/controller",
|
||||
"@clusterio/ctl",
|
||||
"@clusterio/host",
|
||||
"@clusterio/lib",
|
||||
"@clusterio/web_ui",
|
||||
]);
|
||||
const visitedPackages = new Set<string>();
|
||||
|
||||
while (pendingPackages.length) {
|
||||
const { packageName, packageVersion, parentPath, isRootDependency } = pendingPackages.shift()!;
|
||||
if (visitedPackages.has(packageName)) {
|
||||
const { plugins, nonPluginRoots } = await discoverNpmPluginPackages(dependencies);
|
||||
for (const { packageName, packageVersion, packagePath } of plugins) {
|
||||
const pluginName = getPluginName(packagePath);
|
||||
const listedPath = pluginList.get(pluginName);
|
||||
if (listedPath === packageName) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const npmPackagePath = resolvePackagePath(packageName, parentPath);
|
||||
if (!npmPackagePath) {
|
||||
continue;
|
||||
}
|
||||
|
||||
visitedPackages.add(packageName);
|
||||
|
||||
if (await checkPackageJson(npmPackagePath)) {
|
||||
const pluginName = getPluginName(npmPackagePath);
|
||||
if (pluginList.has(pluginName)) {
|
||||
logger.warn(
|
||||
`${pluginName} provided by ${packageName}@${packageVersion} is already in the plugin list, ` +
|
||||
`but with a different path - using ${pluginList.get(pluginName)}`
|
||||
);
|
||||
} else {
|
||||
pluginList.set(pluginName, packageName);
|
||||
logger.info(`Added ${pluginName} from node modules`);
|
||||
hasChanged += 1;
|
||||
}
|
||||
|
||||
const packageJson = JSON.parse(
|
||||
await fs.readFile(path.join(npmPackagePath, "package.json"), { encoding: "utf8" })
|
||||
if (listedPath !== undefined && !await pluginPathExists(listedPath)) {
|
||||
logger.warn(`${pluginName} at ${listedPath} no longer exists, using ${packageName}`);
|
||||
pluginList.set(pluginName, packageName);
|
||||
hasChanged += 1;
|
||||
} else if (pluginList.has(pluginName)) {
|
||||
logger.warn(
|
||||
`${pluginName} provided by ${packageName}@${packageVersion} is already in the plugin list, ` +
|
||||
`but with a different path - using ${pluginList.get(pluginName)}`
|
||||
);
|
||||
for (const [dependencyName, dependencyVersion] of Object.entries(packageJson.dependencies ?? {})) {
|
||||
pendingPackages.push({
|
||||
packageName: dependencyName,
|
||||
packageVersion: dependencyVersion,
|
||||
parentPath: npmPackagePath,
|
||||
isRootDependency: false,
|
||||
});
|
||||
}
|
||||
} else if (isRootDependency && !corePackages.has(packageName)) {
|
||||
skippedNpmPlugins?.set(pluginName, await realDir(packagePath));
|
||||
} else {
|
||||
pluginList.set(pluginName, packageName);
|
||||
logger.info(`Added ${pluginName} from node modules`);
|
||||
hasChanged += 1;
|
||||
}
|
||||
}
|
||||
for (const { packageName, packageVersion } of nonPluginRoots) {
|
||||
if (!corePackages.has(packageName)) {
|
||||
logger.warn(`${packageName}@${packageVersion} is not a clusterio-plugin`);
|
||||
}
|
||||
}
|
||||
@@ -176,9 +294,15 @@ async function findNpmPlugins(pluginList: Map<string, string>): Promise<boolean>
|
||||
*
|
||||
* @param pluginListPath - Path to the plugin list JSON file
|
||||
* @param findLocal - Whether to look for local plugins in plugins/ and external_plugins/ directories
|
||||
* @param skippedNpmPlugins - Filled with the package directory by plugin name for npm packages
|
||||
* skipped because another plugin has the same name, for loadPluginInfos
|
||||
* @returns Map of plugin name to plugin path
|
||||
*/
|
||||
export async function loadPluginList(pluginListPath: string, findLocal = true): Promise<Map<string, string>> {
|
||||
export async function loadPluginList(
|
||||
pluginListPath: string,
|
||||
findLocal = true,
|
||||
skippedNpmPlugins?: Map<string, string>,
|
||||
): Promise<Map<string, string>> {
|
||||
let pluginList = new Map<string, string>();
|
||||
|
||||
// Try to load existing plugin list
|
||||
@@ -201,7 +325,7 @@ export async function loadPluginList(pluginListPath: string, findLocal = true):
|
||||
}
|
||||
|
||||
// Discover NPM plugins
|
||||
hasChanges = await findNpmPlugins(pluginList) || hasChanges;
|
||||
hasChanges = await findNpmPlugins(pluginList, skippedNpmPlugins) || hasChanges;
|
||||
|
||||
if (hasChanges) {
|
||||
await libFileOps.safeOutputFile(pluginListPath, JSON.stringify([...pluginList], null, "\t"));
|
||||
@@ -209,24 +333,3 @@ export async function loadPluginList(pluginListPath: string, findLocal = true):
|
||||
|
||||
return pluginList;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to check if a package.json file exists and contains the clusterio-plugin keyword
|
||||
* @param pluginPath - Path to the plugin directory
|
||||
* @returns Promise that resolves to true if package.json exists and has clusterio-plugin keyword
|
||||
*/
|
||||
async function checkPackageJson(pluginPath: string): Promise<boolean> {
|
||||
const packageJsonPath = path.join(pluginPath, "package.json");
|
||||
|
||||
// Read and parse package.json
|
||||
let content;
|
||||
try {
|
||||
content = await fs.readFile(packageJsonPath, { encoding: "utf8" });
|
||||
} catch (err: any) {
|
||||
return false;
|
||||
}
|
||||
const packageJson = JSON.parse(content);
|
||||
|
||||
// Check if package has the clusterio-plugin keyword
|
||||
return packageJson.keywords?.includes("clusterio-plugin");
|
||||
}
|
||||
@@ -177,9 +177,9 @@ export const logger = winston.createLogger({
|
||||
// Who in their right mind thought that log levels should be hard coded
|
||||
// into the type when you can define custom levels you want to support?
|
||||
export type Logger = Omit<winston.Logger,
|
||||
"error" | "warn" | "help" | "data" | "info" | "debug" | "prompt" | "verbose" | "input" | "silly" |
|
||||
"emerg" | "alert" | "crit" | "warning" | "notice" |
|
||||
"child"
|
||||
"error" | "warn" | "help" | "data" | "info" | "debug" | "prompt" | "verbose" | "input" | "silly"
|
||||
| "emerg" | "alert" | "crit" | "warning" | "notice"
|
||||
| "child"
|
||||
> & {
|
||||
fatal: winston.LeveledLogMethod,
|
||||
error: winston.LeveledLogMethod,
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { logger } from "./logging.js";
|
||||
import { NotFoundError, UnavailableError, ValidationError } from "./errors.js";
|
||||
import { PluginSearchRequest, PluginSearchResult } from "./data/messages_plugin.js";
|
||||
|
||||
const registryTimeoutMs = 10e3;
|
||||
|
||||
// One name segment, optionally after an @scope/ segment. Not starting with . or - keeps npm from
|
||||
// reading the spec as a path, a git repository or a command line option.
|
||||
// https://docs.npmjs.com/cli/v11/configuring-npm/package-json#name
|
||||
const packageNamePattern = /^(?:@[a-zA-Z0-9][a-zA-Z0-9\-._~]*\/)?[a-zA-Z0-9][a-zA-Z0-9\-._~]*$/;
|
||||
|
||||
/**
|
||||
* Split a package spec into its name and optional version, as in name@version. The name itself
|
||||
* may start with @ for scoped packages.
|
||||
*/
|
||||
export function parsePackageSpec(spec: string) {
|
||||
const versionSeparator = spec.lastIndexOf("@");
|
||||
const [name, version] = versionSeparator > 0
|
||||
? [spec.slice(0, versionSeparator), spec.slice(versionSeparator + 1)]
|
||||
: [spec, undefined];
|
||||
if (name.length > 214 || !packageNamePattern.test(name)) {
|
||||
throw new ValidationError(`Invalid plugin name: ${spec}`);
|
||||
}
|
||||
if (version !== undefined && !/^[a-zA-Z0-9][a-zA-Z0-9\-.+]*$/.test(version)) {
|
||||
throw new ValidationError(`Invalid plugin version: ${version}`);
|
||||
}
|
||||
return { name, version };
|
||||
}
|
||||
|
||||
/** Throws unless the package, and version if given, is published on npm, returns the spec to install */
|
||||
export async function checkRegistry(name: string, version?: string) {
|
||||
const packageName = encodeURI(name);
|
||||
const spec = version !== undefined ? `${packageName}@${version}` : packageName;
|
||||
const registryPath = version !== undefined ? `${packageName}/${encodeURIComponent(version)}` : packageName;
|
||||
const npmRequest = await fetch(`https://registry.npmjs.com/${registryPath}`, {
|
||||
method: "HEAD",
|
||||
signal: AbortSignal.timeout(registryTimeoutMs),
|
||||
});
|
||||
if (!npmRequest.ok) {
|
||||
throw new NotFoundError(`Unknown plugin: ${spec}`);
|
||||
}
|
||||
return spec;
|
||||
}
|
||||
|
||||
const latestVersionCacheMs = 5 * 60e3;
|
||||
const latestVersionCacheMaxSize = 1000;
|
||||
const latestVersionConcurrency = 8;
|
||||
|
||||
/** Latest version of each package by name, as found on npm, with when it was looked up */
|
||||
export type LatestVersionCache = Map<string, { version?: string, fetchedAt: number }>;
|
||||
|
||||
/**
|
||||
* Look up the latest published version of each package on npm, packages that can't be
|
||||
* found are left out. Lookups are kept in the given cache for a few minutes.
|
||||
*/
|
||||
export async function fetchLatestVersions(packages: string[], cache: LatestVersionCache) {
|
||||
if (packages.length > 500) {
|
||||
throw new ValidationError("Too many packages");
|
||||
}
|
||||
const names = [...new Set(packages.map(packageName => {
|
||||
const { name, version: pinned } = parsePackageSpec(packageName);
|
||||
if (pinned !== undefined) {
|
||||
throw new ValidationError(`Invalid plugin name: ${packageName}`);
|
||||
}
|
||||
return name;
|
||||
}))];
|
||||
|
||||
const now = Date.now();
|
||||
for (const [name, cached] of cache) {
|
||||
if (now - cached.fetchedAt > latestVersionCacheMs) {
|
||||
cache.delete(name);
|
||||
}
|
||||
}
|
||||
|
||||
const versions: Record<string, string> = {};
|
||||
const queue = [...names];
|
||||
async function lookUp(name: string) {
|
||||
let cached = cache.get(name);
|
||||
if (!cached) {
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(`https://registry.npmjs.com/${name}/latest`, {
|
||||
signal: AbortSignal.timeout(registryTimeoutMs),
|
||||
});
|
||||
} catch (err: any) {
|
||||
logger.warn(`Failed to look up latest version of ${name}: ${err.message}`);
|
||||
return;
|
||||
}
|
||||
if (!response.ok && response.status !== 404) {
|
||||
// Rate limits and outages are retried on the next lookup instead of cached
|
||||
logger.warn(`Failed to look up latest version of ${name}: ${response.status}`);
|
||||
return;
|
||||
}
|
||||
const version = response.ok ? (await response.json() as { version?: string }).version : undefined;
|
||||
cached = { version, fetchedAt: now };
|
||||
for (const oldest of cache.keys()) {
|
||||
if (cache.size < latestVersionCacheMaxSize) {
|
||||
break;
|
||||
}
|
||||
cache.delete(oldest);
|
||||
}
|
||||
cache.set(name, cached);
|
||||
}
|
||||
if (cached.version) {
|
||||
versions[name] = cached.version;
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({ length: latestVersionConcurrency }, async () => {
|
||||
for (let name = queue.shift(); name !== undefined; name = queue.shift()) {
|
||||
await lookUp(name);
|
||||
}
|
||||
}));
|
||||
return versions;
|
||||
}
|
||||
|
||||
const npmSearchMaxSize = 250; // Limit imposed by the registry
|
||||
|
||||
export async function searchPlugins(query: string, page: number, pageSize: number) {
|
||||
if (query.length > 214) {
|
||||
throw new ValidationError("Search query too long");
|
||||
}
|
||||
if (page < 1 || pageSize < 1 || pageSize > npmSearchMaxSize) {
|
||||
throw new ValidationError("Invalid page or page size");
|
||||
}
|
||||
|
||||
const url = new URL("https://registry.npmjs.com/-/v1/search");
|
||||
url.searchParams.set("text", `keywords:clusterio-plugin ${query}`.trim());
|
||||
url.searchParams.set("size", String(pageSize));
|
||||
url.searchParams.set("from", String((page - 1) * pageSize));
|
||||
|
||||
const response = await fetch(url, { signal: AbortSignal.timeout(registryTimeoutMs) });
|
||||
if (!response.ok) {
|
||||
throw new UnavailableError(`npm registry search failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const json = await response.json() as {
|
||||
total: number,
|
||||
objects: {
|
||||
package: {
|
||||
name: string,
|
||||
version: string,
|
||||
description?: string | null,
|
||||
date?: string | null,
|
||||
publisher?: { username?: string | null } | null,
|
||||
links?: { npm?: string | null, homepage?: string | null, repository?: string | null } | null,
|
||||
},
|
||||
}[],
|
||||
};
|
||||
|
||||
return new PluginSearchRequest.Response(
|
||||
json.total,
|
||||
json.objects.map(({ package: pkg }) => new PluginSearchResult(
|
||||
// The registry sends null for some missing fields
|
||||
pkg.name, pkg.version, pkg.description ?? undefined, pkg.publisher?.username ?? undefined,
|
||||
pkg.date ?? undefined, pkg.links?.homepage ?? undefined, pkg.links?.repository ?? undefined,
|
||||
pkg.links?.npm ?? undefined,
|
||||
)),
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
* @author Hornwitser
|
||||
* @module lib/users
|
||||
*/
|
||||
import type { PluginNodeEnvInfo, PluginWebpackEnvInfo } from "./plugin.js";
|
||||
import type { PluginNodeEnvInfo, PluginWebEnvInfo } from "./plugin.js";
|
||||
import { Permission } from "./data/index.js";
|
||||
|
||||
export const permissions = new Map<string, Permission>();
|
||||
@@ -88,7 +88,7 @@ export function definePermission({
|
||||
*
|
||||
* @param pluginInfos - Array of plugin info objects.
|
||||
*/
|
||||
export function registerPluginPermissions(pluginInfos: PluginNodeEnvInfo[] | PluginWebpackEnvInfo[]) {
|
||||
export function registerPluginPermissions(pluginInfos: PluginNodeEnvInfo[] | PluginWebEnvInfo[]) {
|
||||
for (const pluginInfo of pluginInfos) {
|
||||
for (const permission of pluginInfo.permissions ?? []) {
|
||||
if (typeof permission.name !== "string" || !permission.name.startsWith(`${pluginInfo.name}.`)) {
|
||||
@@ -583,6 +583,11 @@ export const corePermissions = [
|
||||
title: "Install plugin",
|
||||
description: "Remotely install a plugin if the target allows remote installs of plugins.",
|
||||
},
|
||||
{
|
||||
name: "core.plugin.search",
|
||||
title: "Search plugins",
|
||||
description: "Search the npm registry for plugins via the controller.",
|
||||
},
|
||||
|
||||
{
|
||||
name: "core.debug.dump_ws",
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { ConfigSchema, FieldDefinition } from "./config/index.js";
|
||||
import type { PermissionDefinition } from "./permissions.js";
|
||||
import type { PlayerStats, Role } from "./data/index.js";
|
||||
import type { SyncHook } from "./SyncHook.js";
|
||||
import type { ErrorClass } from "./link/index.js";
|
||||
|
||||
export const PluginFeatureFlags = [
|
||||
/** The plugin requires module code to be patched into the save */
|
||||
@@ -42,9 +43,10 @@ export type PluginDeclaration = {
|
||||
features?: (typeof PluginFeatureFlags)[number][];
|
||||
|
||||
messages?: any[];
|
||||
errors?: ErrorClass[];
|
||||
permissions?: PermissionDefinition[];
|
||||
routes?: string[];
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a plugin is expected to ship a web build.
|
||||
@@ -82,9 +84,14 @@ export type PluginNodeEnvInfo = PluginDeclaration & {
|
||||
* package.
|
||||
*/
|
||||
npmPackage?: string;
|
||||
/**
|
||||
* Directory of an npm package providing a plugin with the same name
|
||||
* that was skipped on startup as this plugin was already in the list.
|
||||
*/
|
||||
skippedNpmPackage?: string;
|
||||
};
|
||||
|
||||
export type PluginWebpackEnvInfo = PluginDeclaration & {
|
||||
export type PluginWebEnvInfo = PluginDeclaration & {
|
||||
container?: any;
|
||||
package?: any;
|
||||
enabled?: boolean;
|
||||
|
||||
@@ -13,6 +13,10 @@ import { loadPluginEntrypoint, loadPluginClass } from "./loadPlugin.js";
|
||||
import { Config } from "./config/classes.js";
|
||||
import { ControllerConfig, HostConfig, InstanceConfig } from "./config/definitions.js";
|
||||
import Role from "./data/Role.js";
|
||||
import { PluginDetails } from "./data/messages_plugin.js";
|
||||
import {
|
||||
discoverNpmPluginPackages, findNonRegistryDirs, installedDependencies, readPackageJson, realDir, resolvePackagePath,
|
||||
} from "./load_plugin_list.js";
|
||||
|
||||
|
||||
/**
|
||||
@@ -24,10 +28,13 @@ import Role from "./data/Role.js";
|
||||
*
|
||||
* @param pluginList -
|
||||
* Mapping of plugin name to require path for the plugins to load.
|
||||
* @param skippedNpmPlugins -
|
||||
* Directory of the npm package skipped by plugin name, from loadPluginList.
|
||||
* @returns Array of plugin info modules.
|
||||
*/
|
||||
export async function loadPluginInfos(pluginList: Map<string, string>) {
|
||||
export async function loadPluginInfos(pluginList: Map<string, string>, skippedNpmPlugins?: Map<string, string>) {
|
||||
let plugins: libPlugin.PluginNodeEnvInfo[] = [];
|
||||
const nonRegistryDirs = await findNonRegistryDirs();
|
||||
for (let [pluginName, pluginPath] of pluginList) {
|
||||
let pluginInfo: libPlugin.PluginNodeEnvInfo;
|
||||
let pluginPackage: { name?: string, version: string, main?: string, private?: boolean };
|
||||
@@ -87,12 +94,103 @@ export async function loadPluginInfos(pluginList: Map<string, string>) {
|
||||
pluginInfo.packagePath = packagePath;
|
||||
pluginInfo.requirePath = pluginPath;
|
||||
pluginInfo.version = pluginPackage.version;
|
||||
pluginInfo.npmPackage = !pluginPackage.private && pluginPath === pluginPackage.name ? pluginPath : undefined;
|
||||
pluginInfo.npmPackage = !pluginPackage.private && pluginPath === pluginPackage.name
|
||||
&& !nonRegistryDirs.has(await fs.realpath(path.dirname(packagePath))) ? pluginPath : undefined;
|
||||
pluginInfo.skippedNpmPackage = skippedNpmPlugins?.get(pluginName);
|
||||
plugins.push(pluginInfo);
|
||||
}
|
||||
return plugins;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists the plugins on this machine along with the versions currently installed on disk,
|
||||
* including npm plugins installed since startup that will only be loaded after a restart.
|
||||
*
|
||||
* @param pluginInfos - Plugins discovered when this machine started
|
||||
* @param isLoaded - Whether the plugin is loaded
|
||||
* @param isEnabled - Whether the plugin is enabled
|
||||
* @param hasEntrypoint - Whether the plugin has an entrypoint this machine loads
|
||||
* @returns Details of every plugin on this machine
|
||||
*/
|
||||
export async function listPluginDetails(
|
||||
pluginInfos: libPlugin.PluginNodeEnvInfo[],
|
||||
isLoaded: (pluginInfo: libPlugin.PluginNodeEnvInfo) => boolean,
|
||||
isEnabled: (pluginInfo: libPlugin.PluginNodeEnvInfo) => boolean,
|
||||
hasEntrypoint: (pluginInfo: libPlugin.PluginNodeEnvInfo) => boolean = () => true,
|
||||
): Promise<PluginDetails[]> {
|
||||
const plugins: PluginDetails[] = [];
|
||||
const dependencies = installedDependencies(await readPackageJson(process.cwd(), true));
|
||||
// By directory, as a plugin loaded from a path may also be an npm dependency
|
||||
const knownDirs = new Set<string>();
|
||||
// Packages skipped on startup by directory, for plugins still installed
|
||||
const takenDirs = new Set<string>();
|
||||
const { plugins: npmPlugins, nonRegistryDirs } = await discoverNpmPluginPackages(dependencies, true);
|
||||
for (const pluginInfo of pluginInfos) {
|
||||
const dir = await realDir(path.dirname(pluginInfo.packagePath));
|
||||
knownDirs.add(dir);
|
||||
const loaded = isLoaded(pluginInfo);
|
||||
// Without an entrypoint for this machine the plugin has no load_plugin field here
|
||||
const plugin = PluginDetails.fromNodeEnvInfo(
|
||||
pluginInfo,
|
||||
hasEntrypoint(pluginInfo) ? PluginDetails.loadState(loaded, isEnabled(pluginInfo)) : "noEntrypoint",
|
||||
);
|
||||
if (Object.hasOwn(dependencies, pluginInfo.requirePath)) {
|
||||
plugin.dependencyName = pluginInfo.requirePath;
|
||||
}
|
||||
if (nonRegistryDirs.has(dir)) {
|
||||
plugin.npmPackage = undefined;
|
||||
}
|
||||
let packageJson;
|
||||
try {
|
||||
packageJson = await readPackageJson(path.dirname(pluginInfo.packagePath), false);
|
||||
if (packageJson === undefined) {
|
||||
// Uninstalled since startup, it won't be found on the next restart
|
||||
plugin.state = loaded ? "removedLoaded" : "removed";
|
||||
}
|
||||
} catch {
|
||||
// Ignored: npm is still writing it
|
||||
}
|
||||
if (packageJson) {
|
||||
plugin.installedVersion = packageJson.version;
|
||||
if (packageJson.name !== plugin.npmPackage) {
|
||||
// Replaced by an alias since startup
|
||||
plugin.npmPackage = undefined;
|
||||
}
|
||||
}
|
||||
if (!plugin.removed && pluginInfo.skippedNpmPackage !== undefined) {
|
||||
takenDirs.add(pluginInfo.skippedNpmPackage);
|
||||
}
|
||||
plugins.push(plugin);
|
||||
}
|
||||
|
||||
for (const { packageName, packagePath, packageJson } of npmPlugins) {
|
||||
const dir = await realDir(packagePath);
|
||||
if (knownDirs.has(dir)) {
|
||||
continue;
|
||||
}
|
||||
// Startup loads it by package name from the root, which doesn't reach nested packages
|
||||
const rootPath = resolvePackagePath(packageName, process.cwd(), true);
|
||||
if (!rootPath || await realDir(rootPath) !== dir) {
|
||||
continue;
|
||||
}
|
||||
if (typeof packageJson.version !== "string") {
|
||||
continue;
|
||||
}
|
||||
// Same rule as loadPluginInfos, aliases, private and non-registry packages can't be installed by this name
|
||||
const npmPackage = !packageJson.private && packageJson.name === packageName && !nonRegistryDirs.has(dir)
|
||||
? packageName : undefined;
|
||||
const dependencyName = Object.hasOwn(dependencies, packageName) ? packageName : undefined;
|
||||
const plugin = PluginDetails.fromPackageJson(packageName, packageJson, npmPackage, dependencyName);
|
||||
if (takenDirs.has(dir)) {
|
||||
// Skipped on startup as its plugin name is taken, will be again
|
||||
plugin.state = "nameTaken";
|
||||
}
|
||||
plugins.push(plugin);
|
||||
}
|
||||
|
||||
return plugins;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the migration entrypoints of plugins
|
||||
*
|
||||
|
||||
+80
-25
@@ -2,8 +2,11 @@ import util from "util";
|
||||
import path from "path";
|
||||
import { exec } from "child_process";
|
||||
import { logger } from "./logging.js";
|
||||
import { RequestError } from "./errors.js";
|
||||
import { ConflictError, NotFoundError, UnavailableError } from "./errors.js";
|
||||
import type { PluginNodeEnvInfo } from "./plugin.js";
|
||||
import { isNonRegistryPackage } from "./load_plugin_list.js";
|
||||
import { LockFile, LockFileExistsError, LockFileRecoveryError } from "./LockFile.js";
|
||||
import { checkRegistry, parsePackageSpec } from "./npm_registry.js";
|
||||
const execAsync = util.promisify(exec);
|
||||
|
||||
function isDev() {
|
||||
@@ -12,10 +15,55 @@ function isDev() {
|
||||
return import.meta.dirname.split(path.sep).at(-5) === "packages"; // opposed to "@clusterio"
|
||||
}
|
||||
|
||||
async function logExec(cmd: string) {
|
||||
logger.audit(`RCE | ${cmd}`);
|
||||
if (!isDev()) {
|
||||
await execAsync(cmd);
|
||||
const operationInProgressMessage = "Another package operation is already in progress on this machine";
|
||||
|
||||
let operationInProgress = false;
|
||||
|
||||
/**
|
||||
* Run an npm command, at most one at a time per installation. npm runs in the working directory,
|
||||
* which a standalone controller and host share, so a lock file there guards against operations
|
||||
* from other processes and other copies of lib. The flag makes concurrent calls through this copy
|
||||
* reject without racing a release of the lock.
|
||||
*
|
||||
* @param cmd - Command to run
|
||||
* @param check - Called while holding the lock before running the command, may throw to cancel it
|
||||
*/
|
||||
async function logExec(cmd: string, check?: () => Promise<void>) {
|
||||
// Checked and set before the first await so concurrent calls in this process can't interleave
|
||||
if (operationInProgress) {
|
||||
throw new ConflictError(operationInProgressMessage);
|
||||
}
|
||||
operationInProgress = true;
|
||||
const lockPath = path.resolve("npm-operation.lock");
|
||||
// Signal listeners would change how the process exits. If only this process is killed npm keeps
|
||||
// running unlocked, Ctrl+C, systemd and containers stop it too.
|
||||
const lock = new LockFile(lockPath, false);
|
||||
try {
|
||||
try {
|
||||
await lock.acquire();
|
||||
} catch (err) {
|
||||
if (err instanceof LockFileRecoveryError) {
|
||||
throw new UnavailableError(err.message);
|
||||
}
|
||||
if (!(err instanceof LockFileExistsError)) {
|
||||
throw err;
|
||||
}
|
||||
const holder = await lock.holderPid();
|
||||
throw new ConflictError(
|
||||
`${operationInProgressMessage}${holder ? ` (${lockPath} is held by process ${holder})` : ""}`
|
||||
);
|
||||
}
|
||||
try {
|
||||
await check?.();
|
||||
logger.audit(`RCE | ${cmd}`);
|
||||
if (!isDev()) {
|
||||
await execAsync(cmd);
|
||||
}
|
||||
} finally {
|
||||
await lock.release();
|
||||
}
|
||||
} finally {
|
||||
operationInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,16 +71,13 @@ export async function updatePackage(...names: string[]) {
|
||||
return logExec(`npm update --save ${names.join(" ")}`);
|
||||
}
|
||||
|
||||
export async function installPackage(name: string) {
|
||||
return logExec(`npm install --save ${name}`);
|
||||
export async function installPackage(name: string, check?: () => Promise<void>) {
|
||||
return logExec(`npm install --save ${name}`, check);
|
||||
}
|
||||
|
||||
export async function handlePluginUpdate(pluginName: string, pluginInfos: PluginNodeEnvInfo[]) {
|
||||
if (!pluginInfos.some(plugin => plugin.npmPackage === pluginName)) {
|
||||
throw new RequestError(`Plugin ${pluginName} is not installed on this machine`);
|
||||
}
|
||||
|
||||
return await updatePackage(pluginName);
|
||||
/** Whether an npm operation is running in this installation, from this or another process */
|
||||
export async function packageOperationRunning() {
|
||||
return operationInProgress || !await new LockFile(path.resolve("npm-operation.lock")).isStale();
|
||||
}
|
||||
|
||||
export async function handleUpdateAll(corePackage: string, pluginInfos: PluginNodeEnvInfo[]) {
|
||||
@@ -42,20 +87,30 @@ export async function handleUpdateAll(corePackage: string, pluginInfos: PluginNo
|
||||
return await updatePackage(corePackage, ...pluginPackages);
|
||||
}
|
||||
|
||||
export async function handlePluginInstall(pluginName: string) {
|
||||
if (pluginName.length > 214 || /[^a-zA-Z0-9\-_.+@\/]/.test(pluginName)) {
|
||||
// https://docs.npmjs.com/cli/v11/configuring-npm/package-json#name
|
||||
// https://www.npmjs.com/package/validate-npm-package-name
|
||||
throw new RequestError(`Invalid plugin name: ${pluginName}`);
|
||||
/** Throws if the dependency by this name is a path, git, url or alias that installing by name would replace */
|
||||
async function checkNotLocalDependency(name: string) {
|
||||
if (await isNonRegistryPackage(name)) {
|
||||
throw new ConflictError(`${name} is not installed from npm on this machine`);
|
||||
}
|
||||
}
|
||||
|
||||
const packageName = encodeURI(pluginName);
|
||||
const npmRequest = await fetch(`https://registry.npmjs.com/${packageName}`, {
|
||||
method: "HEAD",
|
||||
});
|
||||
if (!npmRequest.ok) {
|
||||
throw new RequestError(`Unknown plugin: ${packageName}`);
|
||||
export async function handlePluginUpdate(pluginName: string, pluginInfos: PluginNodeEnvInfo[]) {
|
||||
// Optionally pinned to a version as name@version to update past the range in package.json
|
||||
const { name, version } = parsePackageSpec(pluginName);
|
||||
if (!pluginInfos.some(plugin => plugin.npmPackage === name)) {
|
||||
throw new NotFoundError(`Plugin ${name} is not installed on this machine`);
|
||||
}
|
||||
|
||||
return await installPackage(packageName);
|
||||
if (version === undefined) {
|
||||
return await updatePackage(name);
|
||||
}
|
||||
// Checked under the lock as another operation may change where it's installed from
|
||||
return await installPackage(await checkRegistry(name, version), () => checkNotLocalDependency(name));
|
||||
}
|
||||
|
||||
/** Install a plugin, optionally pinned to a version as name@version, returns the package name */
|
||||
export async function handlePluginInstall(pluginName: string) {
|
||||
const { name, version } = parsePackageSpec(pluginName);
|
||||
await installPackage(await checkRegistry(name, version), () => checkNotLocalDependency(name));
|
||||
return name;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { spawn } from "child_process";
|
||||
import { loadPluginList } from "./load_plugin_list.js";
|
||||
import * as libConfig from "./config/index.js";
|
||||
import * as libFileOps from "./file_ops.js";
|
||||
import { InvalidFieldError, InvalidValueError } from "./errors.js";
|
||||
import { logger } from "./logging.js";
|
||||
import * as libHelpers from "./helpers.js";
|
||||
import { LockFile } from "./LockFile.js";
|
||||
@@ -174,7 +175,7 @@ export async function handlePluginCommand(
|
||||
try {
|
||||
const pluginPackage = (await import(
|
||||
pathToFileURL(path.posix.join(pluginPath, "package.json")).href,
|
||||
{ with: { type: "json" }},
|
||||
{ with: { type: "json" }}
|
||||
)).default;
|
||||
pluginInfo = (await import(
|
||||
pathToFileURL(path.posix.join(pluginPath, pluginPackage.main ?? "index.js")).href
|
||||
@@ -271,7 +272,7 @@ export async function handleConfigCommand(
|
||||
try {
|
||||
print(instance.get(args.field as string));
|
||||
} catch (err) {
|
||||
if (err instanceof libConfig.InvalidField) {
|
||||
if (err instanceof InvalidFieldError) {
|
||||
logger.error(err.message);
|
||||
} else {
|
||||
throw err;
|
||||
@@ -292,7 +293,7 @@ export async function handleConfigCommand(
|
||||
instance.set(args.field as string, args.value);
|
||||
await instance.save();
|
||||
} catch (err) {
|
||||
if (err instanceof libConfig.InvalidField || err instanceof libConfig.InvalidValue) {
|
||||
if (err instanceof InvalidFieldError || err instanceof InvalidValueError) {
|
||||
logger.error(err.message);
|
||||
} else {
|
||||
throw err;
|
||||
|
||||
@@ -3,11 +3,11 @@ import { Link, Event, EventClass, RequestHandler, WebSocketBaseConnector } from
|
||||
import { Address, MessageRequest, IUser, JsonBoolean, StringEnum } from "./data/index.js";
|
||||
import isDeepStrictEqual from "#is_deep_strict_equal";
|
||||
import type { PermissionName } from "./permissions.js";
|
||||
import { RequestError } from "./errors.js";
|
||||
import { SessionLost, ValidationError } from "./errors.js";
|
||||
import { logger } from "./logging.js";
|
||||
|
||||
export type SubscriptionRequestHandler<T> = RequestHandler<SubscriptionRequest, Event<T> | null>;
|
||||
export type EventSubscriberCallback<T> = (event: T | null, synced: boolean) => void
|
||||
export type EventSubscriberCallback<T> = (event: T | null, synced: boolean) => void;
|
||||
|
||||
/**
|
||||
* Represents a set of string subscription filters.
|
||||
@@ -241,7 +241,7 @@ type Subscriber = {
|
||||
link: Link,
|
||||
address: Address,
|
||||
filters: SubscriptionFilters,
|
||||
}
|
||||
};
|
||||
|
||||
type EventData = {
|
||||
subscriptionUpdate?: SubscriptionRequestHandler<unknown>,
|
||||
@@ -351,7 +351,7 @@ export class SubscriptionController {
|
||||
}
|
||||
}
|
||||
if (count > this._maxFilters) {
|
||||
throw new RequestError(`Subscription exceeds the limit of ${this._maxFilters} filters`);
|
||||
throw new ValidationError(`Subscription exceeds the limit of ${this._maxFilters} filters`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -685,7 +685,7 @@ export class EventSubscriber<E, S = null> {
|
||||
this._notify(null);
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (!(err instanceof RequestError) || err.code !== "SessionLost") {
|
||||
if (!(err instanceof SessionLost)) {
|
||||
logger.error(`Unexpected error updating ${entry.name} subscription:\n${err.stack}`);
|
||||
}
|
||||
if (err instanceof Error) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import fs from "node:fs/promises";
|
||||
import os from "os";
|
||||
import util from "util";
|
||||
import { Gauge } from "./prometheus.js";
|
||||
import { SystemInfo } from "./data/index.js";
|
||||
import { PluginDetails, SystemInfo } from "./data/index.js";
|
||||
|
||||
function filterCpuModel() {
|
||||
const model = os.cpus()[0].model;
|
||||
@@ -125,7 +125,8 @@ export async function gatherSystemInfo(
|
||||
id: SystemInfo["id"],
|
||||
canRestart: boolean,
|
||||
restartRequired: boolean,
|
||||
startedAtMs: number
|
||||
startedAtMs: number,
|
||||
plugins?: PluginDetails[],
|
||||
) {
|
||||
const cpus = os.cpus();
|
||||
const currentTotalCpuMs = cpus.map(({ times }) => times.user + times.idle + times.irq + times.nice + times.sys);
|
||||
@@ -160,5 +161,6 @@ export async function gatherSystemInfo(
|
||||
startedAtMs,
|
||||
now,
|
||||
false,
|
||||
plugins,
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
"./src/config/*.ts",
|
||||
"./src/data/*.ts",
|
||||
"./src/external/*.ts",
|
||||
"./src/browser_polyfills.ts",
|
||||
"./src/check_singleton_import.ts",
|
||||
"./src/datastore.ts",
|
||||
"./src/errors.ts",
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"entryPoints": ["index.ts"],
|
||||
"includeVersion": true
|
||||
}
|
||||
Vendored
-3
@@ -22,6 +22,3 @@ declare module "*.md" {
|
||||
declare const webRoot: string;
|
||||
/** Path static assets are served under, ends with a slash. */
|
||||
declare const staticRoot: string;
|
||||
|
||||
declare const __webpack_init_sharing__: (shareScope: string) => Promise<void>;
|
||||
declare const __webpack_share_scopes__: { default: any };
|
||||
@@ -29,44 +29,37 @@
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
"node": "^22.18.0 || >=24.2.0"
|
||||
},
|
||||
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^5.6.1",
|
||||
"@ant-design/icons": "catalog:",
|
||||
"@clusterio/lib": "workspace:^",
|
||||
"@sinclair/typebox": "catalog:",
|
||||
"@swc/core": "catalog:",
|
||||
"@vitejs/plugin-react": "catalog:",
|
||||
"antd": "catalog:",
|
||||
"assert": "^2.1.0",
|
||||
"browserify-zlib": "^0.2.0",
|
||||
"buffer": "^6.0.3",
|
||||
"clean-webpack-plugin": "^4.0.0",
|
||||
"css-loader": "^7.1.5",
|
||||
"events": "^3.3.0",
|
||||
"file-loader": "^6.2.0",
|
||||
"null-loader": "^4.0.1",
|
||||
"os-browserify": "^0.3.0",
|
||||
"path-browserify": "^1.0.1",
|
||||
"process": "^0.11.10",
|
||||
"rc-field-form": "^1.44.0",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"react-markdown": "catalog:",
|
||||
"react-router": "catalog:",
|
||||
"react-router-dom": "catalog:",
|
||||
"semver": "catalog:",
|
||||
"stream-browserify": "^3.0.0",
|
||||
"style-loader": "^4.0.0",
|
||||
"swc-loader": "^0.2.7",
|
||||
"terser-webpack-plugin": "^5.6.1",
|
||||
"typescript": "catalog:",
|
||||
"util": "^0.12.5",
|
||||
"webpack": "catalog:",
|
||||
"webpack-cli": "catalog:",
|
||||
"webpack-manifest-plugin": "^6.0.1"
|
||||
"vite": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-dom": "^18.3.7"
|
||||
"@types/react-dom": "^18.3.7",
|
||||
"@types/semver": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -144,7 +144,7 @@ export type WebPluginContext = lib.PluginLoadContext<{
|
||||
control: Control;
|
||||
container: any;
|
||||
package: any;
|
||||
}, lib.PluginWebpackEnvInfo>;
|
||||
}, lib.PluginWebEnvInfo>;
|
||||
|
||||
/**
|
||||
* Collection of web plugin hooks
|
||||
@@ -258,7 +258,7 @@ export class BaseWebPlugin {
|
||||
constructor(
|
||||
public container: any,
|
||||
packageData: any,
|
||||
public info: lib.PluginWebpackEnvInfo,
|
||||
public info: lib.PluginWebEnvInfo,
|
||||
public control: Control,
|
||||
public logger: lib.Logger,
|
||||
) {
|
||||
|
||||
@@ -16,22 +16,7 @@ import { pages } from "./pages";
|
||||
|
||||
const { ConsoleTransport, WebConsoleFormat, logger } = lib;
|
||||
|
||||
async function loadScript(url: string) {
|
||||
let script = document.createElement("script");
|
||||
script.src = url;
|
||||
script.type = "text/javascript";
|
||||
script.async = true;
|
||||
|
||||
let result = new Promise((resolve, reject) => {
|
||||
script.onload = resolve;
|
||||
script.onerror = reject;
|
||||
});
|
||||
|
||||
document.head.appendChild(script);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function loadPluginInfos(): Promise<[lib.PluginWebpackEnvInfo[], string]> {
|
||||
async function loadPluginInfos(): Promise<[lib.PluginWebEnvInfo[], string]> {
|
||||
let response = await fetch(`${webRoot}api/plugins`);
|
||||
let pluginList: lib.PluginWebApi[];
|
||||
if (response.ok) {
|
||||
@@ -43,8 +28,7 @@ async function loadPluginInfos(): Promise<[lib.PluginWebpackEnvInfo[], string]>
|
||||
}
|
||||
let pluginSetKey = loadedPluginSetKey(pluginList);
|
||||
|
||||
let pluginInfos: lib.PluginWebpackEnvInfo[] = [];
|
||||
await __webpack_init_sharing__("default");
|
||||
let pluginInfos: lib.PluginWebEnvInfo[] = [];
|
||||
for (let meta of pluginList) {
|
||||
if (meta.web.error) {
|
||||
logger.error(`Failed to load plugin ${meta.name}: ${meta.web.error}`);
|
||||
@@ -54,15 +38,13 @@ async function loadPluginInfos(): Promise<[lib.PluginWebpackEnvInfo[], string]>
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await loadScript(`${staticRoot}${meta.web.main}`);
|
||||
let container: any = (window as { [key: string]: any })[`plugin_${meta.name}`];
|
||||
if (!container) {
|
||||
throw new Error(`Plugin did not expose its container via plugin_${meta.name}`);
|
||||
let container = await import(/* @vite-ignore */ `${staticRoot}${meta.web.main}`);
|
||||
if (typeof container.get !== "function") {
|
||||
throw new Error("Plugin web bundle does not export get");
|
||||
}
|
||||
await container.init(__webpack_share_scopes__.default);
|
||||
let pluginInfo = (await container.get("./"))().plugin;
|
||||
let pluginInfo = (await container.get("./")).plugin;
|
||||
pluginInfo.container = container;
|
||||
pluginInfo.package = (await container.get("./package.json"))();
|
||||
pluginInfo.package = (await container.get("./package.json")).default;
|
||||
pluginInfo.enabled = meta.enabled;
|
||||
pluginInfos.push(pluginInfo);
|
||||
|
||||
@@ -83,7 +65,7 @@ async function loadPluginInfos(): Promise<[lib.PluginWebpackEnvInfo[], string]>
|
||||
}
|
||||
|
||||
async function loadPlugins(
|
||||
pluginInfos: lib.PluginWebpackEnvInfo[],
|
||||
pluginInfos: lib.PluginWebEnvInfo[],
|
||||
control: Control
|
||||
) {
|
||||
const plugins = new Map<string, WebPlugin.BaseWebPlugin>();
|
||||
@@ -94,8 +76,7 @@ async function loadPlugins(
|
||||
}
|
||||
|
||||
try {
|
||||
const moduleFactory = await pluginInfo.container.get(pluginInfo.webEntrypoint);
|
||||
const webModule = moduleFactory();
|
||||
const webModule = await pluginInfo.container.get(pluginInfo.webEntrypoint);
|
||||
|
||||
const pluginContext: WebPlugin.WebPluginContext = {
|
||||
control,
|
||||
|
||||
@@ -157,7 +157,7 @@ npm init "@clusterio" -- --controller-token ${token} --mode "host" --download-he
|
||||
</>;
|
||||
}
|
||||
|
||||
function CopyButton({ text, message }: { text:string, message:string }) {
|
||||
function CopyButton({ text, message }: { text: string, message: string }) {
|
||||
let [clipboardPermision, setClipboardPermission] = useState<PermissionState>("granted");
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
|
||||
@@ -25,8 +25,10 @@ export default function InstanceControlButton(props: InstanceControlButtonProps)
|
||||
|
||||
const instanceId = props.instance.id;
|
||||
const instanceStatus = props.instance.status;
|
||||
const showStart = ["stopped", "stopping"].includes(instanceStatus);
|
||||
const showStop = ["running", "starting"].includes(instanceStatus);
|
||||
// While starting or stopping the button shows the action in progress as loading.
|
||||
const showStart = ["stopped", "starting"].includes(instanceStatus);
|
||||
const showStop = ["running", "stopping"].includes(instanceStatus);
|
||||
const transitioning = ["starting", "stopping"].includes(instanceStatus);
|
||||
|
||||
function actionFinish() {
|
||||
setSwitching(false);
|
||||
@@ -37,7 +39,7 @@ export default function InstanceControlButton(props: InstanceControlButtonProps)
|
||||
|
||||
const actions: VariableDropdownButtonProps["actions"] = [];
|
||||
|
||||
if (account.hasPermission("core.instance.start") && showStart !== switching) {
|
||||
if (account.hasPermission("core.instance.start") && showStart) {
|
||||
actions.push({
|
||||
key: "start",
|
||||
label: "Start",
|
||||
@@ -101,7 +103,7 @@ export default function InstanceControlButton(props: InstanceControlButtonProps)
|
||||
{...props}
|
||||
type="primary"
|
||||
actions={actions}
|
||||
loading={switching}
|
||||
loading={switching || transitioning}
|
||||
disabled={!(showStart || showStop)}
|
||||
/>
|
||||
<Popconfirm
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import React, { Fragment, memo, useCallback, useEffect, useContext, useRef, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { FieldData } from "rc-field-form/lib/interface";
|
||||
|
||||
import {
|
||||
Button, Card, ColorPicker, Checkbox, Col, ConfigProvider, Descriptions, Form, Input, Pagination,
|
||||
Popconfirm, Row, Table, Tag, Typography, Select, Skeleton, Space, Spin, Switch, Modal, Tooltip,
|
||||
TablePaginationConfig, Alert, notification,
|
||||
TablePaginationConfig, Alert, notification, FormProps,
|
||||
} from "antd";
|
||||
|
||||
import {
|
||||
@@ -35,6 +34,8 @@ const { logger } = lib;
|
||||
const { Text } = Typography;
|
||||
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
|
||||
|
||||
type FieldData = NonNullable<FormProps["fields"]>[number];
|
||||
|
||||
type ModChange =
|
||||
{
|
||||
type: "name" | "description"
|
||||
@@ -264,7 +265,7 @@ type DownloadDependenciesProps = {
|
||||
mods: lib.ModRecord[];
|
||||
onChange: (change: ModChange) => void;
|
||||
builtInModNames: string[];
|
||||
}
|
||||
};
|
||||
function DownloadDependenciesButton(props: DownloadDependenciesProps) {
|
||||
const control = useContext(ControlContext);
|
||||
const [modInfos] = useMods();
|
||||
@@ -904,7 +905,7 @@ type SettingsTableFieldProps = {
|
||||
allowedValues: any;
|
||||
locale: Map<string, any>;
|
||||
changed: boolean;
|
||||
}
|
||||
};
|
||||
const SettingsTableField = memo((props: SettingsTableFieldProps) => {
|
||||
const name = props.name;
|
||||
const isBoolean = props.type === "bool-setting";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import { Button, Checkbox, Form, FormInstance, Input, Modal, Radio, Select } from "antd";
|
||||
import { Button, Checkbox, Modal, Tooltip } from "antd";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
|
||||
@@ -7,92 +7,7 @@ import { useAccount } from "../model/account";
|
||||
import { notifyErrorHandler } from "../util/notify";
|
||||
import ControlContext from "./ControlContext";
|
||||
|
||||
type NpmActionProps = {
|
||||
setApplyAction(func: () => Promise<void>): void,
|
||||
form: FormInstance,
|
||||
target: lib.AddressShorthand,
|
||||
}
|
||||
|
||||
function NpmRemoteUpdate({ setApplyAction, form, target }: NpmActionProps) {
|
||||
const control = useContext(ControlContext);
|
||||
|
||||
setApplyAction(async () => {
|
||||
if (target === "controller") {
|
||||
return await control.send(new lib.ControllerUpdateRequest());
|
||||
}
|
||||
return await control.sendTo(target, new lib.HostUpdateRequest());
|
||||
});
|
||||
|
||||
return <>
|
||||
</>;
|
||||
}
|
||||
|
||||
function NpmUpdateAll({ setApplyAction, form, target }: NpmActionProps) {
|
||||
const control = useContext(ControlContext);
|
||||
|
||||
setApplyAction(async () => {
|
||||
await control.sendTo(target, new lib.UpdateAllRequest());
|
||||
});
|
||||
|
||||
return <>
|
||||
</>;
|
||||
}
|
||||
|
||||
function NpmPluginUpdate({ setApplyAction, form, target }: NpmActionProps) {
|
||||
const control = useContext(ControlContext);
|
||||
const [plugins, setPlugins] = useState<lib.PluginDetails[]>([]);
|
||||
|
||||
setApplyAction(async () => {
|
||||
const values = form.getFieldsValue();
|
||||
if (!values.plugin || values.plugin === "") {
|
||||
throw new Error("Plugin not provided");
|
||||
}
|
||||
await control.sendTo(target, new lib.PluginUpdateRequest(values.plugin));
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
control.sendTo(target, new lib.PluginListRequest()).then(newPlugins => {
|
||||
setPlugins(newPlugins);
|
||||
}).catch(notifyErrorHandler("Error fetching role list"));
|
||||
}, [target]);
|
||||
|
||||
return <>
|
||||
<Form.Item label="Plugin" name="plugin">
|
||||
<Select
|
||||
showSearch
|
||||
placeholder="Select a plugin"
|
||||
optionLabelProp="label"
|
||||
options={plugins
|
||||
.filter(p => p.npmPackage !== undefined)
|
||||
.map(plugin => ({
|
||||
value: plugin.npmPackage,
|
||||
label: `${plugin.title} (v${plugin.version})`,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>;
|
||||
}
|
||||
|
||||
function NpmPluginInstall({ setApplyAction, form, target }: NpmActionProps) {
|
||||
const control = useContext(ControlContext);
|
||||
|
||||
setApplyAction(async () => {
|
||||
const values = form.getFieldsValue();
|
||||
if (!values.newPlugin || values.newPlugin === "") {
|
||||
throw new Error("Plugin not provided");
|
||||
}
|
||||
await control.sendTo(target, new lib.PluginInstallRequest(values.newPlugin));
|
||||
});
|
||||
|
||||
// TODO custom npm search based on tags
|
||||
return <>
|
||||
<Form.Item label="Plugin" name="newPlugin">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</>;
|
||||
}
|
||||
|
||||
/** Updates Clusterio on a machine, plugins are installed and updated from the plugins page */
|
||||
export function NpmButton(props: {
|
||||
target: "controller" | { hostId: number },
|
||||
canRestart?: boolean,
|
||||
@@ -101,138 +16,72 @@ export function NpmButton(props: {
|
||||
const control = useContext(ControlContext);
|
||||
const account = useAccount();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [formAction, setFormAction] = useState<string | undefined>(undefined);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const [allows, setAllows] = useState({
|
||||
remoteUpdates: true, pluginUpdates: true, pluginInstall: true,
|
||||
});
|
||||
const [restart, setRestart] = useState(false);
|
||||
const [allowed, setAllowed] = useState(true);
|
||||
|
||||
const isController = props.target === "controller";
|
||||
const hostId = props.target === "controller" ? undefined : props.target.hostId;
|
||||
useEffect(() => {
|
||||
// Without config permission assume it's allowed so the request can still be sent
|
||||
if (!account.hasPermission(isController ? "core.controller.get_config" : "core.host.get_config")) {
|
||||
setAllowed(true);
|
||||
return;
|
||||
}
|
||||
if (props.target === "controller") {
|
||||
if (!account.hasPermission("core.controller.get_config")) {
|
||||
setAllows({ remoteUpdates: true, pluginUpdates: true, pluginInstall: true });
|
||||
return; // If a user doesn't have config perms, then assume true to allow the sending the request
|
||||
}
|
||||
control.send(new lib.ControllerConfigGetRequest()).then(async (serializedConfig) => {
|
||||
control.send(new lib.ControllerConfigGetRequest()).then(serializedConfig => {
|
||||
const config = lib.ControllerConfig.fromJSON(serializedConfig, "control");
|
||||
setAllows({
|
||||
remoteUpdates: config.get("controller.allow_remote_updates"),
|
||||
pluginUpdates: config.get("controller.allow_plugin_updates"),
|
||||
pluginInstall: config.get("controller.allow_plugin_install"),
|
||||
});
|
||||
setAllowed(config.get("controller.allow_remote_updates"));
|
||||
}).catch(notifyErrorHandler("Failed to fetch controller config for remote updates"));
|
||||
} else {
|
||||
if (!account.hasPermission("core.host.get_config")) {
|
||||
setAllows({ remoteUpdates: true, pluginUpdates: true, pluginInstall: true });
|
||||
return; // If a user doesn't have config perms, then assume true to allow the sending the request
|
||||
}
|
||||
control.sendTo(props.target, new lib.HostConfigGetRequest()).then(async (serializedConfig) => {
|
||||
control.sendTo(props.target, new lib.HostConfigGetRequest()).then(serializedConfig => {
|
||||
const config = lib.HostConfig.fromJSON(serializedConfig, "control");
|
||||
setAllows({
|
||||
remoteUpdates: config.get("host.allow_remote_updates"),
|
||||
pluginUpdates: config.get("host.allow_plugin_updates"),
|
||||
pluginInstall: config.get("host.allow_plugin_install"),
|
||||
});
|
||||
setAllowed(config.get("host.allow_remote_updates"));
|
||||
}).catch(notifyErrorHandler("Failed to fetch host config for remote updates"));
|
||||
}
|
||||
}, [hostId]);
|
||||
|
||||
const coreUpdatePermission = `core.${props.target === "controller" ? "controller" : "host"}.update` as const;
|
||||
|
||||
function onValuesChange({ action } : { action?: string }) {
|
||||
if (action) {
|
||||
setFormAction(action);
|
||||
}
|
||||
}
|
||||
|
||||
let applyAction: () => Promise<void>;
|
||||
const setApplyAction = (func: () => Promise<void>) => { applyAction = func; };
|
||||
const canRestart = account.hasPermission(isController ? "core.controller.restart" : "core.host.restart");
|
||||
async function onOk() {
|
||||
if (!applyAction) {
|
||||
form.setFields([{ name: "action", errors: ["Action is required"] }]);
|
||||
return;
|
||||
if (props.target === "controller") {
|
||||
await control.send(new lib.ControllerUpdateRequest());
|
||||
} else {
|
||||
await control.sendTo(props.target, new lib.HostUpdateRequest());
|
||||
}
|
||||
await applyAction();
|
||||
|
||||
const values = form.getFieldsValue();
|
||||
if (values.restart) {
|
||||
// Ternary not used because TS disliked it
|
||||
if (restart) {
|
||||
if (props.target === "controller") {
|
||||
await control.sendTo(props.target, new lib.ControllerRestartRequest());
|
||||
await control.send(new lib.ControllerRestartRequest());
|
||||
} else {
|
||||
await control.sendTo(props.target, new lib.HostRestartRequest());
|
||||
}
|
||||
}
|
||||
|
||||
setFormAction(undefined);
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
return <>
|
||||
<Button
|
||||
type="default"
|
||||
disabled={props.disabled}
|
||||
onClick={() => { setOpen(true); }}
|
||||
>Updates</Button>
|
||||
<Tooltip title={allowed ? undefined : "Remote updates are disabled on this machine"}>
|
||||
<Button disabled={props.disabled || !allowed} onClick={() => { setOpen(true); }}>
|
||||
Update Clusterio
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Modal
|
||||
title="Updates"
|
||||
okText="Apply"
|
||||
title="Update Clusterio"
|
||||
okText="Update"
|
||||
open={open}
|
||||
okButtonProps={{disabled: formAction === undefined}}
|
||||
onOk={() => { onOk().catch(notifyErrorHandler(`Error running ${formAction}`)); }}
|
||||
onOk={() => { onOk().catch(notifyErrorHandler("Error updating Clusterio")); }}
|
||||
onCancel={() => { setOpen(false); }}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={form} onValuesChange={onValuesChange} clearOnDestroy>
|
||||
<Form.Item label="Action" name="action">
|
||||
<Radio.Group value={formAction}>
|
||||
{account.hasPermission(coreUpdatePermission)
|
||||
? <Radio.Button value="remote_update" disabled={!allows.remoteUpdates}>
|
||||
Update Clusterio
|
||||
</Radio.Button>: undefined}
|
||||
{account.hasPermission(coreUpdatePermission) && account.hasPermission("core.plugin.update")
|
||||
? <Radio.Button
|
||||
value="update_all"
|
||||
disabled={!allows.remoteUpdates || !allows.pluginUpdates}
|
||||
>
|
||||
Update All
|
||||
</Radio.Button> : undefined}
|
||||
{account.hasPermission("core.plugin.update")
|
||||
? <Radio.Button value="plugin_update" disabled={!allows.pluginUpdates}>
|
||||
Update Plugin
|
||||
</Radio.Button> : undefined}
|
||||
{account.hasPermission("core.plugin.install")
|
||||
? <Radio.Button value="plugin_install" disabled={!allows.pluginInstall}>
|
||||
Install Plugin
|
||||
</Radio.Button> : undefined}
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
{
|
||||
formAction !== undefined
|
||||
&& account.hasPermission(`core.${props.target === "controller" ? "controller" : "host"}.restart`)
|
||||
&& <Form.Item label="Restart on completion" name="restart" valuePropName="checked">
|
||||
<Checkbox disabled={props.canRestart === false}/>
|
||||
</Form.Item>
|
||||
}
|
||||
{formAction === "remote_update"
|
||||
? <NpmRemoteUpdate {...{setApplyAction, form, target: props.target}}/> : undefined}
|
||||
{formAction === "update_all"
|
||||
? <NpmUpdateAll {...{setApplyAction, form, target: props.target}}/> : undefined}
|
||||
{formAction === "plugin_update"
|
||||
? <NpmPluginUpdate {...{setApplyAction, form, target: props.target}}/> : undefined}
|
||||
{formAction === "plugin_install"
|
||||
? <NpmPluginInstall {...{setApplyAction, form, target: props.target}}/> : undefined}
|
||||
</Form>
|
||||
<p>Updates Clusterio within the version range in the machine's package.json.</p>
|
||||
{canRestart && <Checkbox
|
||||
checked={restart}
|
||||
disabled={props.canRestart === false}
|
||||
onChange={event => { setRestart(event.target.checked); }}
|
||||
>Restart on completion</Checkbox>}
|
||||
</Modal>
|
||||
</>;
|
||||
}
|
||||
|
||||
export function hasNpmButtonPermission(controller: boolean) {
|
||||
const account = useAccount();
|
||||
return account.hasAnyPermission(
|
||||
controller ? "core.controller.update" : "core.host.update",
|
||||
"core.plugin.update", "core.plugin.install"
|
||||
);
|
||||
return account.hasPermission(controller ? "core.controller.update" : "core.host.update");
|
||||
}
|
||||
Loaded 100 of 216 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user