Compare commits

..
Author SHA1 Message Date
Cooldude2606 0baf47c8e7 v2.0.0-alpha.19 2024-12-23 22:08:23 +00:00
Cooldude2606 65d1fe53d6 Update Change Log 2024-12-23 22:02:59 +00:00
Cooldude2606 16bc651afc Add bidirectional sync to admin and ban list (#709)
* Add bidirectional sync to admin and ban list

* Add tests

* Response to comments

* Skip broken tests from change of default value
2024-12-23 18:58:44 +00:00
Cooldude2606 91c1d2e6c6 Fix version variables in compat.lua (#713) 2024-12-23 15:31:04 +00:00
Cooldude2606 ff9ba0271f Expand compat to 0.17 (#711) 2024-12-13 13:48:52 +00:00
Daniel 87746edbf5 Merge pull request #708 from clusterio/fix/cpu-usage-without-wmic
Use Get-Process instead of wmic on windows
2024-12-09 15:25:13 -08:00
Daniel 6f5547b524 Merge pull request #706 from clusterio/feature/stat-exporter-surfaces
Feature/stat exporter surfaces
2024-12-09 15:24:19 -08:00
Danielv123 c0d33d5852 In 2.0, icon_size is optional with a default of 64 - fixes #699 2024-12-09 15:23:45 -08:00
Daniel 485504d885 Recreate instance's required subfolders on startup (#705)
Fixes #698
2024-12-09 15:21:59 -08:00
Daniel b093cd136f Merge pull request #703 from clusterio/feature/connect-to-server
Add connect to server button to web UI using steam launch api
2024-12-09 15:17:12 -08:00
Danielv123 7b34d238ea Use Get-Process instead of wmic on windows 2024-12-03 17:37:40 +00:00
Danielv123 8e22de9d2d Add platform speed and weight metrics
clusterio_statistics_exporter_platform_speed
clusterio_statistics_exporter_platform_weight
2024-12-02 21:35:25 +00:00
Danielv123 9464541d61 Update tests for multi surface support 2024-12-02 21:14:50 +00:00
Danielv123 fd741f9b49 Add clusterio_statistics_exporter_platform_mapping
This allows queries to map between the platform surfaces tracked in the statistics and the name assigned by the player
2024-12-02 21:05:24 +00:00
Danielv123 7934e471a7 Provide production statistics for all surfaces 2024-12-02 21:04:00 +00:00
Daniel 1090197acc Merge pull request #697 from clusterio/fix/json-function-backwards-compatibility
Add backwards compatible json serialization capability
2024-12-01 10:42:05 +00:00
Danielv123 c89b773f26 Reference in readme 2024-12-01 10:38:06 +00:00
Danielv123 c5c73c8c96 Add connect to server button to web UI using steam launch api
Does not work if factorio is already open. Resolves #608
2024-12-01 10:32:25 +00:00
Danielv123 54411bc327 Use builtin version when available 2024-12-01 09:14:04 +00:00
Daniel 98a15d3366 Merge pull request #692 from clusterio/plugin-management
Add plugins automatically
2024-11-29 20:18:42 +00:00
Danielv123 17133dc1f5 Test for compatability with missing dependencies field in package.json 2024-11-29 20:12:53 +00:00
Danielv123 d7792ca369 Log error when a specified plugin does not exist, but allow loading without it
Preventing loading just because a plugin is missing would cause the host to become inaccessible if something went wrong when managing plugins through the remote interface
2024-11-29 20:01:45 +00:00
Danielv123 747c13a715 Only write changed plugin list once 2024-11-29 20:00:42 +00:00
Cooldude2606 583d484573 Merge pull request #701 from Cooldude2606/feature/enum-config-input 2024-11-28 23:22:54 +00:00
Danielv123 9b0a06815b Correctly handle installs with no plugins or dependencies 2024-11-28 20:27:31 +00:00
Danielv123 45ae20051f Fix specifying default quality for 1.1 migration 2024-11-28 20:18:22 +00:00
Danielv123 d1baab5e7f Synchronize space age logistics slots
Requests of the v1 format is automatically upgraded and placed in a new section. Filters in named sections are not synchronized to avoid messing with non-player entities on servers you join. Synchronizing logistics groups falls under the scope of another plugin.
2024-11-28 20:18:22 +00:00
Daniel 6c3c094e94 Prefer linking to issues over pull requests 2024-11-27 17:32:45 +00:00
Daniel 1ed52d8fe6 Create pull_request_template.md 2024-11-27 17:32:45 +00:00
Danielv123 2fb33c9648 Support synchronizing crafting queue in 2.0 with new storage api 2024-11-27 17:28:59 +00:00
Cooldude2606andDanielv123 4248e57dcb Add config migrations (#693)
Co-authored-by: Danielv123 <danielv@danielv.no>
2024-11-26 23:14:56 +00:00
Danielv123 85cdbb1185 Add backwards compatible json serialization capability
resolves #687 using https://github.com/rxi/json.lua
2024-11-26 19:11:53 +00:00
Danielv123 2e729b9e47 Add tests for monorepo plugins and non-plugin packages 2024-11-26 10:33:26 +00:00
Danielv123 427acfe82e Refactor, fix linting issues 2024-11-26 10:18:54 +00:00
Danielv123 79c4bf1441 Ignore missing plugins instead of throwing 2024-11-26 10:07:14 +00:00
Danielv123 fea07e4f55 Add tests 2024-11-26 10:03:35 +00:00
Danielv123 5dbfff9bfd Support for monorepo autodiscovery 2024-11-26 09:23:09 +00:00
Danielv123 37fed2064e Warn when multiple plugins of the same name are detected 2024-11-26 08:49:30 +00:00
Danielv123 f95e1d2998 Only log not-a-plugin for non-core packages 2024-11-26 08:27:29 +00:00
Danielv123 5586b1640b Include keywords for clusterio and factorio in all first party packages 2024-11-26 08:17:45 +00:00
Danielv123 91cbd7a464 Handle files not existing 2024-11-25 18:26:35 +00:00
Danielv123 472177bf39 Add jsdoc, reduce logging 2024-11-25 18:07:51 +00:00
Danielv123 397dc6a5a6 Autodiscover plugins in dependencies with keyword "clusterio-plugin" 2024-11-25 17:23:03 +00:00
Danielv123 636777d935 Add locally provided plugins by default 2024-11-25 12:22:16 +00:00
Cooldude2606 05ca4eb25e Merge pull request #686 from Cooldude2606/feature/2.0-compat
Add 2.0 support for clusterio api and 1st party plugins
2024-11-23 22:39:47 +00:00
Cooldude2606 ebf8504507 Patches after manual testing 2024-11-23 14:27:06 +00:00
Cooldude2606 ba3d74388d Update changelog 2024-11-23 14:27:03 +00:00
Cooldude2606 8b551deda8 Add 2.0 support to module template 2024-11-23 14:26:12 +00:00
Cooldude2606 2ba33d58ed Add 2.0 support to statistics_exporter 2024-11-23 14:26:11 +00:00
Cooldude2606 33e05c244c Add 2.0 support to research_sync 2024-11-23 14:26:11 +00:00
Cooldude2606 4ca5d17cb0 Add 2.0 support to player_auth 2024-11-23 14:26:11 +00:00
Cooldude2606 4ee508d1b2 Add 2.0 support to inventory_sync 2024-11-23 14:26:10 +00:00
Cooldude2606 ad836d3776 Add 2.0 support to core lua 2024-11-23 14:26:10 +00:00
Cooldude2606 201a201f05 Add compat lib 2024-11-23 14:25:57 +00:00
Cooldude2606 4c64ef9d15 Merge pull request #684 from Cooldude2606/feature/2.0-scenarios
Add support for 2.0 builtin mods and scenarios
2024-11-22 13:42:40 +00:00
Cooldude2606 e4f0ab1e8f Merge branch 'master' into feature/2.0-scenarios 2024-11-22 13:36:33 +00:00
Cooldude2606 efa2b76ab0 Take the earlier first join time 2024-11-22 04:06:34 -08:00
Cooldude2606 d80385c947 Add test for user merging 2024-11-22 04:06:34 -08:00
Cooldude2606 bdf02a5ea2 Made user ids case insensitive 2024-11-22 04:06:34 -08:00
Daniel 41d76ff757 Prevent bypassing command permission with leading whitespace 2024-11-21 08:46:22 -08:00
Cooldude2606 c5841b17a0 Add tests for new config value 2024-11-21 08:46:22 -08:00
Cooldude2606 762212747e Added factorio.enable_script_commands 2024-11-21 08:46:22 -08:00
Cooldude2606 feb09a6fe0 Update change log 2024-11-21 08:25:23 -08:00
Cooldude2606 06d9d74b67 Add default autoComplete for credential fields 2024-11-21 08:25:23 -08:00
Cooldude2606 be4c31c10f Add new node LTS to CI 2024-11-21 08:25:23 -08:00
Cooldude2606 1556c0afdd Enable source maps in tests 2024-11-21 08:25:23 -08:00
Cooldude2606 069f568941 Add silent test as npm command 2024-11-21 08:25:23 -08:00
Cooldude2606 4ef930c08c Add file excluded by gitignore 2024-11-13 22:29:21 +00:00
Cooldude2606 3850558d49 Add and fix tests 2024-11-13 22:17:27 +00:00
Cooldude2606 81444cc447 Fix ModPackView and add 2.0 scenario support 2024-11-13 19:11:59 +00:00
Cooldude2606 c714d86c5c Added Builtin Mods 2024-11-13 17:32:14 +00:00
Cooldude2606 c6354ded70 Merge pull request #683 from Cooldude2606/fix/winston-log-format
Bump winston, winston-transport, and resolve type error
2024-11-12 18:11:17 +00:00
Cooldude2606 cb29db0e45 Bump winston-transport@4.9 2024-11-12 17:15:15 +00:00
Cooldude2606 7298ef2ea7 Bump winston@3.17 and resolve type error 2024-11-12 16:46:01 +00:00
Cooldude2606 ace8356f68 Merge pull request #679 from Cooldude2606/fix/whitelist-remove
Fix whitelist remove command with tests. Tests check that set-banned and set-whitelist will send rcon commands to running instances and that it is case insensitive.
2024-11-01 01:29:10 +00:00
Cooldude2606 19b5c4d857 Fix whitelist command 2024-10-31 16:07:58 +00:00
Cooldude2606 f6a40baa55 Added tests for banlist/whitelist rcon commands.
Tests check that set-banned and set-whitelist will send rcon commands to running instances and that it is case insensitive.
2024-10-31 16:02:12 +00:00
Cooldude2606 0afbb69308 Use sync file writes and catch errors to reset state. (#677)
By using sync file writes it prevents interrupts between the state change and the server process starting. Also adding error catching to reset the state following an error writing to the files.
Fixes: #676
2024-10-29 16:50:05 +00:00
Cooldude2606 9e03c743e9 Merge pull request #678 from Cooldude2606/fix/host-user-sync
Fix host being unable to sync user state to instances after controller connection
2024-10-29 16:28:37 +00:00
Cooldude2606 dba2dd246e Update change log 2024-10-29 01:15:58 +00:00
Cooldude2606 198e58c762 Allow host as src for user sync events 2024-10-29 01:03:08 +00:00
Cooldude2606 ef6861a566 Fixed host tests not checking the message was valid 2024-10-29 00:59:17 +00:00
Hornwitser 60e5ed008d Fix unhandled promise rejection in Link tests 2024-10-28 20:15:33 +01:00
Hornwitser 3838bc6a70 Remove "instance" from ConfigLocation
When the config system was ported to TypeScript the "instance" location
was accidentally added to the type of available locations for access
control.  This has never been a valid location as it was deemed too
complicated to work with.  The correct location for instances is
"controller" and "host" as these are the locations the configs are
handled.
2024-10-28 20:15:09 +01:00
Hornwitser a6033f4171 Merge pull request #675 from clusterio/fix-instance-update
Fix instance update
2024-10-28 20:14:18 +01:00
Hornwitser 6f85ec9dd6 Log files written to aid debugging
Log to the verbose level when a datastore file read or written to in
order to make it easier to diagnose whether the files are written or
not.
2024-10-28 20:07:34 +01:00
Cooldude2606 e0b72e568d Mark RCON password as a credential (#673)
* Mark RCON password as a credential

* Update location name and description

* Add controller to array to avoid UB
2024-10-28 18:46:26 +00:00
Hornwitser 34da073800 Fix setting instance config fields not being saved
If only the config of an instance is updated the dirty flag of the
instance is not set, which results in changes being discarded when the
controller shuts down afterwards.

Fix by always triggering the update logic when an instance field is
changed.
2024-10-28 19:30:03 +01:00
Cooldude2606 300db5cbfd Change display title for controller.external_address (#674)
* Rename controller.external_address

* Revert and correct title to avoid breaking change

* Update change log
2024-10-28 17:53:37 +00:00
Cooldude2606 c49bc07e15 Add hidden and readonly config properties (#665) 2024-10-27 23:02:45 +00:00
Cooldude2606 c31671d1e6 Added 2.0 zip file name to version matcher (#671)
* Added 2.0 zip file name to version matcher

Fixes: Install problem ? #668

* Add changelog entry for 671.
2024-10-27 22:53:34 +00:00
Cooldude2606 9d18d18603 Update CI to support testing of multiple factorio versions (#672)
We have plans to support the latest version and one minor previous. 2.0 will be added to CI once enough changes have been made to support it.
2024-10-27 22:24:15 +00:00
Cooldude2606 8a47fd8414 Update tests 2024-09-21 18:09:29 +01:00
Cooldude2606 49d584394d Add security logic to readonly and object support 2024-09-21 17:41:46 +01:00
Cooldude2606 e3eb266704 Add hidden and readonly config property 2024-09-21 01:52:01 +01:00
Cooldude2606 3e6806ff0e Merge pull request #664 from Cooldude2606/feature/role-drop-down
Add InputRole as a drop down selector
2024-09-21 01:22:52 +01:00
Cooldude2606 76349fa780 Merge branch 'master' into feature/role-drop-down 2024-09-21 01:06:07 +01:00
Cooldude2606 d4b0e658c3 Change log updates 2024-09-21 01:03:51 +01:00
Cooldude2606 549ec3d1e7 Add InputRole as a drop down selector 2024-09-21 01:01:36 +01:00
Hornwitser d5f3ac0f4c Merge pull request #663 from Hornwitser/config-credentials
Implement shared Factorio credentials
2024-09-19 00:42:52 +02:00
Hornwitser a09a564358 Log where Factorio credentials taken from
Provide ease of diagnosing credential issues by accurately logging if
the credentials were sourced from the instance config, the host config,
the controller config, or if no credentials were found.
2024-09-18 23:41:45 +02:00
Hornwitser f873f4b369 Add autocomplete attribute to credential fields
Prevent browsers from automatically autofilling the password entry box
for the Factorio credentials by setting the autocomplete property to
new-password on the password input.
2024-09-18 23:10:31 +02:00
Hornwitser 44e5847a1d Implement shared Factorio credentials
Add config entries to controller and host for the Factorio username and
token to use when talking to the Factorio APIs.  If set an username and
token is not set in factorio.settings then these settings are used,
making it easy to configure one set of credentials for a cluster/host.

For running more public provide your own hardware clusters, there's an
option to disable controller sharing the credential with the hosts so
that each host have to set their own credentials.
2024-09-16 23:23:21 +02:00
Hornwitser eedf5b8f67 Credential option for config entries
Implement a credential option to config entries which is a list of
locations the config entry can be read from.  When specified and the
option is a string field this gets rendered as a password field in the
config UI.  If the credential is not readable from control the value of
the fields is cleared when the UI is refreshed.
2024-09-16 23:23:21 +02:00
Cooldude2606 77fea7e295 Fix unrelibable datastore tests (#662)
* Use incrementDateMs

* Change wait time to 15ms

* Fix loop condition
2024-09-14 17:20:52 +01:00
Hornwitser a1c7173dd2 Merge pull request #661 from Hornwitser/windows-rework
Rework Factorio server stop logic
2024-09-14 18:15:19 +02:00
Cooldude2606 dfc406d025 Host shutdown on duplicate connection (#647)
* Update no-shadow lint rule for enums

https://typescript-eslint.io/rules/no-shadow/

* Add websocket close codes enum

* Pass close code and reason to event handlers

* Shutdown host on duplicate connection

* Add connection refusal

* Fix lib/link tests

* Response to comments

* Additional Tests

* Removed missed codes in close event
2024-09-14 16:39:59 +01:00
Hornwitser 10a857c4ad Gracefully handle RCON connection dropping
Handle the edge cases of RCON authentication failing and RCON connection
failing during startup so that server stop still works in these cases.
2024-09-14 16:09:17 +02:00
Hornwitser e4aeafaf14 Set a map limit to speed up integration test 2024-09-14 15:05:13 +02:00
Hornwitser a1e7b24298 Fix lingering killed server flag
Fix killing the Factorio server causing the next run of it to report a
different error when it's killed by an external factor than what's
expected.
2024-09-14 15:05:13 +02:00
Hornwitser 7ff06f2b96 Rewrite Factorio server shutdown logic
Rewrite shutdown logic for Factorio servers to send a /quit command via
RCON for both Linux and Windows when stopping a server to avoid having
two confusingly intermixed code paths for Windows and Unix systems.
This makes shutdown on Windows tell clients the server shut down instead
of abruptly stoping to respond.
2024-09-14 15:05:13 +02:00
Hornwitser 36fc2ac1db Support nested factorio dirs in server test
Source the factorio directory from the host config as with other tests
and handle it pointing to a folder of Factorio installations.
2024-09-14 15:05:13 +02:00
Hornwitser 64cc9c2b64 Use factorioDir for alt-host config
Source the factorio installation from the host config for the alt host
test like with other tests.
2024-09-14 14:58:55 +02:00
Hornwitser c0e631e251 Fix tables sorting being broken
Newer versions of Ant Design requires either dataIndex or key to be
defined on columns for sorting of the tables to work.  Add missing key
entries on columns.
2024-09-12 22:32:42 +02:00
Hornwitser 8e21290b22 Enable skipped tests on Windows
Use the stop host command to stop alt-host so that the tests that depend
on it can also be run on Windows.
2024-09-12 15:26:12 +02:00
Hornwitser a05aef260f Allow iterating through datastores like a Map
Support the for (const [k, v] of datastore) syntax which is commonly
used with maps by adding a Symbol.iterator function returning the
appropriate iterator.
2024-09-04 13:43:56 +02:00
Hornwitser 7547a48fd6 Fix controller not accepting old hosts.json format
Migrate the content of the hosts.json file before attempting to loading
it into the new datastore format.
2024-09-04 13:40:15 +02:00
Arvids Godjuks 33c9542524 Merge pull request #660 from clusterio/bugfix/special-case-base-mod
Fix built-in mods showing up as missing in web UI
2024-09-04 14:36:29 +03:00
Hornwitser 95396ff1c3 Fix this is not a constructor error on startup
When passing methods around they lose their binding to this, which means
the fromJSON methods no longer has this be their class.  Fix by binding
the methods to the class they belong to before passing them on to the
datastore.
2024-09-04 13:32:38 +02:00
Arvids Godjuks 59091d74f5 Fix built-in mods showing up as missing in web ui
Added "builtInMods" array that contains a list of built-in mod names that we check against to not show an error and remove ability to delete those mods from mod packs

Fixes #581
2024-09-04 14:14:55 +03:00
Hornwitser 6226a45d0b Fix safeOutputFile writing to wrong directory (#659)
When renaming a file over another file the operation is only atomic if
both the source file and the target file is in the same directory.  Fix
safeOutputFile writing the temporary file to a different directory than
the target file due to the path separator not being included when
creating the temporary file path.
2024-09-04 00:14:47 +02:00
Hornwitser 84958e3f9d Merge pull request #629 from Cooldude2606/feature/datastore
Impliment datastores to reduce boilerplate
2024-09-04 00:14:16 +02:00
Hornwitser a2ea2f2604 Merge branch 'master' into feature/datastore 2024-09-04 00:08:52 +02:00
Hornwitser 5d97e8e816 Source factorio dir from config-host.json in tests
If a factorio dir is configured in config-host.json then use that in the
tests.  This removes the requirement of having a factorio installation
in ./factorio or linking to one there when running the tests.
2024-09-03 21:58:45 +02:00
Hornwitser f3150a9261 Add tests for Address.addressedTo
Make sure the confusing logic in addressedTo is working correctly by
adding tests for it.
2024-09-03 21:58:45 +02:00
Hornwitser 4068129124 Remove useless div on drop zone
This would render on top of modals and doesn't appear to be necessary.
2024-09-03 21:58:45 +02:00
Hornwitser a883e692e4 Add changelog for #619 2024-09-03 21:58:45 +02:00
Hornwitser 5a73a73c36 Fix installer setting bad controller_url
The host.controller_url config is required to end with a / for the url
joining logic to work.  Fix code added #619 setting this to a value not
ending with a /.
2024-09-03 21:58:45 +02:00
Hornwitser a1856e6edf Merge pull request #573 from CCpersonguy/feature/instance-version-2
Display Instance's Factorio version in the web UI
2024-09-03 21:57:42 +02:00
Arvids Godjuks b2f0000a64 Update CHANGELOG.md
Fixed extra new line in changelog
2024-09-03 14:58:38 +03:00
Cooldude2606 8054bd66d2 Fix instance subscriptions (#655)
Remove some assumptions that prevent subscription listeners from working on an
instance.  This means allowing the host to forward the event from the instance
to the controller; correctly storing subscription listeners by address rather
than by connection; and a bunch of network and event stuff to correctly clean up
a listener.

Remove the assumption that all subscriptions events contains the updates
property.  This meant fixing inconsistent types; always checking if the property
existed; fixing lots of the tests that made incorrect assumptions.

Minior fixes:
- Fix double firing of listener callbacks on first connection.
- Fix synced being set to true too early on first connection.
- Fix edge case interaction between last response and last snapshot for values
  with updated set as 0.

Also change the the output made during the start of tests to indicate something
is happening when start up is slow and allow for silent testing to reduce the
amount of text output to the console.
2024-09-03 12:31:32 +02:00
Arvids Godjuks 0c63852a2c Merge branch 'master' into feature/instance-version-2 2024-09-03 02:09:28 +03:00
Hornwitser 47896247fe Add option to set path to server executable (#657)
Allow overriding the logic selecting the path to the Factorio
executable.  This is mainly useful when working with development
versions of the game.
2024-09-03 01:04:50 +02:00
Arvids Godjuks 1687f2f896 Merge pull request #654 from Cooldude2606/feature/plugin-mono-repos
Include mono repo plugins in build
2024-09-03 02:01:04 +03:00
Arvids Godjuks 7e5608e2d8 Merge pull request #656 from clusterio/bugfix/numeric-user-name-as-string
Fix bootstrap command missing type declaration for admin name
2024-09-03 01:31:57 +03:00
Arvids Godjuks 55fcd90ed2 Fix bootstrap command missing type declaration for admin name
Lack of typing was resulting in admin name being serialized to storage as a number and breaking websocket communications protocol due to type mismatch: expected string, got number.

Fixes #536
2024-09-03 01:24:24 +03:00
Cooldude2606 7db5821967 Update change log and publish steps (#646) 2024-09-02 23:06:40 +02:00
Cooldude2606 5215ee8eef Include mono repo plugins in build 2024-09-02 22:01:59 +01:00
Hornwitser 0b50a6a29e Add changelog entry for #652 2024-09-02 22:43:53 +02:00
Arvids Godjuks b69db08414 Merge pull request #652 from Hornwitser/fix-installer-breaking-on-space
Escape arguments when installer invokes commands
2024-09-02 23:18:02 +03:00
Hornwitser 0cd24d120c Install optional dependencies on CI
The @swc/core module depends on optional dependencies being installed in
order to get the correct native binding's package installed.  Remove
--no-optional flag passed to pnpm to install these.
2024-08-30 01:03:54 +02:00
Hornwitser 70cf680edd Escape arguments when installer invokes commands
The usage of shell: true means that no escaping is done on the arguments
passed to the commands invoked by the installer. This leads to the
installation failing if the user for example puts a space in the
username for the admin account.

Fix by escaping the arguments passed on command lines. This is
unreasonably complicated on Windows 😡.
2024-08-30 00:32:58 +02:00
Cooldude2606 660af756e6 Minor Corrections 2024-08-16 07:00:08 +02:00
Cooldude2606 08c522429b Implemented configDir for outDir 2024-08-16 07:00:08 +02:00
Cooldude2606 02df956f6c Fix breaking change introduced in v5.5
https://devblogs.microsoft.com/typescript/announcing-typescript-5-5/#simplified-reference-directive-declaration-emit
2024-08-16 07:00:08 +02:00
Cooldude2606 263bb16619 Fix missing codecov upload token 2024-08-16 06:58:14 +02:00
Cooldude2606 1f47f6dfc9 Update all pages to use PageHeader 2024-06-14 19:20:07 +02:00
Danielv123 51657bea44 Extend PageHeader for plugins
My plugins used the antd pageheader more than base did. I did not notice that these features dissappeared when we migrated from antd to our own implementation - this brings them back
2024-06-14 19:19:42 +02:00
Cooldude2606 498638c0c9 Improve config, message, and module templates (#641)
* Improve config, message, and module templates

* Update global handler in template module

* Add comments to empty instance template
2024-06-08 13:53:23 +01:00
Cooldude2606 51c450727d Add clean script to remove dist and other artifacts (#640)
* Add clean script

* Switch to using yargs
2024-06-08 13:53:00 +01:00
Cooldude2606 dbc79779ea Fix sending log messages across invalid link (#639) 2024-06-08 13:52:29 +01:00
Cooldude2606 2c2521a688 Add error tests for sendEvent and sendRequest (#638) 2024-06-07 15:46:49 +01:00
Cooldude2606 70e95a56b9 Fix test starting alt host under windows 2024-06-07 07:28:04 -06:00
Cooldude2606 fa0b0a6c21 Bump ci action versions (#637) 2024-05-26 19:28:27 +01:00
Cooldude2606 8b823a571d Coverage 100% 2024-05-25 18:17:50 +01:00
Cooldude2606 e90451ead8 Fix broken test 2024-05-25 01:20:58 +01:00
Cooldude2606 222a8abdb8 Update change log 2024-05-25 00:32:14 +01:00
Cooldude2606 eb658f86c0 Add unit tests 2024-05-24 22:10:04 +01:00
Cooldude2606 d696b8b52c Change abstract class name 2024-05-24 18:29:08 +01:00
Danielv123 2dbf264d46 Add bun compatibility 2024-05-24 17:25:29 +01:00
Danielv123 ffc1a0fccd Add devcontainer configuration file 2024-05-23 11:54:04 +01:00
Lars Corbijn fddd65ddb8 Update documentation links for typescript change (#633) 2024-05-22 17:52:27 +01:00
Cooldude2606 94a07b5af4 Give restore abilty to memory provider 2024-05-22 17:42:25 +01:00
Cooldude2606 af50cfd47a Consistency changes 2024-05-22 17:19:05 +01:00
Cooldude2606 609db31027 Add hosts, instances, systems 2024-05-22 16:39:22 +01:00
Danielv123 e3ab71a2e9 Fix broken types
Some time between antd 5.15.0 and 5.17.3 they moved around some types, which caused our builds to break. This fixes the issue.
2024-05-21 19:08:15 +01:00
Cooldude2606 509fab358a Add saves datastore 2024-05-19 02:28:36 +01:00
Cooldude2606 6b11fb810a Change mod packs to datastore 2024-05-19 01:41:28 +01:00
Cooldude2606 80b4a96e85 Add datastore to lib 2024-05-19 01:24:30 +01:00
Cooldude2606 f4679ef6d1 Merge pull request #625 from Cooldude2606/fix/loopback-routing
Fix loopback routing message and add tests
2024-05-18 23:04:18 +01:00
Danielv123 eb78cdc322 Allow declaring custom config input components for objects
Input components allow us to customize the config field editing experience in plugins, but due to an oversight this did not include object definitions.
2024-05-18 20:57:46 +01:00
Danielv123 8705377232 Add typebox dependency 2024-05-18 20:57:16 +01:00
Danielv123 692075df1d Add useInstanceConfig hook
Currently the web interface implements configs by pulling the state directly in the components where they are used. I need to access the instance config for a plugin, which would require me to duplicate parts of the instance config retrival code.
Instead, I implement it here under lib. This allows 3rd party consumers to get immediate access if we decide to implement a subscription model for configs in the future.
2024-05-18 20:57:16 +01:00
Cooldude2606 ebc0298115 Update changelog 2024-05-18 18:48:08 +01:00
Cooldude2606 d1fa5e67d4 Improve error throwing for sending events 2024-05-18 18:42:53 +01:00
Daniel 7489a76d4a Merge pull request #602 from Danielv123/react-to-drag-and-drop
React to drag and drop
2024-05-18 17:57:31 +02:00
Cooldude2606 201209efb3 Fix undefined stack on some IPC errors (#624) 2024-05-18 14:56:37 +01:00
Danielv123 c6abe29e89 Correctly disable dropzone when host is offline 2024-05-18 10:27:40 +01:00
Danielv123 c8e54cd563 Fix crash loading mutiplayer map in singleplayer
Caused by on_player_created not being called when
loading a multiplayer map in singleplayer. For
further discussion see $622 and
https://forums.factorio.com/viewtopic.php?f=7&t=113497
2024-05-18 10:59:19 +02:00
Cooldude2606 0c9e91b953 Fix error message on instance loopback attempt 2024-05-18 03:40:18 +01:00
Cooldude2606 fb4a5e40f3 Add tests for loopbacks 2024-05-18 03:33:55 +01:00
Danielv123 05b697fd24 Prevent dropping files outside of dropzones 2024-05-15 19:43:40 +01:00
Daniel d7c2e7eed7 Update mod management section of readme (#618)
* Update mod management section of readme

Resolves #617

* Update section after review
2024-05-15 10:41:24 -06:00
Danielv123 f1fd8247fb Add factorio version indicator feature to changelog 2024-05-15 10:40:36 +01:00
Danielv123 92912fa9e5 Show configured instance version while not running 2024-05-15 10:33:37 +01:00
Danielv123 e3753f292b Show current factorio version on instance page 2024-05-15 09:50:39 +01:00
CCpersonguy b57c419769 Version should disappear first on narrow windows 2024-05-15 09:50:39 +01:00
CCpersonguy 594158deac Remove verion-caching logic in Controller 2024-05-15 09:50:39 +01:00
CCpersonguy 3d3276b02c fix lint errors 2024-05-15 09:50:39 +01:00
CCpersonguy dc2f86dcf3 allow FactorioServer.version to return undefined 2024-05-15 09:50:39 +01:00
CCpersonguy 8a57d9b5f6 display Factorio version in Instances web UI 2024-05-15 09:50:39 +01:00
Cooldude2606 b9e9a754b8 Change all workspace versions to workspace:* (#621) 2024-05-08 20:38:05 +01:00
Cooldude2606 54f56d0ecd Merge pull request #619 from Danielv123/prompt-for-http-port
Installer promt for http port
2024-05-05 20:39:25 +01:00
Danielv123 131e45e10a Skip create integration tests
Tests are skipped because they are really slow and fail in CI
2024-05-05 02:24:57 +02:00
Danielv123 b27256421d Fixed tests 2024-05-05 02:00:16 +02:00
Danielv123 e405f09f33 Add integration tests for create 2024-05-05 01:46:48 +02:00
Danielv123 78a9f73de8 Installer promt for http port
Resolves #564
2024-05-05 00:18:36 +02:00
Danielv123 b6113a1007 Merge branch 'master' into react-to-drag-and-drop 2024-05-04 20:49:29 +02:00
Danielv123 c34a13b3e4 Use global drag and drop hint overlay 2024-05-04 20:31:36 +02:00
Danielv123 920b5c1655 Merge branch 'master' into react-to-drag-and-drop 2024-05-04 15:55:33 +02:00
Hornwitser e341adfb29 Remove redundant if condition
Noticed-By: @Cooldude2606
2024-04-28 20:39:27 +02:00
Hornwitser 464d588b5e v2.0.0-alpha.18 2024-04-28 19:14:27 +02:00
Hornwitser 0ddc1899fa Merge pull request #616 from Hornwitser/fix-mod-settings-types
Handle bad mod setting types
2024-04-28 19:12:22 +02:00
Hornwitser 4e1116ae2d Gracefully handle bad mod setting types in Web UI
Make sure all cobinations of missmatched types and missing mod settings
are gracefully handled by the Web UI.

Fixes #609.
2024-04-28 16:59:03 +02:00
Hornwitser 3cf9f96233 Coerce mod settings types during data export
Use the settings prototypes to coerce any incorrectly typed mod settings
to the correct type either by casting or by replacing the value with the
default value from the prototype.

This will fix cases where an update to a mod changes the type of ono of
its defined settings.
2024-04-28 16:52:33 +02:00
Cooldude2606 c37fe0f438 Merge pull request #613 from Danielv123/Fix-patch-with-missing-file
Correctly handle module files missing from a previously patched save
2024-04-27 22:12:35 +01:00
Danielv123 8831b33c01 Filter out missing files on load 2024-04-27 22:09:16 +02:00
Hornwitser 56124574fb Add changelog for #614 2024-04-27 19:58:00 +02:00
Hornwitser 9b416583f0 Add changelog for #570 2024-04-27 19:57:28 +02:00
Hornwitser b885e37b5b Add changelog for #606 2024-04-27 19:39:57 +02:00
Hornwitser 7e7e763517 Add changelog for #600 2024-04-27 19:38:21 +02:00
Daniel 811c4f7127 Use shell:true when running non-user input .cmd files on windows (#614)
Required after CVE fix: https://nodejs.org/en/blog/vulnerability/april-2024-security-releases-2/
Resolves #612
2024-04-23 22:19:30 +01:00
Danielv123 f0fb471ea1 Correctly handle module files missing from a previously patched save
Resolves #611
2024-04-23 21:57:55 +02:00
Hornwitser 79c1a2dc99 Fix plugin hooks called before host removal (#607)
Due a bad order of operations when a host connection was closed the
event handler listening on "close" to remove the host from the
hostConnections mapping was added after the HostConnection's event
handler listening on "close" and invoking plugin hooks in response to
the host going offline.  This in turn causes plugins that send
broadcasts in repsonse to thos hooks to throw an error due to the
broadcast sending code assuming connections that exist are valid target
to send messages to.

Fix by ordering the listener for the removal of the host connection
before the listener invoking plugin events.
2024-03-10 00:43:38 +01:00
Cooldude2606 a8ce8e7cc0 Merge pull request #570 from Cooldude2606/feature/plugin-template
Add command to create a new plugin from a template
2024-03-08 23:05:40 +00:00
Cooldude2606 50687515fa Merge branch 'feature/plugin-template' of https://github.com/Cooldude2606/clusterio into feature/plugin-template 2024-03-08 22:00:55 +00:00
Cooldude2606 6f81216e7c Addressed remaining review comments 2024-03-08 22:00:38 +00:00
Cooldude2606andHornwitser 3423e0a30c Apply suggestions from code review
- Copy paste errors.
- Incorrect capitalisation.
- Incorrect exports.
- Missed find and replace.

Co-authored-by: Hornwitser <github@hornwitser.no>
2024-03-08 20:08:16 +00:00
Cooldude2606 8fbdb5dfab Merge branch 'master' into feature/plugin-template 2024-03-06 20:12:08 +00:00
Hornwitser 7625fd82e3 Cache ModInfo to reduce startup time (#606)
Store and use a cache of the loaded ModInfo data to mod-info-cache.json
in the mods directory during startup.  For simplicity this does not
cache mods added to the controller or host after startup, these get
added to the cache on the next startup.
2024-03-06 17:30:45 +00:00
Cooldude2606 5dcc66034b Fix possible undefined title error (#605) 2024-03-06 18:11:45 +01:00
Cooldude2606 a66b1af454 Extra touch ups 2024-03-05 00:56:01 +00:00
Cooldude2606 3adfcf0a29 Updated web content permission 2024-02-28 16:36:35 +00:00
Cooldude2606andHornwitser 9a4241e4cd Update flags for output file
Co-authored-by: Hornwitser <github@hornwitser.no>
2024-02-28 16:26:53 +00:00
Cooldude2606 8dcb2197d6 Use try catch for output file existing 2024-02-28 15:25:08 +00:00
Cooldude2606 a168f7db5e Add call to action for config desc 2024-02-27 23:57:13 +00:00
Cooldude2606 283aba70df Update descriptions 2024-02-27 23:53:20 +00:00
Cooldude2606 62abfa903b Change to new preprocessor style 2024-02-27 23:07:38 +00:00
Danielv123 b7fec4c700 Set codecov to informational 2024-02-27 12:09:15 -06:00
Cooldude2606 ec19d5456e Merge branch 'master' into feature/plugin-template 2024-02-27 13:07:08 +00:00
Danielv123 e5d82dcfde Don't fail CI due to project level codecov regressions 2024-02-26 22:34:41 -06:00
Danielv123 b70feb37f2 Show table overlay when dragging and dropping saves/mods 2024-02-26 22:04:37 -06:00
Danielv123 ebf6536374 Change style of mod upload button on drag and drop 2024-02-26 22:03:47 -06:00
Daniel a68bb9c0a8 Increase libLink event emitter leak warning limit (#600) 2024-02-25 23:50:14 +01:00
Hornwitser 2075814ed1 Add changelog for #598 2024-02-23 18:09:09 +01:00
Hornwitser f07ee22969 Add changelog for #556 2024-02-23 18:06:02 +01:00
Cooldude2606 a70490648f Fix total db size calculation for inv sync (#598) 2024-02-23 16:53:15 +00:00
Hornwitser e3abf8ba33 Merge pull request #556 from Cooldude2606/feature/http-logging
Add logging for express requests and errors
2024-02-23 17:35:06 +01:00
Cooldude2606 46309ce5b8 Lint Fixes 2024-02-18 17:58:16 +00:00
Cooldude2606 f392b258f7 Add subscriptions and luacheck 2024-02-17 03:11:54 +00:00
Cooldude2606 1afee2ede5 Merge branch 'master' into feature/http-logging 2024-02-14 22:46:19 +00:00
Cooldude2606 0b2fcdcec4 Merged redundant middleware 2024-02-14 22:37:48 +00:00
DanielandHornwitser 735faf4b2c Apply suggestions from review
Co-authored-by: Hornwitser <github@hornwitser.no>
2024-02-14 09:26:13 +01:00
Danielv123 d2775e4db9 Update message section of writing-plugins document 2024-02-14 09:26:13 +01:00
Hornwitser b3fc278b2c Add template to CHANGELOG.md
Document the format and expected sections of entries as a template in
the changelog so that it's easier to add new log entries.
2024-02-12 18:21:46 +01:00
Hornwitser 5f85c0575e v2.0.0-alpha.17 2024-02-12 17:54:48 +01:00
Hornwitser cf2b3db20d Add thank you note to contributors of Alpha 17 2024-02-12 17:53:16 +01:00
Hornwitser a9c6430531 Document current publishing workflow
Docement the adaptations I've made to the publishing procedure which
uses a less dangerous publish command and removes the dist folders
before publishing to avoid accidental upload of stale build files.
2024-02-12 17:53:13 +01:00
Hornwitser 8a580a66be Add breaking change note for 535e43b 2024-02-12 17:37:27 +01:00
Hornwitser 8ae5e11021 Add changelog for #587 2024-02-12 17:33:13 +01:00
Hornwitser fac04adc6f Add known issue about the base mod 2024-02-12 17:29:51 +01:00
Hornwitser d8f1d8a3e2 Add known issue about Factorio migrations 2024-02-12 17:25:10 +01:00
Hornwitser abcbf3fddc Add pagination to saves list (#592)
Prevent the saves list from pushing the console and config off the
screen by turning on the pagination for it.
2024-02-12 17:24:00 +01:00
Cooldude2606 e5a4d51c86 Merge pull request #590 from Hornwitser/bugfixes-3
Bugfixes 3
2024-02-11 23:01:43 +00:00
Hornwitser 97e615fc21 Merge pull request #584 from Hornwitser/build-rework
Build rework
2024-02-11 22:14:35 +01:00
HornwitserandCooldude2606 e990134b70 Use suggested wording in docs/contributing.md
Co-authored-by: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com>
2024-02-11 21:40:32 +01:00
Hornwitser c2af8a2377 Merge pull request #587 from Danielv123/make-resource-references-clickable
Make resource references clickable
2024-02-11 16:58:50 +01:00
Hornwitser c9609259db Ignore error loading mod in fetchMod
If a bad download or file transfer causes a mod in a host's mod folder
to become corrupt the attempt to load the mod will throw an error that's
not the file not existing.  This in turn causes starting the instance to
always fail as the mod is never attempted to be re-downloaded.

Fix by ignoring errors attempting to load mods already present in the
mods folder that aren't in the mod store so that a new download is
attempted after that fails.
2024-02-11 15:48:45 +01:00
Danielv123 3cddd2b9f8 Export Link component for use by plugins 2024-02-10 16:33:22 +03:00
Danielv123 8879f8dd80 Replaced react Link with custom component
The custom component merges the behaviour of antd Typography.Link and the react-router.dom Link after the custom component prop was removed in v6 according to this guide: https://reactrouter.com/en/main/upgrading/v5#remove-link-component-prop
2024-02-09 09:11:53 +03:00
Danielv123 4aca5050b9 Allow sending instance management messages from controller plugins 2024-02-09 07:00:05 +03:00
Danielv123 89919f7a89 Fix inconsistent indentation 2024-02-09 06:49:17 +03:00
Danielv123 e2da9ac03d Make role tag in users table clickable
As per issue #572
2024-02-09 06:30:43 +03:00
Hornwitser e8d9eb6916 Convert prototype icons to array during export
It's possible to set the .icons to an arbitrary key value mapping
instead of an array and the game will still accept it.  Convert the
icons to an array if it's an object mapping to handle this case.

Note that this doesn't preserve the order the game reads the keys in
because JavaScript reorders numeric key indexes on objects differently
to how the game orders them.
2024-02-08 21:18:48 +01:00
Hornwitser 8865509011 Delete player statistics when deleting instances
Clear the statistics stored per user for the instance when an instance
is deleted.
2024-02-08 19:06:21 +01:00
Danielv123 bcda88710e Make host name in instance list clickable
As per issue #572
2024-02-08 20:50:14 +03:00
Danielv123 d6b0e88dda Make host name on instance page clickable 2024-02-08 20:49:33 +03:00
Hornwitser 1021e29c98 Support shorthand notation for colours
Implement the shorthand notations that Factorio recognises for colours
where individual components may be left out or the colour specified as
an array of values instead of an object.
2024-02-08 18:42:38 +01:00
Hornwitser 96082e32b7 Replace deprecated usage of Select bordered 2024-02-08 15:25:06 +01:00
Hornwitser f70b4f74b1 Add watch script to rebuild on changes 2024-02-08 05:23:48 +01:00
Hornwitser ebb93ada94 Replace babel and ts-loader with swc
Build and minify the Web UI using swc instead of babel, tsc and terser.
This results in a significant speedup of the build process as it skips
checking types and uses native code to do the minification.

To prevent accidental type errors being introduced the TypeScript
compiler is still used to perform type checking on the Web UI files.

Due to peer dependency resolving shenanigans the swc/core package had to
be added to the workspace, controller and web_ui to avoid pnpm creating
a duplicate install of webpack that break the build.
2024-02-08 04:57:19 +01:00
Hornwitser 597d080d71 Add tsconfig.json that references both builds
Add back unqualified tsconfig.json files to each package that references
both the Node.js build and the browser build.  This makes tsc --build
compile both builds when the variant to do is not specified.
2024-02-08 03:20:26 +01:00
Hornwitser e4f90b3480 Move tsconfig.json to tsconfig.node.json
Distinguish the Node.js build in the same way the browser build is with
the .node suffix to the tsconfig files it uses.  This means
tsconfig.node outputs to dist/node and tsconfig.browser outputs to
dist/browser.
2024-02-08 03:20:26 +01:00
Hornwitser 8c4d2db192 Move TypeScript Node.js build to dist/node
Use a separate folder for the TypeScript build for Node.js in the dist
folder.  This makes each type of build consistently have its own output
folder instead of being interleaved with the Node.js build.
2024-02-08 03:20:26 +01:00
Hornwitser 152eb7d454 Move TypeScript browser build to dist/browser
Use dist/browser as the output of the browser oriented TypeScript build
of packages instead of dist/web/build.  This distinguishes it better
from the Web UI bundle which is put together with webpack.
2024-02-07 18:35:05 +01:00
Hornwitser 8e5d581b9b Move tsconfig.web.json to tsconfig.browser.json
Rename the config files for the browser build of modules to better
communicate that it's purpose is to compile code for a browser
environment rather than the Web UI in particular.  This also aligns with
the "browser" option in package.json, the existing browser.ts entrypoint
in lib, and how the browser environment is typically referred to.
2024-02-07 17:42:39 +01:00
Hornwitser 99990c7a0d Fix incorrect header level in CHANGELOG.md 2024-02-07 00:05:13 +01:00
Hornwitser ffd61b961d Merge pull request #583 from Hornwitser/bugfixes-2
Bugfixes 2
2024-02-07 00:03:05 +01:00
Hornwitser dcf216ca09 Fix research sync applying progress to wrong level
When the level of a technology changes either by setting level
directly or by setting researched to true, the saved progress of the
research is reset only if the technology is not the current research.

If during the initial synchronisation with the controller an instance
has progressed on a multi level research, it's the current research, and
the controller's level of that research is higher, then the level is set
to the higher level and the game keeps the progress.  This in turn
results in all of the progress made on the previous level being added to
the higher level.

Fix by setting the progress to zero when changing the level or
researched status of a technology that's the active research.
2024-02-06 22:43:04 +01:00
Hornwitser 6bf2bf51f2 Remove deprecated showArrow prop
Ant design has deprecated the Select's showArrow prop.  It's now always
shown by default.
2024-02-06 22:43:04 +01:00
Hornwitser 44b0a8ff83 Throw error on bad permission check in Wed UI
Throw an error if an attempt is made to check for a permission that does
not exist in the Web UI to prevent future bad permission checks going
unnoticed.
2024-02-06 22:43:04 +01:00
Hornwitser d3f8c993b1 Fix bad permission checks in Web UI
Fix attempts to check for permissions that don't exist in the Web UI
that has gone unnoticed because the check still succeeds when the
account has the cluster admin permission.
2024-02-06 22:43:04 +01:00
Hornwitser 535e43b527 Rename save.list.subscribe permission
Follow the same pattern as all the other subscribable lists by having a
separate .list and .subscribe permissions for saves.  This also fixes
the save list not being visible due to a typo in the permission in the
Web UI.
2024-02-06 22:43:04 +01:00
Hornwitser 2df67e32da Set key for top level page fragment
Without a unique per page key set react will try to reuse the existing
state and components of the previous page and update them to match the
new page.  This causes unexpected behaviour as components get turned
into into a Frankenstein abomination of the previous page and the
current one fused into one.

Fix by setting a key on the top level fragment of the page which in turn
forces React to re-render the whole page whenever the user switches to
another page.
2024-02-06 22:43:04 +01:00
Hornwitser 6b85567bc6 Provide theme to top level ErrorBoundary
If the theme is not provided then the App's ErrorBoundary will render
white text on white background, and vice versa for black.
2024-02-06 22:43:04 +01:00
Hornwitser 2e25831b4d Warn on subscription updates with same timestamp
If the value in a subscribed list is updated while the updatedAtMs
timestamp is kept the same then the caching logic of the subscription
system will cause updates to be silently dropped.  Log a warning if a
subscriber catches this happening to help tracking down such cases.
2024-02-06 22:43:04 +01:00
Hornwitser 021af793fc Handle instance status updates with port change
If an instance is reassigned its port may change without the status
changing, these updates should not be ignored.
2024-02-06 22:43:04 +01:00
Hornwitser 7ba14f7433 Fix instance details not updating on reassigmnent
Always send the instance details update when an instance is reassigned,
not just when it is unassigned.  The idea for not sending an update when
assigned to a host was that the host would send an update on the status
that in turn updates the instance details.  However this is not actually
the case; when a stopped instance is reassigned its status does not
change so the update is ignored.
2024-02-06 22:43:04 +01:00
Hornwitser 40f5a6ab7c Fix timestamp not set on clearing instance saves
Set the save's updatedAtMs timestamp when clearing the save list of a
reassigned or deleted instance.  This fixes the Web UI not updating the
saves list to remove the saves when an instance is reassigned.
2024-02-06 22:43:04 +01:00
Hornwitser 48c31b4825 Fix unhandled rejection on disconnect (#582)
If a link is closed while the prepareDisconnect action is in progress
the link will still try to signal the disconnect ready over the socket
when the prepareDisconnect action completes, which throws due to the
socket no longer being present.  This error is not caught due to the
catch happening before the finally on the promise and triggers an
unhandled rejection.

Fix by rearranging the finally to come before the catch on the promise
so that errors are always handled, and checking that the link still has
a session before attempting to send the disconnect ready signal.
2024-02-05 23:36:43 +01:00
Hornwitser 520007c8d1 Fix unhandled rejection on RCON lost (#580)
When using .finally on a promise a new promise is created which calls
the handler passed to it when the the original promise is resolved or
rejected, and resolves or rejects with same value as the original
promise.  This means if the rejection of the new promise that was
created by finally is not handled then an unhandled promise rejection
will be raised.

Fix by making sure the promise rejection from the finally promise
created in subspace_storage's handleUpdateStorageEvent is properly
handled.
2024-02-05 15:51:15 +01:00
Cooldude2606 58231cc02c Add IPC to templates and handle helper function server 2024-01-24 00:40:22 +00:00
Cooldude2606 2916dcc58c Fix hang from rl.close on windows 2024-01-23 02:47:10 +00:00
Cooldude2606 b9a2d6b7bd Add current folder as default for plugin name 2024-01-23 01:00:04 +00:00
Cooldude2606 224a451bbd Fix lint for jsx template 2024-01-23 00:34:46 +00:00
Cooldude2606 b222f6dcb1 Update plugin creation docs 2024-01-23 00:24:54 +00:00
Cooldude2606 9b0293501f Add js templates and config templates 2024-01-23 00:14:08 +00:00
Cooldude2606 de00a043c3 Update lint rules for templates 2024-01-22 19:09:48 +00:00
Cooldude2606 396e2c59c7 Revert accidental edit to vscode settings 2024-01-22 18:55:34 +00:00
Cooldude2606 c3b2c846b7 Add package.json template 2024-01-22 18:42:37 +00:00
Cooldude2606 be8bba8ff8 Complete template files 2024-01-22 05:01:09 +00:00
Cooldude2606 c81d2b61a6 Add template copying 2024-01-22 02:47:34 +00:00
Cooldude2606 534dc14221 Add create command mode/args for plugin templates 2024-01-21 23:36:27 +00:00
Cooldude2606 fcf174376d Clean up the request correctly 2024-01-18 18:50:31 +00:00
Cooldude2606 0e68596fbd Remove insecure and bloated module 2024-01-18 18:50:30 +00:00
Cooldude2606 9bcbc1e7f3 Add http logging for express 2024-01-18 18:50:29 +00:00
273 changed files with 10984 additions and 1982 deletions

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
{
"image":"mcr.microsoft.com/devcontainers/universal:2",
"postCreateCommand": "wget https://factorio.com/get-download/1.1.107/headless/linux64 && tar -xf linux64 && pnpm install",
}
+18 -2
View File
@@ -23,7 +23,9 @@ module.exports = {
"@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"] }],
"prefer-const": "off",
"no-shadow": "off",
},
},
{
@@ -44,6 +46,7 @@ module.exports = {
"files": [
"packages/web_ui/src/**/*.ts?(x)",
"packages/lib/browser.ts",
"packages/create/templates/*/web/**/*.{t,j}s?(x)",
"{packages,plugins}/*/web/**/*.ts?(x)",
],
"env": {
@@ -94,6 +97,19 @@ module.exports = {
"node/shebang": "off",
},
},
{
"files": [
"packages/create/templates/**/*.{t,j}s?(x)",
],
"rules": {
"node/no-missing-require": "off",
"node/no-missing-import": "off",
"spaced-comment": "off",
"comma-spacing": "off",
"max-len": "off",
"indent": "off",
},
},
],
"rules": {
@@ -320,7 +336,7 @@ module.exports = {
"semi-style": "off",
"sort-imports": "off",
"sort-keys": "off",
"sort-vars": "error",
"sort-vars": "off",
"space-before-blocks": "error",
"space-before-function-paren": "off",
"space-in-parens": ["error", "never"],
@@ -391,7 +407,7 @@ module.exports = {
"node/process-exit-as-throw": "error",
"node/shebang": ["error", {
"convertPath": {
"*.ts": ["^(.+)\\.ts$", "dist/$1.js"],
"*.ts": ["^(.+)\\.ts$", "dist/node/$1.js"],
},
}],
},
+17
View File
@@ -0,0 +1,17 @@
Describe why and what has been changed by this pullrequest, feel free to also ask for input on decisions you are unsure about etc.
The changelog lines below will be copied directly from merged pull requests to the changelog on new releases. Changes are expected to preferrentially link to the associated issues. If there are no relevant issues, link to the pull request itself.
## Changelog
```
### Features
- Added doodad to web interface. [#999](https://github.com/clusterio/clusterio/issues/999)
### Changes
- ...
### Fixes
- ...
### Breaking changes
- ...
```
+19 -14
View File
@@ -12,41 +12,46 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18.x, 20.x]
node-version: [18.x, 20.x, 22.x]
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
factorio-version: [1.1.110]
# Supported versions are latest stable/experimental and one minor version before stable (even if from a previous major)
steps:
- uses: actions/checkout@v3
- uses: pnpm/action-setup@v2.4.0
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 8
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- run: pnpm i --no-optional --no-frozen-lockfile
- run: wget -q -O factorio.tar.gz https://www.factorio.com/get-download/latest/headless/linux64 && tar -xf factorio.tar.gz && rm factorio.tar.gz
- name: Prepare and install
run: pnpm i --no-frozen-lockfile
- name: Use Factorio ${{ matrix.factorio-version }}
run: wget -q -O factorio.tar.gz https://www.factorio.com/get-download/${{ matrix.factorio-version }}/headless/linux64 && tar -xf factorio.tar.gz && rm factorio.tar.gz
- name: Run tests
if: ${{ matrix.node-version != '18.x' }}
if: ${{ matrix.node-version != '18.x' || matrix.factorio-version != '1.1.110' }}
run: pnpm test
- name: Run coverage
if: ${{ matrix.node-version == '18.x' }}
if: ${{ matrix.node-version == '18.x' && matrix.factorio-version == '1.1.110' }}
run: pnpm run ci-cover
- name : Upload coverage to Codecov
if: ${{ matrix.node-version == '18.x' }}
uses: codecov/codecov-action@v2
if: ${{ matrix.node-version == '18.x' && matrix.factorio-version == '1.1.110' }}
uses: codecov/codecov-action@v4
with:
files: ./coverage/lcov.info
token: ${{ secrets.CODECOV_TOKEN }}
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: pnpm/action-setup@v2.4.0
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 8
- uses: actions/setup-node@v3
- uses: actions/setup-node@v4
with:
node-version: 18.x
- run: pnpm i --no-optional --no-frozen-lockfile
- run: pnpm i --no-frozen-lockfile
- run: pnpm run lint
+1
View File
@@ -31,6 +31,7 @@ node_modules
Factorio*
/factorio
!/test/file/factorio
/instances
/mods
/database
+1
View File
@@ -0,0 +1 @@
auto-install-peers=false
+175 -1
View File
@@ -1,8 +1,182 @@
# Changelog
<!-- Template for entries. Add sections that apply as changes are made.
## Version 2.0.0-alpha.xx
### Major Features
- Major additions and/or major improvements to managing clusters.
### Features
- New features added.
### Changes
- Bugfixes and small changes to existing behaviour.
### Breaking Changes
- Changes which break backwards compatibility with older hosts or plugins code.
Many thanks to the following for contributing to this release:
[@username](https://github.com/username)
-->
## Version 2.0.0-alpha.19
### Major Features
- Added support for 2.0 builtin mods during mod pack creation. [#684](https://github.com/clusterio/clusterio/pull/684)
- Added basic 2.0 support to clusterio lua api and 1st party plugins. [#685](https://github.com/clusterio/clusterio/pull/685)
### Features
- Added Datastore and Datastore Provider classes to the library to support savable Maps. [#629](https://github.com/clusterio/clusterio/pull/629)
- Added clean script to speed up publishing and debugging of build steps. [#640](https://github.com/clusterio/clusterio/pull/640)
- Added sub-tile option on plugin page headers. [#642](https://github.com/clusterio/clusterio/pull/642)
- Greatly improved plugin and module template generation. [#641](https://github.com/clusterio/clusterio/pull/641)
- Duplicate host connections from the same address will kill the older host process. [#647](https://github.com/clusterio/clusterio/pull/647)
- Controller will refuse duplicate host connections from different addresses. [#647](https://github.com/clusterio/clusterio/pull/647)
- Monorepos are now supported for external plugins. [#654](https://github.com/clusterio/clusterio/pull/654)
- Changed default role selection to be a dropdown menu rather than id. [#664](https://github.com/clusterio/clusterio/pull/664)
- Added credential option to config entries to allow writing sensitive data that can't be read back remotely.
- Allow hidden and readonly graphical representations of config values. [#665](https://github.com/clusterio/clusterio/pull/665)
- Added locale exporting for all builtin mods, not just for the base mod. [#684](https://github.com/clusterio/clusterio/pull/684)
- Added option for silent testing which only shows the results, can be ran using `pnpm silent-test` [#680](https://github.com/clusterio/clusterio/pull/680)
- Added prompt for http port on installation of Clusterio. [#564](https://github.com/clusterio/clusterio/issues/564)
- Added drag and drop overlay to upload button on mods page and save table on instance page. [#591](https://github.com/clusterio/clusterio/issues/591)
- Automatically add plugins from ./plugins, ./external_plugins and installed npm modules. [#692](https://github.com/clusterio/clusterio/pull/692)
- `compat.table_to_json` and `compat.json_to_table` supported in all stages in pre 2.0 [#687](https://github.com/clusterio/clusterio/issues/687)
- Add "connect to server" button to web interface, launches with steam api. [#608](https://github.com/clusterio/clusterio/issues/608)
- Add space platform name mapping to space platform statistics. [#706](https://github.com/clusterio/clusterio/pull/706)
- Track space platform speed metric clusterio_statistics_exporter_platform_speed. [#706](https://github.com/clusterio/clusterio/pull/706)
- Track space platform weight metric clusterio_statistics_exporter_platform_weight. [#706](https://github.com/clusterio/clusterio/pull/706)
- Added bidirectional sync to admin and ban list. [#709](https://github.com/clusterio/clusterio/pull/709)
### Fixes
- Fixed IPC errors reporting undefined when no stack trace was present. [#624](https://github.com/clusterio/clusterio/pull/624)
- Fixed loopback routing for instances reporting the wrong error message. [#625](https://github.com/clusterio/clusterio/pull/625)
- Fixed docs linking to non-existing js files after ts migration. [#633](https://github.com/clusterio/clusterio/pull/633)
- Fixed CI workflows using deprecated version of nodejs. [#637](https://github.com/clusterio/clusterio/pull/637)
- Fixed linux only tests to run on windows. [#661](https://github.com/clusterio/clusterio/pull/661)
- Fixed log events being sent from a host over an invalid websocket. [#639](https://github.com/clusterio/clusterio/pull/639)
- Fixed spaces passed in arguments to the installer causing it to break. [#620](https://github.com/clusterio/clusterio/issues/620)
- Fixed numeric admin name breaking login. [#536](https://github.com/clusterio/clusterio/issues/536)
- Fixed issues with subscriptions which prevented them from working on instances or generic events. [#655](https://github.com/clusterio/clusterio/pull/655).
- Fixed unreliable datastore tests. [#662](https://github.com/clusterio/clusterio/pull/662).
- Fixed 2.0 version extraction from linux headless. downloaded during installation of clusterio. [#671](https://github.com/clusterio/clusterio/pull/671)
- Fixed invalid transient state during server start. [#676](https://github.com/clusterio/clusterio/issues/676)
- Fixed host user sync to instances after reconnecting to the. controller. [#678](https://github.com/clusterio/clusterio/pull/678)
- Fixed WebUI adding and displaying the wrong base mod version. [#684](https://github.com/clusterio/clusterio/pull/684)
- Fixed WebUI inconsistent pattern matching for factorio version. [#684](https://github.com/clusterio/clusterio/pull/684)
- Fixed `safeOutputFile` not being atomic because of a missing path separator. [#659](https://github.com/clusterio/clusterio/pull/659)
- Fixed built-in mods showing as missing in the WebUI. [#581](https://github.com/clusterio/clusterio/issues/581)
- Fixed dirty flag not being set when only the config of an instance is updated. [#675](https://github.com/clusterio/clusterio/pull/675)
- Fixed incorrect command being used to remove players from the whitelist. [#679](https://github.com/clusterio/clusterio/pull/679)
- Fixed CI following undocumented breaking change from downstream dependency. [#683](https://github.com/clusterio/clusterio/pull/683)
- Fixed instance crash when missing saves folder. [#698](https://github.com/clusterio/clusterio/issues/698)
- Fixed statistics exporter not working on newer versions of windows 11 due to wmic deprecation. [#707](https://github.com/clusterio/clusterio/issues/707)
### Changes
- Added prompt for the HTTP port to host the controller to the installer. [#619](https://github.com/clusterio/clusterio/pull/619)
- Added tests for sendEvent and sendRequest throwing the correct error messages. [#638](https://github.com/clusterio/clusterio/pull/638)
- Updated all web ui pages to use correct page header component. [#643](https://github.com/clusterio/clusterio/pull/643)
- Bumped Typescript version to 5.5 and implemented `${configDir}` in base configs for "outDir". [#648](https://github.com/clusterio/clusterio/pull/648).
- Added factorio.executable_path option which allows overriding the default path to the Factorio executable run.
- Added instance version display. [#573](https://github.com/clusterio/clusterio/pull/573)
- Added `factorio.shutdown_timeout` config to set the time the host will wait for a Factorio server to shut down befor killing it.
- Changed shutdown logic to prefer sending a /quit command via RCON instead of using diverging logic on Windows and Linux.
- Added `controller.factorio_username` and `controller.factorio_token` config to set Factorio credentials used for the whole cluster.
- Added `controller.share_factorio_credential_with_hosts` config to optionally require hosts to provide their own credentials.
- Added `host.factorio_username` and `host.factorio_token` config to set Factorio credentials used on a given host.
- Changed `instance.rcon_password` config to be a credential field that can not be read by the control interface.
- Changed `host.id` `instance.id` and `instance.assign_host` config to be hidden fields that can not be seen on the web ui.
- Updated display name and description of `controller.external_address` to avoid confusion. [#674](https://github.com/clusterio/clusterio/pull/674)
- Renamed config value `controller.external_address` to `controller.public_url`, migrations are applied automatically.
- String config values with whitelisted values (enum) now display as a selection dropdown. [#701](https://github.com/clusterio/clusterio/pull/701)
- Existing selection dropdowns now support search filtering. [#701](https://github.com/clusterio/clusterio/pull/701)
- Added surface label to clusterio_statistics_exporter_instance_force_flow_statistics. [#691](https://github.com/clusterio/clusterio/issues/691)
- Compat lib now supports versions from 0.17 to 2.0 [#711](https://github.com/clusterio/clusterio/pull/711)
- Add bidirectional sync to admin and ban list. [#709](https://github.com/clusterio/clusterio/pull/709)
### Breaking Changes
- Previously silent errors for controller.sendEvent now throw exceptions [#625](https://github.com/clusterio/clusterio/pull/625).
- @clusterio/controller export InstanceInfo has added factorioVersion parameter.
- Argument order for `Config.canAccess` changed to require an access mode passed as the second argument.
- Instances can now have `factorio.enable_script_commands` disabled which will throw an error when any script command is used over rcon. [#681](https://github.com/clusterio/clusterio/pull/681).
- User IDs are now case insensitive, duplicate users will be automatically merged with a backup created. [#682](https://github.com/clusterio/clusterio/pull/682)
- Plugins now require the keyword "clusterio-plugin" in package.json to be automatically loaded [#692](https://github.com/clusterio/clusterio/pull/692)
Many thanks to the following for contributing to this release:
[@CCpersonguy](https://github.com/CCpersonguy)
[@Cooldude2606](https://github.com/Cooldude2606)
[@Danielv123](https://github.com/Danielv123)
[@Hornwitser](https://github.com/Hornwitser)
[@Laar](https://github.com/Laar)
[@psihius](https://github.com/psihius)
## Version 2.0.0-alpha.18
### Features
- Added logging of HTTP requests and errors on the controller [#556](https://github.com/clusterio/clusterio/pull/556).
- Added caching of data parsed from mods to speed up startup time [#606](https://github.com/clusterio/clusterio/pull/606).
- Added command to installer to generate the code for making a new plugin [#570](https://github.com/clusterio/clusterio/pull/570).
### Changes
- Fixed Inventory Sync reporting a negative database size [#598](https://github.com/clusterio/clusterio/pull/598).
- Fixed plugin events being invoked while a host connection was in an invalid state.
- Suppresed bogus warning about event listener leak in the browser's console [#600](https://github.com/clusterio/clusterio/pull/600).
- Fixed installer breaking on Windows after Node.js released a security fix [#614](https://github.com/clusterio/clusterio/pull/614).
- During a data export the mod settings in mod packs will now be corrected to the type of the settings prototype if the type is incorrect.
- Fixed missing color setting causing the Mod Pack view in the Web UI to show an error [#609](https://github.com/clusterio/clusterio/issues/609).
Many thanks to the following for contributing to this release:
[@Cooldude2606](https://github.com/Cooldude2606)
[@Danielv123](https://github.com/Danielv123)
[@Hornwitser](https://github.com/Hornwitser)
## Version 2.0.0-alpha.17
### Features
- Made the host an instance is assigned to link to the host page for that host in the Web UI [#587](https://github.com/clusterio/clusterio/pull/587).
- Added pagination to the saves list.
### Changes
- Fixed an unhandled error in Subspace Storage crashing the host.
- Fixed an unhandled rejection in disconnect logic crashing the host.
- Fixed instance screen in Web UI not updating when an instance was reassigned.
- Fixed an error thrown in the rendering of one page in the Web UI causing all pages to become inaccessible.
- Fixed Web UI not showing instance saves when the permission to list and subscribe the saves is granted but the user is not a cluster admin.
- Similarly fixed Web UI not showing the mod pack create, delete, list and host revoke token actions if the user is not a cluster admin.
- Fixed Research Sync applying progress of a previous techology level to the level on the controller when it's the active research during instance startup.
- Fixed data export with a mod using shorthand notation for the default value of a color-setting causing the ModPack data to be set to invalid data.
- Fixed per user per instance player stats not being deleted when the instance it's for is deleted.
- Fixed mods setting the item prototype icons property to an object mapping instead of an array breaking the data export.
- Fixed a bad mod in the host's mods folder causing instance start to fail instead of the host re-downloading the mod.
### Breaking Changes
- Renamed the permission `core.instance.save.list.subscribe` to `core.instance.save.subscribe`.
Many thanks to the following for contributing to this release:
[@Cooldude2606](https://github.com/Cooldude2606)
[@Danielv123](https://github.com/Danielv123)
[@Hornwitser](https://github.com/Hornwitser)
## Version 2.0.0-alpha.16
## Changes
### Changes
- Fixed message sequences not being renumbered when forwarded between links causing reconnects to send duplicated messages.
+15 -6
View File
@@ -198,7 +198,7 @@ For the standalone installation mode you can use
If you plan to make your cluster available externally set the address
that it will be accessible under with, for example
npx clusteriocontroller config set controller.external_address http://203.0.113.4:1234/
npx clusteriocontroller config set controller.public_url http://203.0.113.4:1234/
Change the url to reflect the IP, protocol, and port the controller is accessible under, dns names are also supported.
If you're planning on making the controller accessible on the internet it's recommended to set up TLS, see the [Setting Up TLS](/docs/setting-up-tls.md) document for more details.
@@ -209,12 +209,17 @@ See the [readme for @clusterio/controller](/packages/controller/README.md) for m
## Managing Factorio mods
Clusterio currently only provides a manual method for running mods on servers by copying them either to each instance's individual `mods` folder, or by copying them into a host installation's `sharedMods` folder which will link/copy them to the `mods` folder of each instance on that host if the file doesn't already exist in the `mods` folder for that instance.
Mods are copied from `sharedMods` on Windows and symlinked on Linux, this means that when mods are removed from `sharedMods` the mods will also be removed from the instance's `mods` folder on Linux if it got there by being linked by Clusterio, but not on Windows.
Mods in clusterio are managed as parts of modpacks. There is currently no mod portal integration, so you first need to navigate to the mods section of the web UI and upload your mods. You can then create a modpack from the uploaded mods by navigating to the mods page, hitting `create`. Once you have a modpack, you can assign it to an instance under Instance config -> Factorio -> Mod pack. You can also set a default modpack for new instances in the controller settings.
For managing the mod settings, the `mod-settings.dat` and `mod-list.json` files will also be linked/copied over if present in `sharedMods`, but since Factorio creates these files automatically it might be necessary to delete them from each instance's `mods` folder for changes to them in `sharedMods` to take effect.
![Select mod pack for instance](docs/assets/config_select_mod_pack.png)
Better remote management of mods is a planned feature.
Mod settings can be managed under the modpack configuration page in the web UI. For mod settings to show, you first need to run an export. This is done by creating an instance with the modpack assigned, opening the instance page and pressing More -> Export data.
![Export mod configuration data](docs/assets/export_mod_config_data.png)
Once this is done, the mod settings will show up under the modpack configuration page.
![Mod settings configuration in web interface](docs/assets/mod_settings_configuration.png)
## Setting up shared storage
@@ -231,7 +236,7 @@ After completing the installation start up the controller and at least one host
The installer provides the `run-controller` and `run-host` scripts to make this simple.
Once the controller process is running you can log into the web interface which is hosted by default on http://localhost:8080/ (adjust the port number if you changed it), use the admin authentication token provided from the installation to log in.
The basics of setting up a Factorio server from the web interface is to create an instance, assign it to a host and then click start.
The basics of setting up a Factorio server from the web interface is to create an instance, assign it to a host and then click start. You can also use the "Connect via Steam" button to launch Factorio directly and connect to the server using Steam's protocol handler.
### Running via Systemd
@@ -270,6 +275,10 @@ If you want to use a different user for the command line interface, you can gene
- When the `server_select` plugin is installed you may see `[error] Unhandled event server_select:UpdateInstancesEvent` logged when starting an instance.
This should be ignored as it does not indicate an error occured.
- After updating Factorio you may get the error `Error: Expected empty response but got "Cannot execute command. Error: [string "clusterio_private.update_instance(..."]:1: attempt to index global 'clusterio_private' (a nil value)?` on instance start.
This is due to Factorio applying a migration replacing the patched content of the save with the original scenario, attempting to start the instance again should resolve the issue.
- In the mod pack view of the web interface the "base" mod is displayed with an error complaining about the mod being missing from the controller storage.
This is due to the "base" mod currently not being connectly handled as a built-in mod to Factorio in the web interface, this error message should be ignored.
- After migrating from Alpha 13 to Alpha 14 running an instance data export fails with the error `Upload failed: 401 Unauthorized` due to the host token being issued to a slave.
To workaround this find the id of the host in the host config and generate a new host token with the same id.
Stop the host and replace the value of the `host.controller_token` field in `config-host.json` with the new host token before starting it up again.
+116
View File
@@ -0,0 +1,116 @@
/**
* Clean the repository of build and execution artifacts.
*
* clean: A quick clean that removes all build artifacts making the output safe to publish
* clean-tests: Removes all build and test artifacts should not be required if tests are setup correctly.
* clean-all: Removes all build, test, and execution artifacts. Including cluster config and logs!
*/
"use strict";
const path = require("path");
const fs = require("fs-extra");
const yargs = require("yargs");
const npmPackage = require("./package.json");
const workspaces = npmPackage.workspaces
.map(p => p.slice(0, -2))
.filter(p => !p.includes("external_plugins"));
let DRY = false;
/**
* Attempt to remove a file / directory if it exists.
*
* @param {string} filePath The path to remove.
*/
async function tryRemove(filePath) {
if (await fs.exists(filePath)) {
// eslint-disable-next-line no-console
console.log(filePath);
}
if (!DRY) {
await fs.remove(filePath);
}
}
/**
* Removes all build artifacts
*/
async function removeBuildArtifacts() {
const tasks = [];
for (const workspace of workspaces) {
const basePath = path.resolve(workspace);
const packages = await fs.readdir(basePath);
for (const packageName of packages) {
tasks.push(
tryRemove(path.join(basePath, packageName, "dist"))
);
}
}
await Promise.all(tasks);
}
/**
* Removes all test artifacts
*/
async function removeTestArtifacts() {
const artifacts = [
".nyc_output", "coverage", "temp", "static",
];
await Promise.all(artifacts.map(artifact => tryRemove(path.resolve(artifact))));
}
/**
* Removes all execution artifacts
*/
async function removeExecutionArtifacts() {
const artifacts = [
"database", "instances", "logs", "mods",
"config-control.json", "config-controller.json", "config-host.json",
"FactorioAdminToken.txt", "plugin-list.json",
];
await Promise.all(artifacts.map(artifact => tryRemove(path.resolve(artifact))));
}
/**
* Main function for this script
*/
async function main() {
await yargs
.scriptName("clean")
.usage("$0 <command> [options]")
.option("dry", {
nargs: 0,
describe: "Will not delete any files",
default: false,
type: "boolean",
})
.command("all", "Remove all artifacts", () => {}, argv => {
DRY = argv.dry;
removeBuildArtifacts();
removeTestArtifacts();
removeExecutionArtifacts();
})
.command("tests", "Remove all build and test artifacts", () => {}, argv => {
DRY = argv.dry;
removeBuildArtifacts();
removeTestArtifacts();
})
.command("fast", "Remove all build artifacts", () => {}, argv => {
DRY = argv.dry;
removeBuildArtifacts();
})
.demandCommand(1, "You need to specify a command to run")
.strict()
.parse();
}
// Run main if started from command line
if (module === require.main) {
main();
}
+6
View File
@@ -0,0 +1,6 @@
coverage:
status:
project:
default:
if_ci_failed: success
informational: true
Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

+1 -1
View File
@@ -30,7 +30,7 @@ There's also the virtual connector which lets two links in the same program be c
## Message
There's two types of messages currently implemented apart from the Link builtin close message and that's Request and Event.
The builtin messages are defined in [packages/lib/link/messages.js](/packages/lib/link/messages.js) and are attached to links via the attachAllMessages function, which is usally called in the Link subclass constructor.
The builtin messages are defined in [packages/lib/src/link/messages.ts](/packages/lib/src/link/messages.ts) and are attached to links via the attachAllMessages function, which is usally called in the Link subclass constructor.
The messages define which links they are valid on and only attaches handlers for those links.
There's also a forwarding mechanism for when a request or event is to be forwarded another hop.
+1 -1
View File
@@ -6,7 +6,7 @@ Clusterio uses a field based configuration system to manage settings for the con
Plugins can add their own config fields for the controller and instances configs.
The fields are defined early in the startup, and fields for disabled plugins will still be created.
The built-in config fields are defined in [packages/lib/config/definitions.js](/packages/lib/config/definitions.js) while plugins can add their own config field definitions to the `controllerConfigFields` and `instanceConfigFields` properties of the `plugin` export.
The built-in config fields are defined in [packages/lib/src/config/definitions.ts](/packages/lib/src/config/definitions.ts) while plugins can add their own config field definitions to the `controllerConfigFields` and `instanceConfigFields` properties of the `plugin` export.
A `<plugin_name>.load_plugin` field is automatically created for each plugin and it's used to enable/disable the loading plugins on startup.
+2 -2
View File
@@ -51,9 +51,9 @@ interfaces.
Defaults to null.
### controller.external_address
### controller.public_url
External address the controller is accessible on.
Public address the controller is accessible on.
Currently only used for `clusteriocontroller bootstrap create-ctl-config` in order to give the right url to connect to.
This should be a full URL ending with a /.
+10 -5
View File
@@ -81,6 +81,9 @@ Note that to use `clusterioctl` you will have to create a control config first:
Once you've set up the cluster you can use `node packages/controller run` to run the controller and `node packages/host run` to run the host which connects to the controller.
When editing TypeScript files ran by Node.js you need to run `pnpm build` to compile the files to JavaScript before restarting the controller/host/ctl for changes take effect.
To have TypeScript watch for changes and rebuild immediadly you may run `pnpm watch` instead.
For web development on the controller there are also the following flags:
`--dev` - Recompile the `web_ui` package on the fly providing live reloading of it.
@@ -297,14 +300,16 @@ You will need commit access to the repository as well publish access to the @clu
Check and make sure there are no untracked files with `git status`.
2. Update the version in package files.
2. Check all PRs merged since the last version and copy their change logs into the main changelog.
3. Update the version in package files.
Make sure to update the command for the right version jump.
```sh
sed -i 's/2\.0\.0-alpha\.12/2\.0\.0-alpha\.13/' {packages,plugins}/*/package.json
```
3. Inspect, commit and tag the changes.
4. Inspect, commit and tag the changes.
Again, make sure to be tagging the right version.
```sh
@@ -313,9 +318,9 @@ You will need commit access to the repository as well publish access to the @clu
git tag v2.0.0-alpha.13
```
4. Publish the packages.
5. Publish the packages.
```sh
# Warning: Double check your current working directory is the repository before running this
pnpm -r --filter='!./external_plugins/**' publish
pnpm clean
pnpm -r --filter='./{plugins,packages}/*' publish
```
+1 -1
View File
@@ -78,7 +78,7 @@ import { BaseInstancePlugin } from "@clusterio/host";
Previously the info of a plugin would be loaded by requiring `./plugin_dir/info`, this has been changed to require `./plugin_dir` which in turn means that `info.js` is no longer the default entrypoint.
To solve this you can either add `"main": "info.js",` to package.json or rename `info.js` to `index.js` (the rest of this guide assumes the entrypoint was renamed to `index.js`).
The motivation behind this change was to allow TypeScript to output its build to a sub-folder of the plugin, if you're using TypeScript you'll likely have the entrypoint be located in `dist/index.js` instead.
The motivation behind this change was to allow TypeScript to output its build to a sub-folder of the plugin, if you're using TypeScript you'll likely have the entrypoint be located in `dist/node/index.js` instead.
The expected place where the declaration of a plugin is exported has also changed from `module.exports` to `module.exports.plugin`:
+3 -3
View File
@@ -1,7 +1,7 @@
# Protocol
Clusterio communicates using a simple homebrew message passing protocol based on JSON over WebSocket with limited support for reliable transport of messages.
The protocol is implemented in [packages/lib/link](/packages/lib/link) and uses the `ws` library to handle the WebSocket connections on the Node.js side and the native WebSocket API on the browser side.
The protocol is implemented in [packages/lib/src/link](/packages/lib/src/link) and uses the `ws` library to handle the WebSocket connections on the Node.js side and the native WebSocket API on the browser side.
Two general kinds of messages are supported: events and requests.
Events are messages sent by one party and received by the other and offer no notification for the sender on whether it was actually received.
@@ -166,7 +166,7 @@ Events are messages send in one direction over the link, that the receiver is ex
By convention message types ending in `*_event` are events.
The data payload of an event consists solely of event specific properties.
See [packages/lib/link/messages.js](/packages/lib/link/messages.js) for the recognized core events and their contents.
See [packages/lib/src/link/messages.ts](/packages/lib/src/link/messages.ts) for the recognized core events and their contents.
Plugins may defined their own events as well.
#### `*_event`
@@ -181,7 +181,7 @@ They function a lot like HTTP requests except that both parties of a connection
By convention message types ending in `*_request` are requests, and is expected to be replied to with a corresponding `*_response` message.
If an error occured while processing the request an error response containing an error property in the data payload is sent instead.
See [packages/lib/link/messages.js](/packages/lib/link/messages.js) for the recognized core requests and their contents.
See [packages/lib/src/link/messages.ts](/packages/lib/src/link/messages.ts) for the recognized core requests and their contents.
Plugins may define their own requests as well.
#### `*_request`
+114 -139
View File
@@ -12,8 +12,6 @@ The plugin classes have pre-defined hooks that are called during various stages
- [Plugin Configuration](#plugin-configuration)
- [Communicating with Factorio](#communicating-with-factorio)
- [Defining Link Messages](#defining-link-messages)
- [Defining Events](#defining-events)
- [Defining Requests](#defining-requests)
- [Sending Link Messages](#sending-link-messages)
- [Handling connection events](#handling-connection-events)
- [Collecting Statistics](#collecting-statistics)
@@ -41,6 +39,7 @@ The usual guides for creating such packages apply.
At minimum the `package.json` file must contain a version entry.
A possible workflow for developing plugins is to place the plugin in a sub-directory of where clusterio has been installed, and rely on Node.js searching up the folder heirarchy for it to find `@clusterio/lib`.
After creating a directory for your plugin, the boilerplate can be generated automaticlly from a set of tempaltes using `npm exec @clusterio/create -y -- --plugin-template` after which you will be asked which areas your plugin will interact with and your plugin's name.
To add it to `plugin-list.json` so that it gets loaded use the `plugin add <path>` sub-command to either clusteriocontroller, clusteriohost or clusterioctl.
Note that it's important that the path starts with ./ or ../ (use .\ or ..\ on Windows).
@@ -302,6 +301,20 @@ local clusterio_api = require("modules/clusterio/api")
clusterio_api.send_json("my_plugin_foo", { data = 123 })
```
For convience a helper function is exposed on server specifically for handling IPC events.
```js
async init() {
this.instance.server.handle("my_plugin_foo", this.handleFoo.bind(this));
}
async handleFoo(content) {
// Do stuff with content
}
```
**Note:** This only handles IPC events, all other events must use `on`. You should not include the `ipc-` prefix when using this method. Error logging is handled for you, if you want custom error handling use `on`.
It's recommended to either use the plugin name as the channel name or to prefix the channel name with the name of the plugin if you need multiple channels.
It's also important to catch any errors that might occur as they will otherwise be propogated to the instance code and kill the server.
@@ -325,175 +338,137 @@ Events are simple one-way notifications that invoke a handler on the target it's
Requests are pairs of request and response messages where the request is sent to the target and the response is the reply back from the target.
The requests are similar to HTTP requests, except both parties of a link may initiate one.
### Defining Events
Events are defined as properties of the messages object exported on `plugin` that map to instances of the `Event` class from `lib/link`.
The name of the property correspond to the handler invoked on the plugin class.
The Event constructor takes an object of properties that define the event, for example the following could be defined in `plugin`:
```js
messages: {
startFrobnication: new lib.Event({
type: "foo_frobber:start_frobnication",
links: ["controller-host", "host-instance"],
forwardTo: "instance",
eventRequired: ["frobnication_type"],
eventProperties: {
"frobnication_type": { type: "string" },
"urgent": { type: "boolean" },
},
}),
},
```
This specifies an event that can be sent from the controller to a host, and from a host to an instance.
It also specifies that the event must contain the property `frobnication_type`, with a string value in the data payload and that it may optionally contain a boolean `urgent` property.
It will also be forwarded by hosts to a specific instance.
The following properties are recognized by the Event constructor:
#### type
The message type sent over the wire.
This must start with the name of the plugin followed by colon and and be unique for the plugin.
The type of the message sent over the socket will have the suffix `_event` appended to it.
#### links
An array of strings describing which links this event can be sent over.
Direction matters, `"controller-host"` means the event can be sent from the controller to the host, but can't be sent back the other way, unless `"host-controller"` is also present in the links array.
The available endpoints are `controller`, `host`, `instance`, and `control`.
Controller talks with host and control, and host talks to instance.
The full chain must be specified as the individual links in order for a message to travers multiple hops, (i.e., for a message to go from controller to instance it must have both `"controller-host"` and `"host-instance"` in the links array).
See `forwardTo` and `broadcastTo` for ways to forward an event to the next link in a chain.
#### forwardTo
Target to forward an event to.
Can either be `"controller"`, to indicate a host should forward it to the controller, or `"instance"`, to indicate it should be forwarded to the instances specified by the `instance_id` event property.
This works by using a default handler for the event at the links that forward it.
#### broadcastTo
Target to broadcast this message towards.
A value of "instance" means the event will be broadcast to all instances downstream of the target it's sent to, but not back from where it came from.
Currently, only "instance" is supported.
This means that sending the event to a host from an instance will cause it to be broadcast to all instances of that host except for the instance it came from.
#### eventRequired
By default all properties defined in `eventProperties` are required to be present in the payload for the event.
This can be overriden by specifying an array of properties which are required here.
This is equivalent to using the `required` keyword in JSON schema.
#### eventProperties
Object with properties mapping to a JSON schema of that property that specifies what's valid to send in the event.
This is equivalent to using the `properties` keyword in JSON schema, except that the properties specified are by default required and additional properties are not allowed.
See [this guide][guide] for an introduction to writing JSON schemas.
The forwardTo and broadcastTo can be combined such that specifying `"controller"` as the forwardTo value and `"instance"` as the broadcastTo value will cause the event to be broadcast to all instances in the cluster.
For this to work, you will need to specify `instance-host`, `host-controller`, `controller-host`, and `host-instance` as the links.
Keep in mind when forwarding events that if the target an event is being forwarded to is not online, the event will be dropped.
Use a request if you need a confirmation that the message was received.
### Definining Requests
Requests are defined as properties of the messages object exported by `plugin` that map to instances of the `Request` class from `lib/link`.
The name of the property corresponds to the handler invoked on the plugin class.
The Request constructor takes an object of properties that define the event.
Messages are defined as items of the `plugin.messages` array exported by `index.js`.
For example, the following could be defined in `plugin`:
```js
messages: {
reportFrobnication: new lib.Request({
type: "foo_frobber:report_frobnication",
links: ["controller-host", "host-instance"],
forwardTo: "instance",
requestRequired: ["verbosity"],
requestProperties: {
"verbosity": { type: "integer" },
"special": { type: "boolean" },
},
responseProperties: {
"report": {
type: "array",
items: { type: "string" },
messages: [
class Frobnicate {
static plugin = "foo_frobber"; // Plugin name
static type = "request";
static src = "controller";
static dst = "instance";
static jsonSchema = {
type: "object",
properties: {
"verbosity": { type: "integer" },
"special": { type: "boolean" },
},
},
}),
},
required: ["verbosity"],
additionalProperties: false,
};
constructor(json) {
this.verbosity = json.verbosity;
this.special = json.special;
}
static fromJSON(json) {
return new Frobnicate(json);
}
toJSON() {
return {
verbosity: this.verbosity,
special: this.special,
};
}
static Response = {
jsonSchema: {
type: "object",
properties: {
"report": {
type: "array",
items: { type: "string" },
},
},
},
fromJSON(json) {
return json
}
}
}
]
```
This specifies a request that can be sent from the controller to a host, and from a host to an instance.
The request data must contain the property `verbosity` with an integer number as the value, as well as the `instance_id` property (implied by `forwardTo: "instance"`) and it may also contain a boolean `special` property.
It also defines that the response sent must contain a `report` property mapping to an array of strings.
When received by a host, it will also be forwarded to the instance specified by `instance_id`.
This specifies a request that can be sent from the controller to an instance, being routed through the correct host behind the scenes.
The request data must contain the property `verbosity` with an integer number as the value, and it may also contain a boolean `special` property.
It also defines that the response sent may contain a `report` property mapping to an array of strings.
The following properties are recognized by the Request constructor:
The following properties are recognized as part of a message class:
#### type
#### static plugin
The message type sent over the wire.
This must start with the name of the plugin followed by colon and and be unique for the plugin.
The type of the message sent over the socket will have the suffix `_request` appended to it for the request and `_response` appended to it for the response.
Must contain the name of the plugin this message is defined by.
This property is not used in core messages.
#### links
#### static type
An array of strings describing which links this request can be sent over.
Direction matters; `"controller-host"` means the request can be sent from the controller to the host and the host can reply to the controller, but the host can't send a request to the controller unless `"host-controller"` is also present in the links array.
A string describing the message type sent over the wire.
This can be either `"request"` or `"event"`.
The primary difference is whether they allow for a response.
The available endpoints are `controller`, `host`, `instance`, and `control`.
Controller talks with host and control, and host talks to instance.
The full chain must be specified as the individual links in order for a message to travers multiple hops, (i.e., for a message to go from controller to instance it must have both `"controller-host"` and `"host-instance"` in the links array).
See `forwardTo` for ways to forward a request to the next link in a chain.
#### static src
#### forwardTo
A string or an array of strings describing where this message is allowed to originate from.
The available endpoints are `"controller"`, `"host"`, `"instance"`, or `"control"`.
Messages sent from a different source will be rejected.
Target to forward the request to.
Can either be `"controller"` to indicate a host should forward it to the controller when receiving it from an instance, or `"instance"` to indicate it should be forwarded to the instances specified by the `instance_id` request property.
This works by using a default handler for the request by the links that forward it.
#### static dst
#### requestRequired
A string or array of strings describing where this message may be addressed to.
The available endpoints are `"controller"`, `"host"`, `"instance"`, or `"control"`.
If the target is multiple hops away it the message will be forwarded towards its destination by the intermediaries.
For example a message sent from a control connection an instance will be forwarded by the controller to the relevant host and from the host to the correct instance.
Messages sent to a different destination will be rejected.
By default all properties defined in `requestProperties` are required to be present in the payload for the request.
This can be overriden by specifying an array of properties which are required here.
This is equivalent to using the `required` keyword in JSON schema.
#### static jsonSchema
#### requestProperties
A JSON schema that specifies what's valid to send in the message.
Object with properties mapping to a JSON schema of that property that specifies what's valid to send in the request.
This is equivalent to using the `properties` keyword in JSON schema, except that the properties specified are by default required and additional properties are not allowed.
See [this guide][guide] for an introduction to writing JSON schemas
#### toJSON
#### responseRequired
Optional method to serialize an instance of the class to a JSON object, see the [documentation for `JSON.stringify()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#description) for the semantics of this method.
This should return an object that's valid according to `jsonSchema` and accepted by `fromJSON`, and may be omitted if the default serialisation by `JSON.stringify()` already does this.
Same as the requestRequired only for the response sent back by the target.
#### static fromJSON
#### responseProperties
Required function to restore the class from a JSON object.
This function is called when the message reaches its destination and is expected to return an identical instance of the class.
I.e. the result of `fromJSON(JSON.parse(JSON.stringify(instance)))` should be a complete copy of the message.
Same as the requestProperties only for the response sent back by the target.
#### static Response
Optional [JSON sterilisable class](/docs/devs/json-serialisable-classes.md) that specifies the response to the request.
Required properties are `jsonSchema` and `fromJSON`.
## Sending Link Messages
Link messages are sent by calling the `.send()` method on the Event/Request instance with the link you want to send it over and the data to send.
For `InstancePlugin` code the link to the host is the `instance` itself, which is accessible through the `.instance` property of the `InstancePlugin`.
The `.info` property of the plugin class exposes the data exported from the plugin's `plugin` export.
In other words:
Link messages are sent by calling the `.sendTo()` method on a connection object with the destination and the data you want to send, where the data is an instance of one of the defined message classes in plugin.messages.
For example:
```js
// In an InstancePlugin class
async frobnicate() {
this.info.messages.exampleEvent.send(this.instance, { foo: "bar" });
const response = await this.instance.sendTo("controller", new messages.Frobnicate({
verbosity: 2,
special: false,
}));
console.log(response); // { report: [...] }
}
```
For the Request class the send method is async and returns the response data received from the target it was sent to, or throws an error if the request failed.
For classes with `static type = "request"` the send method is async and returns a promise that resolves to the response data received from the target it was sent to, or rejects with an error if the request failed.
The destination specification can either be the ID of a particular control, host or instance, or one of the keywords used to send to multiple targets at once.
Example destinations:
```js
sendTo("controller", message);
sendTo("allHosts", message);
sendTo("allControls", message);
sendTo("allInstances", message);
sendTo({ hostId: 123 }, message);
sendTo({ instanceId: 123 }, message);
sendTo({ controlId: 123 }, message);
```
### Handling connection events
@@ -508,7 +483,7 @@ For example the sending of an event from an `InstancePlugin` class can be stoppe
```js
if (this.host.connected) {
this.info.messages.frobnicate.send(this.instance, { foo: "bar" });
this.instance.sendTo("controller", new messages.Frobnicate({ foo: "bar" }));
}
```
@@ -617,4 +592,4 @@ class CtlPlugin extends BaseCtlPlugin {
module.exports = {
CtlPlugin,
}
```
```
+18 -3
View File
@@ -1,15 +1,21 @@
{
"name": "root",
"private": true,
"files": [],
"scripts": {
"build": "tsc --build",
"test": "pnpm run build && mocha --check-leaks -R spec --recursive test \"plugins/*/test/**\"",
"watch": "tsc --build --watch",
"test": "pnpm run build && mocha --enable-source-maps --check-leaks -R spec --recursive test \"plugins/*/test/**\" --exclude \"test/manual/**\"",
"silent-test": "SILENT_TEST=y pnpm test",
"fast-test": "FAST_TEST=y pnpm test",
"cover": "nyc pnpm test",
"fast-cover": "FAST_TEST=y nyc pnpm test",
"ci-cover": "nyc --reporter=lcovonly pnpm run-script test",
"lint": "eslint packages plugins test",
"lint-fix": "eslint --fix packages plugins test",
"clean": "node ./clean.js fast",
"clean-tests": "node ./clean.js tests",
"clean-all": "node ./clean.js all",
"docs": "typedoc",
"bundle-dependencies": "bundle-dependencies"
},
@@ -22,11 +28,19 @@
"engines": {
"node": ">=18"
},
"workspaces": [
"packages/*",
"plugins/*",
"external_plugins/*",
"external_plugins/*/*"
],
"devDependencies": {
"@clusterio/controller": "workspace:*",
"@clusterio/ctl": "workspace:*",
"@clusterio/host": "workspace:*",
"@clusterio/lib": "workspace:*",
"@clusterio/web_ui": "workspace:*",
"@swc/core": "^1.4.0",
"@typescript-eslint/eslint-plugin": "^6.4.1",
"@typescript-eslint/parser": "^6.4.1",
"eslint": "^8.45.0",
@@ -39,7 +53,8 @@
"mocha": "^10.2.0",
"nyc": "^15.1.0",
"phin": "^3.7.0",
"typedoc": "^0.24.8",
"typescript": "^5.1.6"
"typedoc": "^0.26.3",
"typescript": "^5.5.3",
"yargs": "^17.7.2"
}
}
+1 -1
View File
@@ -1,2 +1,2 @@
webpack.config.js
dist/web/build
dist/browser
+16 -19
View File
@@ -66,9 +66,9 @@ void new lib.Gauge(
if (!controller || !controller.hosts) {
return;
}
for (let [id, host] of controller.hosts) {
for (const host of controller.hosts.values()) {
gauge.labels({
host_id: String(id),
host_id: String(host.id),
host_name: host.name,
}).set(1);
};
@@ -86,9 +86,9 @@ void new lib.Gauge(
if (!controller || !controller.instances) {
return;
}
for (let [id, instance] of controller.instances) {
for (const instance of controller.instances.values()) {
gauge.labels({
instance_id: String(id),
instance_id: String(instance.id),
instance_name: String(instance.config.get("instance.name")),
host_id: String(instance.config.get("instance.assigned_host")),
}).set(1);
@@ -139,7 +139,7 @@ async function handleBootstrapCommand(
return;
}
let admin = userManager.users.get(args.name);
let admin = userManager.getByName(args.name);
if (!admin) {
admin = userManager.createUser(args.name);
}
@@ -150,14 +150,14 @@ async function handleBootstrapCommand(
await userManager.save(path.join(controllerConfig.get("controller.database_directory"), "users.json"));
} else if (subCommand === "generate-user-token") {
let user = userManager.users.get(args.name);
let user = userManager.getByName(args.name);
if (!user) {
logger.error(`No user named '${args.name}'`);
process.exitCode = 1;
return;
}
// eslint-disable-next-line no-console
console.log(userManager.signUserToken(user.name));
console.log(userManager.signUserToken(user));
} else if (subCommand === "generate-host-token") {
// eslint-disable-next-line no-console
@@ -167,7 +167,7 @@ async function handleBootstrapCommand(
));
} else if (subCommand === "create-ctl-config") {
let admin = userManager.users.get(args.name);
let admin = userManager.getByName(args.name);
if (!admin) {
logger.error(`No user named '${args.name}'`);
process.exitCode = 1;
@@ -178,7 +178,7 @@ async function handleBootstrapCommand(
controlConfig.set("control.controller_url", Controller.calculateControllerUrl(controllerConfig));
controlConfig.set(
"control.controller_token",
userManager.signUserToken(admin.name),
userManager.signUserToken(admin),
);
let content = JSON.stringify(controlConfig, null, "\t");
@@ -248,8 +248,12 @@ async function initialize(): Promise<InitializeParameters> {
.command("config", "Manage Controller config", lib.configCommand)
.command("bootstrap", "Bootstrap access to cluster", yargs => {
yargs
.command("create-admin <name>", "Create a cluster admin")
.command("generate-user-token <name>", "Generate authentication token for the given user")
.command("create-admin <name>", "Create a cluster admin", yargs => {
yargs.positional("name", { describe: "Name of the admin user", type: "string" });
})
.command("generate-user-token <name>", "Generate authentication token for the given user", yargs => {
yargs.positional("name", { describe: "Name of the user", type: "string" });
})
.command("generate-host-token <id>", "Generate authentication token for the given host", yargs => {
yargs.positional("id", { describe: "ID of the host", type: "number" });
})
@@ -313,14 +317,7 @@ async function initialize(): Promise<InitializeParameters> {
}
logger.info(`Loading available plugins from ${args.pluginList}`);
let pluginList = new Map();
try {
pluginList = new Map(JSON.parse(await fs.readFile(args.pluginList, { encoding: "utf8" })));
} catch (err: any) {
if (err.code !== "ENOENT") {
throw err;
}
}
let pluginList = await lib.loadPluginList(args.pluginList);
// If the command is plugin management we don't try to load plugins
if (command === "plugin") {
+12 -8
View File
@@ -3,42 +3,45 @@
"description": "Implementation of Clusterio controller",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.16",
"main": "dist/index.js",
"version": "2.0.0-alpha.19",
"main": "dist/node/index.js",
"scripts": {
"prepare": "tsc --build && webpack-cli --env production",
"test": "echo \"Error: no test specified\" && exit 1"
},
"bin": {
"clusteriocontroller": "./dist/controller.js"
"clusteriocontroller": "./dist/node/controller.js"
},
"keywords": [
"clusterio",
"factorio"
],
"engines": {
"node": ">=18"
},
"dependencies": {
"@clusterio/lib": "^2.0.0-alpha.16",
"@clusterio/lib": "workspace:*",
"@sinclair/typebox": "^0.30.4",
"busboy": "^1.6.0",
"compression": "^1.7.4",
"express": "^4.18.2",
"finalhandler": "^1.2.0",
"fs-extra": "^11.1.1",
"jsonwebtoken": "^9.0.1",
"jszip": "^3.10.1",
"set-blocking": "^2.0.0",
"webpack-cli": "^5.1.4",
"winston": "^3.10.0",
"winston": "^3.17.0",
"winston-daily-rotate-file": "^4.7.1",
"ws": "^8.13.0",
"yargs": "^17.7.2"
},
"devDependencies": {
"@clusterio/web_ui": "^2.0.0-alpha.16",
"@clusterio/web_ui": "workspace:*",
"@swc/core": "^1.4.0",
"@types/busboy": "^1.5.0",
"@types/compression": "^1.7.2",
"@types/express": "^4.17.17",
"@types/finalhandler": "^1.2.0",
"@types/fs-extra": "^11.0.1",
"@types/jsonwebtoken": "^9.0.2",
"@types/node": "^20.4.5",
@@ -48,8 +51,9 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.4.14",
"typescript": "^5.1.6",
"typescript": "^5.5.3",
"webpack": "^5.88.2",
"webpack-cli": "^5.1.4",
"webpack-dev-middleware": "^6.1.1",
"webpack-merge": "^5.9.0"
},
+2 -2
View File
@@ -35,13 +35,13 @@ export default class BaseConnection extends lib.Link {
this.handle(lib.SubscriptionRequest, this.handleSubscriptionRequest.bind(this));
this.connector.on("close", () => {
this._controller.subscriptions.unsubscribe(this);
this._controller.subscriptions.unsubscribeLink(this);
});
}
async disconnect(code: number, reason: string) {
try {
await this.connector.disconnect();
await this.connector.disconnect(code, reason);
} catch (err: any) {
if (!(err instanceof lib.SessionLost)) {
logger.error(`"Unexpected error preparing disconnect:\n${err.stack}`);
+16 -20
View File
@@ -134,7 +134,7 @@ export default class ControlConnection extends BaseConnection {
this.checkPermission(message, entry);
} catch (err: any) {
this.connector.sendResponseError(new lib.ResponseError(err.message, err.code), message.src);
logger.audit(`Permission denied for ${message.name} by ${this.user.name} from ${this.connector.dst}`);
logger.audit(`Permission denied for ${message.name} by ${this.user.id} from ${this.connector.dst}`);
throw err;
}
}
@@ -195,7 +195,7 @@ export default class ControlConnection extends BaseConnection {
}
async handleHostRevokeTokensRequest(request: lib.HostRevokeTokensRequest) {
const host = this._controller.hosts.get(request.hostId);
const host = this._controller.hosts.getMutable(request.hostId);
if (!host) {
throw new Error(`Unknown host id (${request.hostId})`);
}
@@ -207,7 +207,7 @@ export default class ControlConnection extends BaseConnection {
hostConnection.connector.terminate();
}
this._controller.hostsUpdated([host]);
this._controller.hosts.set(host);
}
async handleHostListRequest(): Promise<lib.HostDetails[]> {
@@ -397,8 +397,7 @@ export default class ControlConnection extends BaseConnection {
if (this._controller.modPacks.has(modPack.id)) {
throw new lib.RequestError(`Mod pack with ID ${modPack.id} already exist`);
}
this._controller.modPacks.set(modPack.id, modPack);
this._controller.modPacksUpdated([modPack]);
this._controller.modPacks.set(modPack);
}
async handleModPackUpdateRequest(request: lib.ModPackUpdateRequest) {
@@ -406,19 +405,16 @@ export default class ControlConnection extends BaseConnection {
if (modPack.id === undefined || !this._controller.modPacks.has(modPack.id)) {
throw new lib.RequestError(`Mod pack with ID ${modPack.id} does not exist`);
}
this._controller.modPacks.set(modPack.id, modPack);
this._controller.modPacksUpdated([modPack]);
this._controller.modPacks.set(modPack);
}
async handleModPackDeleteRequest(request: lib.ModPackDeleteRequest) {
let { id } = request;
let modPack = this._controller.modPacks.get(id);
let modPack = this._controller.modPacks.getMutable(id);
if (!modPack) {
throw new lib.RequestError(`Mod pack with ID ${id} does not exist`);
}
modPack.isDeleted = true;
this._controller.modPacks.delete(id);
this._controller.modPacksUpdated([modPack]);
this._controller.modPacks.delete(modPack);
}
async handleModGetRequest(request: lib.ModGetRequest) {
@@ -630,7 +626,7 @@ export default class ControlConnection extends BaseConnection {
async handleUserGetRequest(request: lib.UserGetRequest): Promise<lib.User> {
let name = request.name;
let user = this._controller.userManager.users.get(name);
let user = this._controller.userManager.getByName(name);
if (!user) {
throw new lib.RequestError(`User ${name} does not exist`);
}
@@ -648,17 +644,17 @@ export default class ControlConnection extends BaseConnection {
}
async handleUserRevokeTokenRequest(request: lib.UserRevokeTokenRequest) {
let user = this._controller.userManager.users.get(request.name);
let user = this._controller.userManager.getByName(request.name);
if (!user) {
throw new lib.RequestError(`User '${request.name}' does not exist`);
}
if (user.name !== this.user.name) {
if (user.id !== this.user.id) {
this.user.checkPermission("core.user.revoke_other_token");
}
user.invalidateToken();
for (let controlConnection of this._controller.wsServer.controlConnections.values()) {
if (controlConnection.user.name === user.name) {
if (controlConnection.user.id === user.id) {
controlConnection.connector.terminate();
}
}
@@ -666,7 +662,7 @@ export default class ControlConnection extends BaseConnection {
}
async handleUserUpdateRolesRequest(request: lib.UserUpdateRolesRequest) {
let user = this._controller.userManager.users.get(request.name);
let user = this._controller.userManager.getByName(request.name);
if (!user) {
throw new lib.RequestError(`User '${request.name}' does not exist`);
}
@@ -684,7 +680,7 @@ export default class ControlConnection extends BaseConnection {
async handleUserSetAdminRequest(request: lib.UserSetAdminRequest) {
let { name, create, admin } = request;
let user = this._controller.userManager.users.get(name);
let user = this._controller.userManager.getByName(name);
if (!user) {
if (create) {
this.user.checkPermission("core.user.create");
@@ -701,7 +697,7 @@ export default class ControlConnection extends BaseConnection {
async handleUserSetBannedRequest(request: lib.UserSetBannedRequest) {
let { name, create, banned, reason } = request;
let user = this._controller.userManager.users.get(name);
let user = this._controller.userManager.getByName(name);
if (!user) {
if (create) {
this.user.checkPermission("core.user.create");
@@ -719,7 +715,7 @@ export default class ControlConnection extends BaseConnection {
async handleUserSetWhitelistedRequest(request: lib.UserSetWhitelistedRequest) {
let { name, create, whitelisted } = request;
let user = this._controller.userManager.users.get(name);
let user = this._controller.userManager.getByName(name);
if (!user) {
if (create) {
this.user.checkPermission("core.user.create");
@@ -736,7 +732,7 @@ export default class ControlConnection extends BaseConnection {
async handleUserDeleteRequest(request: lib.UserDeleteRequest) {
let name = request.name;
let user = this._controller.userManager.users.get(name);
let user = this._controller.userManager.getByName(name);
if (!user) {
throw new lib.RequestError(`User '${name}' does not exist`);
}
+155 -222
View File
@@ -3,6 +3,7 @@ import { BlockList, type AddressInfo, isIPv4, isIPv6 } from "net";
import type { ControllerArgs } from "../controller";
import express, { type Request, type Response, type NextFunction, type Application } from "express";
import type { Static } from "@sinclair/typebox";
import finalhandler from "finalhandler";
import compression from "compression";
import events, { EventEmitter } from "events";
@@ -54,17 +55,6 @@ export default class Controller {
config: lib.ControllerConfig;
app: Application;
/** True if (@link Controller.systems} has changed since last time it was saved */
systemsDirty = false;
/** True if {@link Controller.hosts} has changed since last time it was saved */
hostsDirty = false;
/** True if {@link Controller.instances} has changed since last time it was saved */
instancesDirty = false;
/** True if {@link Controller.saves} has changed since last time it was saved */
savesDirty = false;
/** True if {@link Controller.modPacks} has changed since last time it was saved */
modPacksDirty = false;
httpServer: http.Server | null = null;
httpServerCloser: HttpCloser | null = null;
httpsServer: https.Server | null = null;
@@ -94,7 +84,7 @@ export default class Controller {
public shouldRestart: boolean = false;
_fallbackedRequests: Map<lib.RequestClass<unknown, unknown>, lib.RequestHandler<unknown, unknown>> = new Map();
_registeredRequests: Map<lib.RequestClass<unknown, unknown>, lib.RequestHandler<unknown, unknown>> = new Map();
_registeredEvents = new Map();
_registeredEvents = new Map<lib.EventClass<unknown>, lib.EventHandler<unknown>>();
_snoopedEvents = new Map();
devMiddleware: any | null = null;
@@ -110,11 +100,33 @@ export default class Controller {
let databaseDirectory = config.get("controller.database_directory");
await fs.ensureDir(databaseDirectory);
const systems = await Controller.loadSystems(path.join(databaseDirectory, "systems.json"));
const hosts = await Controller.loadHosts(path.join(databaseDirectory, "hosts.json"));
const instances = await Controller.loadInstances(path.join(databaseDirectory, "instances.json"));
const saves = await Controller.loadSaves(path.join(databaseDirectory, "saves.json"));
const modPacks = await Controller.loadModPacks(path.join(databaseDirectory, "mod-packs.json"));
const systems = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
path.join(databaseDirectory, "systems.json"),
lib.SystemInfo.fromJSON.bind(lib.SystemInfo)
).bootstrap());
const hosts = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
path.join(databaseDirectory, "hosts.json"),
HostInfo.fromJSON.bind(HostInfo),
this.migrateHosts, this.finaliseHosts,
).bootstrap());
const instances = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
path.join(databaseDirectory, "instances.json"),
json => InstanceInfo.fromJSON(json, "controller"),
this.migrateInstances, this.finaliseInstances,
).bootstrap());
const saves = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
path.join(databaseDirectory, "saves.json"),
lib.SaveDetails.fromJSON.bind(lib.SaveDetails)
).bootstrap());
const modPacks = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
path.join(databaseDirectory, "mod-packs.json"),
lib.ModPack.fromJSON.bind(lib.ModPack)
).bootstrap());
const userManager = new UserManager(config);
await userManager.load(path.join(databaseDirectory, "users.json"));
@@ -144,15 +156,15 @@ export default class Controller {
* will restart the controller on non-zero exit codes.
*/
public canRestart: boolean = false,
public systems = new Map<lib.SystemInfo["id"], lib.SystemInfo>(),
public systems = new lib.SubscribableDatastore<lib.SystemInfo>(),
/** Mapping of host id to host info */
public hosts = new Map<number, HostInfo>(),
public hosts = new lib.SubscribableDatastore<HostInfo>(),
/** Mapping of instance id to instance info */
public instances = new Map<number, InstanceInfo>(),
public instances = new lib.SubscribableDatastore<InstanceInfo>(),
/** Mapping of save id to save details */
public saves = new Map<string, lib.SaveDetails>(),
public saves = new lib.SubscribableDatastore<lib.SaveDetails>(),
/** Mapping of mod pack id to mod pack */
public modPacks = new Map<number, lib.ModPack>(),
public modPacks = new lib.SubscribableDatastore<lib.ModPack>(),
/** Mods stored on the controller */
public modStore = new lib.ModStore(config.get("controller.mods_directory"), new Map()),
/** User and roles manager for the cluster */
@@ -184,6 +196,13 @@ export default class Controller {
this.subscriptions.handle(lib.ModPackUpdatesEvent, this.handleModPackSubscription.bind(this));
this.subscriptions.handle(lib.ModUpdatesEvent, this.handleModSubscription.bind(this));
this.subscriptions.handle(lib.UserUpdatesEvent, this.handleUserSubscription.bind(this));
// Handle updates for datastores
this.systems.on("update", this.systemsUpdated.bind(this));
this.hosts.on("update", this.hostsUpdated.bind(this));
this.instances.on("update", this.instanceDetailsUpdated.bind(this));
this.saves.on("update", this.savesUpdated.bind(this));
this.modPacks.on("update", this.modPacksUpdated.bind(this));
}
async start(args: ControllerArgs) {
@@ -274,7 +293,7 @@ export default class Controller {
Controller.addAppRoutes(this.app, this.pluginInfos);
if (!args.dev) {
let manifestPath = path.join(__dirname, "..", "web", "manifest.json");
let manifestPath = path.join(__dirname, "..", "..", "web", "manifest.json");
let manifest = await Controller.loadJsonObject(manifestPath);
if (!manifest["main.js"]) {
@@ -286,6 +305,26 @@ export default class Controller {
// Load plugins
await this.loadPlugins();
// Log all express errors from middleware or routes (app.use is missing the type overload)
this.app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction): void => {
logger.log({
level: "error",
message: "Error while handling HTTP" +
`${req.httpVersion} ${req.method} ${req.originalUrl}:\n${err.stack}`,
meta: {
method: req.method,
url: req.originalUrl,
httpVersion: req.httpVersion,
headers: req.headers,
query: req.query,
},
});
// Create and call the final handler, this is the same method used by express
// This is used instead of next(err) to stop express from logging the error to the console
finalhandler(req, res, { env: this.app.get("env") })(err);
});
this.wsServer = new WsServer(this);
// Only start listening for connections after all plugins have loaded
@@ -333,7 +372,7 @@ export default class Controller {
if (args.dev) {
// eslint-disable-next-line node/no-missing-require
webpackConfigs.push(require("../../webpack.config")({})); // Path outside of build
webpackConfigs.push(require("../../../webpack.config")({})); // Path outside of build
}
if (args.devPlugin) {
let devPlugins = new Map();
@@ -480,10 +519,8 @@ export default class Controller {
requests.push(lib.gatherSystemInfo("controller", this.canRestart));
const newMetrics = await Promise.all(requests);
for (const metric of newMetrics) {
this.systems.set(metric.id, metric);
this.systems.set(metric);
}
this.systemsDirty = true;
this.subscriptions.broadcast(new lib.SystemInfoUpdateEvent(newMetrics));
} catch (err: any) {
logger.error(`Unexpected error updating system infos:\n${err.stack ?? err.message}`);
}
@@ -524,160 +561,57 @@ export default class Controller {
await lib.safeOutputFile(this.configPath, JSON.stringify(this.config, null, "\t"));
}
let databaseDirectory = this.config.get("controller.database_directory");
if (this.systemsDirty) {
this.systemsDirty = false;
await Controller.saveSystems(path.join(databaseDirectory, "systems.json"), this.systems);
}
if (this.hostsDirty) {
this.hostsDirty = false;
await Controller.saveHosts(path.join(databaseDirectory, "hosts.json"), this.hosts);
}
if (this.instancesDirty) {
this.instancesDirty = false;
await Controller.saveInstances(path.join(databaseDirectory, "instances.json"), this.instances);
}
if (this.savesDirty) {
this.savesDirty = false;
await Controller.saveSaves(path.join(databaseDirectory, "saves.json"), this.saves);
}
if (this.modPacksDirty) {
this.modPacksDirty = false;
await Controller.saveModPacks(path.join(databaseDirectory, "mod-packs.json"), this.modPacks);
}
await Promise.all([
this.systems.save(),
this.hosts.save(),
this.instances.save(),
this.saves.save(),
this.modPacks.save(),
]);
if (this.userManager.dirty) {
await this.userManager.save(path.join(databaseDirectory, "users.json"));
await this.userManager.save(path.join(this.config.get("controller.database_directory"), "users.json"));
}
await lib.invokeHook(this.plugins, "onSaveData");
}
static async loadSystems(filePath: string): Promise<Map<lib.SystemInfo["id"], lib.SystemInfo>> {
let json: Static<typeof lib.SystemInfo.jsonSchema>[];
try {
json = JSON.parse(await fs.readFile(filePath, { encoding: "utf8" }));
static migrateHosts(rawJson: unknown[]): Static<typeof HostInfo.jsonSchema>[] {
let serialized = rawJson as any;
} catch (err: any) {
if (err.code !== "ENOENT") {
throw err;
}
return new Map();
// migrate from pre alpha.19 format
if (serialized.length && serialized[0] instanceof Array) {
serialized = serialized.map((e: any) => e[1]);
}
return new Map(json.map(m => lib.SystemInfo.fromJSON(m)).map(m => [m.id, m]));
return serialized;
}
static async saveSystems(filePath: string, systems: Map<lib.SystemInfo["id"], lib.SystemInfo>) {
await lib.safeOutputFile(filePath, JSON.stringify([...systems.values()], null, "\t"));
}
static async loadHosts(filePath: string): Promise<Map<number, HostInfo>> {
let serialized: [number, Static<typeof HostInfo.jsonSchema>][];
try {
serialized = JSON.parse(await fs.readFile(filePath, { encoding: "utf8" }));
} catch (err: any) {
if (err.code !== "ENOENT") {
throw err;
}
return new Map();
static finaliseHosts(host: HostInfo): HostInfo {
if (host.connected) {
host.connected = false;
host.updatedAtMs = Date.now();
}
return host;
}
// TODO: migrate remove after release.
if (serialized.length && !(serialized[0] instanceof Array)) {
return new Map(); // Discard old format.
}
const hosts = new Map();
for (let [id, json] of serialized) {
const host = HostInfo.fromJSON(json);
if (host.connected) {
host.connected = false;
host.updatedAtMs = Date.now();
static migrateInstances(rawJson: unknown[]): Static<typeof InstanceInfo.jsonSchema>[] {
const serialized = rawJson as Static<typeof InstanceInfo.jsonSchema>[];
return serialized.map(json => {
if (!json.config) { // migrate: from pre Alpha 14 format.
return { config: json as any, status: "running" }; // Use running to force updatedAtMs
}
hosts.set(id, host);
return json;
});
}
static finaliseInstances(instance: InstanceInfo): InstanceInfo {
const status = instance.config.get("instance.assigned_host") === null ? "unassigned" : "unknown";
if (instance.status !== status) {
instance.status = status;
instance.updatedAtMs = Date.now();
}
return hosts;
}
static async saveHosts(filePath: string, hosts: Map<number, HostInfo>) {
await lib.safeOutputFile(filePath, JSON.stringify([...hosts.entries()], null, "\t"));
}
static async loadInstances(filePath: string): Promise<Map<number, InstanceInfo>> {
logger.info(`Loading ${filePath}`);
let instances = new Map();
try {
let serialized = JSON.parse(
await fs.readFile(filePath, { encoding: "utf8" })
) as Static<typeof InstanceInfo.jsonSchema>[];
for (let json of serialized) {
if (!json.config) { // migrate: from pre Alpha 14 format.
json = { config: json as any, status: "running" }; // Use running to force updatedAtMs
}
const instance = InstanceInfo.fromJSON(json, "controller");
const status = instance.config.get("instance.assigned_host") === null ? "unassigned" : "unknown";
if (instance.status !== status) {
instance.status = status;
instance.updatedAtMs = Date.now();
}
instances.set(instance.id, instance);
}
} catch (err: any) {
if (err.code !== "ENOENT") {
throw err;
}
return new Map();
}
return instances;
}
static async saveInstances(filePath: string, instances: Map<number, InstanceInfo>) {
let serialized = [...instances.values()];
await lib.safeOutputFile(filePath, JSON.stringify(serialized, null, "\t"));
}
static async loadSaves(filePath: string): Promise<Map<string, lib.SaveDetails>> {
let json: Static<typeof lib.SaveDetails.jsonSchema>[];
try {
json = JSON.parse(await fs.readFile(filePath, { encoding: "utf8" }));
} catch (err: any) {
if (err.code !== "ENOENT") {
throw err;
}
return new Map();
}
return new Map(json.map(s => lib.SaveDetails.fromJSON(s)).map(s => [s.id, s]));
}
static async saveSaves(filePath: string, saves: Map<string, lib.SaveDetails>) {
await lib.safeOutputFile(filePath, JSON.stringify([...saves.values()], null, "\t"));
}
static async loadModPacks(filePath: string): Promise<Map<number, lib.ModPack>> {
let json;
try {
json = JSON.parse(await fs.readFile(filePath, { encoding: "utf8" }));
} catch (err: any) {
if (err.code !== "ENOENT") {
throw err;
}
return new Map();
}
return new Map(json.map((e: any) => [e.id, lib.ModPack.fromJSON(e)]));
}
static async saveModPacks(filePath: string, modPacks: Map<number, lib.ModPack>) {
await lib.safeOutputFile(filePath, JSON.stringify([...modPacks.values()], null, "\t"));
return instance;
}
static async loadJsonObject(filePath: string, throwOnMissing: boolean = false): Promise<any> {
@@ -721,14 +655,29 @@ export default class Controller {
static addAppRoutes(app: Application, pluginInfos: any[]) {
app.use((req: Request, res: Response, next) => {
let startNs = process.hrtime.bigint();
const startNs = process.hrtime.bigint();
stream.finished(res, () => {
let routePath = "static";
if (req.route && req.route.path) {
routePath = req.route.path;
}
let endNs = process.hrtime.bigint();
endpointDurationSummary.labels(routePath).observe(Number(endNs - startNs) / 1e9);
const endNs = process.hrtime.bigint();
const durationMs = (Number(endNs - startNs) / 1e6);
endpointDurationSummary.labels(routePath).observe(durationMs / 1e3);
logger.log({
level: "http",
message: `HTTP${req.httpVersion} ${req.method} ${req.originalUrl}` +
` (Status ${res.statusCode} in ${durationMs}ms)`,
meta: {
method: req.method,
url: req.originalUrl,
statusCode: res.statusCode,
responseTime: durationMs,
httpVersion: req.httpVersion,
headers: req.headers,
query: req.query,
},
});
});
next();
});
@@ -737,7 +686,7 @@ export default class Controller {
// Set folder to serve static content from (the website)
const staticOptions = { immutable: true, maxAge: 1000 * 86400 * 365 };
app.use("/static",
express.static(path.join(__dirname, "..", "..", "dist", "web", "static"), staticOptions)
express.static(path.join(__dirname, "..", "..", "web", "static"), staticOptions)
);
app.use("/static", express.static("static", staticOptions)); // Used for data export files
@@ -759,6 +708,10 @@ export default class Controller {
}
}
systemsUpdated(systems: lib.SystemInfo[]) {
this.subscriptions.broadcast(new lib.SystemInfoUpdateEvent(systems));
}
async handleSystemInfoSubscription(request: lib.SubscriptionRequest) {
const systems = [...this.systems.values()].filter(
metric => metric.updatedAtMs > request.lastRequestTimeMs,
@@ -780,11 +733,6 @@ export default class Controller {
}
hostsUpdated(hosts: HostInfo[]) {
const now = Date.now();
for (const host of hosts) {
host.updatedAtMs = now;
}
this.hostsDirty = true;
let updates = hosts.map(host => host.toHostDetails());
this.subscriptions.broadcast(new lib.HostUpdatesEvent(updates));
}
@@ -840,8 +788,6 @@ export default class Controller {
"tags": ["clusterio"],
"max_players": 0,
"visibility": { "public": true, "lan": true },
"username": "",
"token": "",
"game_password": "",
"require_user_verification": true,
"max_upload_in_kilobytes_per_second": 0,
@@ -860,12 +806,10 @@ export default class Controller {
};
instanceConfig.set("factorio.settings", settings);
let instance = new InstanceInfo(instanceConfig, "unassigned", undefined, Date.now());
this.instances.set(instanceId, instance);
this.instancesDirty = true;
let instance = new InstanceInfo(instanceConfig, "unassigned", undefined, undefined, Date.now());
this.instances.set(instance);
await lib.invokeHook(this.plugins, "onInstanceStatusChanged", instance);
this.addInstanceHooks(instance);
this.instanceDetailsUpdated([instance]);
return instance;
}
@@ -875,17 +819,7 @@ export default class Controller {
* @internal
*/
clearSavesOfInstance(instanceId: number) {
const updates = [];
for (const [id, save] of this.saves) {
if (save.instanceId === instanceId) {
save.isDeleted = true;
updates.push(save);
this.saves.delete(id);
}
}
if (updates.length) {
this.savesUpdated(updates);
}
this.saves.deleteMany([...this.saves.values()].filter(save => save.instanceId === instanceId));
}
/**
@@ -932,16 +866,14 @@ export default class Controller {
// Assign to target
instance.config.set("instance.assigned_host", hostId ?? null);
// "fieldChanged" event handler will set this.instancesDirty
if (hostId !== undefined && newHostConnection) {
await newHostConnection.send(
new lib.InstanceAssignInternalRequest(instanceId, instance.config.toRemote("host"))
);
} else {
instance.status = "unassigned";
instance.updatedAtMs = Date.now();
this.instanceDetailsUpdated([instance]);
}
this.instances.set(instance);
}
/**
@@ -958,15 +890,13 @@ export default class Controller {
if (hostId !== null) {
await this.sendTo({ hostId }, new lib.InstanceDeleteInternalRequest(instanceId));
}
this.instances.delete(instanceId);
this.instancesDirty = true;
this.clearSavesOfInstance(instanceId);
let prev = instance.status;
instance.status = "deleted";
instance.updatedAtMs = Date.now();
this.instanceDetailsUpdated([instance]);
this.instances.delete(instance);
this.clearSavesOfInstance(instanceId);
this.userManager.clearStatsOfInstance(instanceId);
await lib.invokeHook(this.plugins, "onInstanceStatusChanged", instance, prev);
}
@@ -994,16 +924,18 @@ export default class Controller {
addInstanceHooks(instance: InstanceInfo) {
instance.config.on("fieldChanged", (field: string, curr: any, prev: any) => {
instance.updatedAtMs = Date.now();
if (field === "instance.name") {
this.instanceDetailsUpdated([instance]);
}
this.instances.set(instance); // Trigger update logic
this.instancesDirty = true;
lib.invokeHook(this.plugins, "onInstanceConfigFieldChanged", instance, field, curr, prev);
});
}
instanceDetailsUpdated(instances: InstanceInfo[]) {
for (const instance of instances) {
if (instance.status === "stopped") {
this.subscriptions.unsubscribeAddress(lib.Address.fromShorthand({instanceId: instance.id}));
}
}
const updates = instances.map(instance => instance.toInstanceDetails());
this.subscriptions.broadcast(new lib.InstanceDetailsUpdatesEvent(updates));
}
@@ -1016,7 +948,6 @@ export default class Controller {
}
savesUpdated(saves: lib.SaveDetails[]) {
this.savesDirty = true;
this.subscriptions.broadcast(new lib.InstanceSaveDetailsUpdatesEvent(saves));
}
@@ -1027,11 +958,6 @@ export default class Controller {
modPacksUpdated(modPacks: lib.ModPack[]) {
const now = Date.now();
for (const modPack of modPacks) {
modPack.updatedAtMs = now;
}
this.modPacksDirty = true;
this.subscriptions.broadcast(new lib.ModPackUpdatesEvent(modPacks));
lib.invokeHook(this.plugins, "onModPacksUpdated", modPacks);
}
@@ -1189,7 +1115,7 @@ export default class Controller {
}
static calculateControllerUrl(config: lib.ControllerConfig) {
let url = config.get("controller.external_address");
let url = config.get("controller.public_url");
if (!url) {
if (config.get("controller.https_port")) {
url = `https://localhost:${config.get("controller.https_port")}/`;
@@ -1224,7 +1150,7 @@ export default class Controller {
mainBundle = stats.toJson().assetsByChunkName["main"];
}
fs.readFile(path.join(__dirname, "..", "..", "web", "index.html"), "utf8").then((content) => {
fs.readFile(path.join(__dirname, "..", "..", "..", "web", "index.html"), "utf8").then((content) => {
res.type("text/html");
res.send(content
.replace(/__CLUSTER_NAME__/g, res.app.locals.controller.config.get("controller.name"))
@@ -1312,10 +1238,10 @@ export default class Controller {
throw Error(`Unknown type ${requestOrEvent.constructor.type}.`);
}
async sendRequest(request: any, dst: lib.Address) {
sendRequest(request: any, dst: lib.Address) {
let connection;
if (dst.type === lib.Address.controller) {
throw new Error("controller as dst is not supported.");
throw new Error(`Message would return back to sender ${dst}.`);
} else if (dst.type === lib.Address.control) {
connection = this.wsServer.controlConnections.get(dst.id);
@@ -1350,24 +1276,33 @@ export default class Controller {
sendEvent<T>(event: lib.Event<T>, dst: lib.Address) {
let connection;
if (dst.type === lib.Address.controller) {
throw new Error("controller as dst is not supported.");
throw new Error(`Message would return back to sender ${dst}.`);
} else if (dst.type === lib.Address.control) {
connection = this.wsServer.controlConnections.get(dst.id);
if (!connection) {
throw new Error("Target control connection does not exist.");
}
} else if (dst.type === lib.Address.instance) {
let instance = this.instances.get(dst.id);
if (!instance) {
return;
throw new Error(`Instance with ID ${dst.id} does not exist`);
}
let hostId = instance.config.get("instance.assigned_host");
if (hostId === null) {
return;
throw new Error("Instance is not assigned to a host");
}
connection = this.wsServer.hostConnections.get(hostId);
if (!connection) {
throw new Error("Host containing instance is not connected");
}
} else if (dst.type === lib.Address.host) {
connection = this.wsServer.hostConnections.get(dst.id);
if (!connection) {
throw new Error("Host is not connected");
}
} else if (dst.type === lib.Address.broadcast) {
if (dst.id === lib.Address.control) {
@@ -1385,7 +1320,7 @@ export default class Controller {
}
} else {
throw new Error(`Unexpected broacdast target ${dst.id}`);
throw new Error(`Unexpected broadcast target ${dst.id}`);
}
return;
@@ -1393,9 +1328,7 @@ export default class Controller {
throw new Error(`Unknown address type ${dst.type}`);
}
if (connection) {
connection.sendEvent(event, dst);
}
connection.sendEvent(event, dst);
}
async sendToHostByInstanceId<Req extends InstanceId, Res>(request: Req & lib.Request<Req, Res>): Promise<Res>;
+114 -38
View File
@@ -42,8 +42,7 @@ export default class HostConnection extends BaseConnection {
0,
false,
);
this._controller.hosts.set(this.id, this.info);
this._controller.hostsUpdated([this.info]);
this._controller.hosts.set(this.info);
this._checkPluginVersions();
@@ -57,10 +56,9 @@ export default class HostConnection extends BaseConnection {
}
this.connector.on("close", () => {
const now = Date.now();
// Update status to unknown for instances on this host.
const instances: InstanceInfo[] = [];
for (let instance of this._controller.instances.values()) {
for (let instance of this._controller.instances.valuesMutable()) {
if (instance.config.get("instance.assigned_host") !== this.id) {
continue;
}
@@ -68,21 +66,24 @@ export default class HostConnection extends BaseConnection {
instances.push(instance);
let prev = instance.status;
instance.status = "unknown";
instance.updatedAtMs = now;
lib.invokeHook(this._controller.plugins, "onInstanceStatusChanged", instance, prev);
}
this._controller.instanceDetailsUpdated(instances);
this._controller.instances.setMany(instances);
this.info.connected = false;
this._controller.hostsUpdated([this.info]);
this._controller.hosts.set(this.info);
});
this.handle(lib.GetFactorioCredentialsRequest, this.handleGetFactorioCredentialsRequest.bind(this));
this.handle(lib.HostInfoUpdateEvent, this.handleHostInfoUpdateEvent.bind(this));
this.handle(lib.InstanceStatusChangedEvent, this.handleInstanceStatusChangedEvent.bind(this));
this.handle(lib.InstancesUpdateRequest, this.handleInstancesUpdateRequest.bind(this));
this.handle(lib.InstanceSaveDetailsUpdatesEvent, this.handleInstanceSaveDetailsUpdatesEvent.bind(this));
this.handle(lib.LogMessageEvent, this.handleLogMessageEvent.bind(this));
this.handle(lib.InstancePlayerUpdateEvent, this.handleInstancePlayerUpdateEvent.bind(this));
this.snoopEvent(lib.InstanceAdminlistUpdateEvent, this.snoopInstanceAdminlistUpdateEvent.bind(this));
this.snoopEvent(lib.InstanceBanlistUpdateEvent, this.snoopInstanceBanlistUpdateEvent.bind(this));
this.snoopEvent(lib.InstanceWhitelistUpdateEvent, this.snoopInstanceWhitelistUpdateEvent.bind(this));
}
validateIngress(message: lib.MessageRoutable) {
@@ -155,14 +156,33 @@ export default class HostConnection extends BaseConnection {
return this.info.id;
}
/**
* Remote address of the host this connection is connected to
*
* @returns {string} host remote address.
*/
get remoteAddress() {
return this.info.remoteAddress;
}
async handleGetFactorioCredentialsRequest() {
if (this._controller.config.get("controller.share_factorio_credentials_with_hosts")) {
return {
username: this._controller.config.get("controller.factorio_username") ?? undefined,
token: this._controller.config.get("controller.factorio_token") ?? undefined,
};
}
return {};
}
async handleHostInfoUpdateEvent(event: lib.HostInfoUpdateEvent) {
this.info.name = event.update.name;
this.info.publicAddress = event.update.publicAddress;
this._controller.hostsUpdated([this.info]);
this._controller.hosts.set(this.info);
}
async handleInstanceStatusChangedEvent(request: lib.InstanceStatusChangedEvent) {
let instance = this._controller.instances.get(request.instanceId);
let instance = this._controller.instances.getMutable(request.instanceId);
// It's possible to get updates from an instance that does not exist
// or is not assigned to the host it originated from if it was
@@ -178,16 +198,19 @@ export default class HostConnection extends BaseConnection {
// changed. Hosts also send status updates on assignInstance which
// for hacky reason is also used to push config changes and
// restablish status after a connection loss.
if (instance.status === request.status) {
if (
instance.status === request.status
&& instance.gamePort === request.gamePort
) {
return;
}
let prev = instance.status;
instance.status = request.status;
instance.gamePort = request.gamePort;
instance.updatedAtMs = Date.now();
instance.factorioVersion = request.factorioVersion;
this._controller.instances.set(instance);
logger.verbose(`Instance ${instance.config.get("instance.name")} State: ${instance.status}`);
this._controller.instanceDetailsUpdated([instance]);
await lib.invokeHook(this._controller.plugins, "onInstanceStatusChanged", instance, prev);
}
@@ -207,7 +230,7 @@ export default class HostConnection extends BaseConnection {
const instanceConfig = new lib.InstanceConfig("controller");
instanceConfig.update(instanceData.config, false, "host");
let controllerInstance = this._controller.instances.get(instanceConfig.get("instance.id"));
let controllerInstance = this._controller.instances.getMutable(instanceConfig.get("instance.id"));
if (controllerInstance) {
// Check if this instance is assigned somewhere else.
if (controllerInstance.config.get("instance.assigned_host") !== this.id) {
@@ -222,9 +245,9 @@ export default class HostConnection extends BaseConnection {
let prev = controllerInstance.status;
controllerInstance.status = instanceData.status;
controllerInstance.gamePort = instanceData.gamePort;
controllerInstance.updatedAtMs = Date.now();
logger.verbose(`Instance ${instanceConfig.get("instance.name")} State: ${instanceData.status}`);
controllerInstance.factorioVersion = instanceData.factorioVersion;
instanceUpdates.push(controllerInstance);
logger.verbose(`Instance ${instanceConfig.get("instance.name")} State: ${instanceData.status}`);
await lib.invokeHook(
this._controller.plugins, "onInstanceStatusChanged", controllerInstance, prev
);
@@ -237,23 +260,20 @@ export default class HostConnection extends BaseConnection {
instanceConfig,
instanceData.status,
instanceData.gamePort,
instanceData.factorioVersion,
Date.now(),
);
this._controller.instances.set(instanceConfig.get("instance.id"), newInstance);
this._controller.instancesDirty = true;
instanceUpdates.push(newInstance);
this._controller.addInstanceHooks(newInstance);
await this.send(
new lib.InstanceAssignInternalRequest(
instanceConfig.get("instance.id"), instanceConfig.toRemote("host")
)
);
instanceUpdates.push(newInstance);
await lib.invokeHook(this._controller.plugins, "onInstanceStatusChanged", newInstance);
}
if (instanceUpdates.length) {
this._controller.instanceDetailsUpdated(instanceUpdates);
}
this._controller.instances.setMany(instanceUpdates);
// Push lists to make sure they are in sync.
let adminlist: Set<string> = new Set();
@@ -262,13 +282,13 @@ export default class HostConnection extends BaseConnection {
for (let user of this._controller.userManager.users.values()) {
if (user.isAdmin) {
adminlist.add(user.name);
adminlist.add(user.id);
}
if (user.isBanned) {
banlist.set(user.name, user.banReason);
banlist.set(user.id, user.banReason);
}
if (user.isWhitelisted) {
whitelist.add(user.name);
whitelist.add(user.id);
}
}
@@ -276,33 +296,27 @@ export default class HostConnection extends BaseConnection {
}
async handleInstanceSaveDetailsUpdatesEvent(event: lib.InstanceSaveDetailsUpdatesEvent) {
const now = Date.now();
const updates: lib.SaveDetails[] = [];
const deletes: lib.SaveDetails[] = [];
for (const save of event.updates) {
const existingSave = this._controller.saves.get(save.id);
if (existingSave && save.equals(existingSave)) {
continue;
}
save.updatedAtMs = now;
this._controller.saves.set(save.id, save);
updates.push(save);
}
if (event.instanceId !== undefined) {
const updatedSaves = new Set(event.updates.map(s => s.id));
for (const [id, save] of this._controller.saves) {
if (save.instanceId === event.instanceId && !updatedSaves.has(id)) {
save.isDeleted = true;
save.updatedAtMs = now;
updates.push(save);
this._controller.saves.delete(id);
for (const save of this._controller.saves.values()) {
if (save.instanceId === event.instanceId && !updatedSaves.has(save.id)) {
deletes.push(save);
}
}
}
// Hosts eagerly send updates, which means we may get an update
// where nothing actualy changed.
if (updates.length) {
this._controller.savesUpdated(updates);
}
// where nothing actually changed.
this._controller.saves.setMany(updates);
this._controller.saves.deleteMany(deletes);
}
async handleLogMessageEvent(event: lib.LogMessageEvent) {
@@ -315,7 +329,7 @@ export default class HostConnection extends BaseConnection {
async handleInstancePlayerUpdateEvent(event: lib.InstancePlayerUpdateEvent, src: lib.Address) {
let instanceId = src.id;
let user = this._controller.userManager.users.get(event.name);
let user = this._controller.userManager.getByName(event.name);
if (!user) {
user = this._controller.userManager.createUser(event.name);
}
@@ -339,4 +353,66 @@ export default class HostConnection extends BaseConnection {
stats: event.stats,
});
}
async snoopInstanceAdminlistUpdateEvent(event: lib.InstanceAdminlistUpdateEvent, src: lib.Address) {
if (src.type !== lib.Address.instance) {
return;
}
let user = this._controller.userManager.getByName(event.name);
if (!user) {
user = this._controller.userManager.createUser(event.name);
}
user.isAdmin = event.admin;
this._controller.usersUpdated([user]);
const instance = this._controller.instances.get(src.id)!;
await lib.invokeHook(this._controller.plugins, "onPlayerEvent", instance, {
type: event.admin ? "promote" : "demote" as lib.PlayerEvent["type"],
name: event.name,
stats: user.instanceStats.get(src.id)!,
});
}
async snoopInstanceBanlistUpdateEvent(event: lib.InstanceBanlistUpdateEvent, src: lib.Address) {
if (src.type !== lib.Address.instance) {
return;
}
let user = this._controller.userManager.getByName(event.name);
if (!user) {
user = this._controller.userManager.createUser(event.name);
}
user.isBanned = event.banned;
user.banReason = event.reason;
this._controller.usersUpdated([user]);
const instance = this._controller.instances.get(src.id)!;
await lib.invokeHook(this._controller.plugins, "onPlayerEvent", instance, {
type: event.banned ? "ban" : "unban" as lib.PlayerEvent["type"],
name: event.name,
stats: user.instanceStats.get(src.id)!,
resaon: event.reason,
});
}
async snoopInstanceWhitelistUpdateEvent(event: lib.InstanceWhitelistUpdateEvent, src: lib.Address) {
if (src.type !== lib.Address.instance) {
return;
}
let user = this._controller.userManager.getByName(event.name);
if (!user) {
user = this._controller.userManager.createUser(event.name);
}
user.isWhitelisted = event.whitelisted;
this._controller.usersUpdated([user]);
const instance = this._controller.instances.get(src.id)!;
await lib.invokeHook(this._controller.plugins, "onPlayerEvent", instance, {
type: event.whitelisted ? "whitelisted" : "unwhitelisted" as lib.PlayerEvent["type"],
name: event.name,
stats: user.instanceStats.get(src.id)!,
});
}
}
+11
View File
@@ -11,6 +11,7 @@ export default class InstanceInfo {
public config: lib.InstanceConfig,
public status: lib.InstanceStatus,
public gamePort?: number,
public factorioVersion?: string,
public updatedAtMs = 0,
) {
this.config = config;
@@ -22,6 +23,7 @@ export default class InstanceInfo {
"status": lib.InstanceStatus,
"gamePort": Type.Optional(Type.Number()),
"updatedAtMs": Type.Optional(Type.Number()),
"factorioVersion": Type.Optional(Type.String()),
});
static fromJSON(json: Static<typeof this.jsonSchema>, location: lib.ConfigLocation) {
@@ -29,6 +31,7 @@ export default class InstanceInfo {
lib.InstanceConfig.fromJSON(json.config, location),
json.status,
json.gamePort,
json.factorioVersion,
json.updatedAtMs,
);
}
@@ -40,6 +43,7 @@ export default class InstanceInfo {
this.config.get("instance.assigned_host") ?? undefined,
this.gamePort,
this.status,
this.factorioVersion,
this.updatedAtMs,
);
}
@@ -48,6 +52,13 @@ export default class InstanceInfo {
return this.status === "deleted";
}
set isDeleted(value: boolean) {
if (value === false) {
throw new Error("Setting isDeleted to false is not supported, use status instead.");
}
this.status = "deleted";
}
/** Shorthand for `instance.config.get("instance.id")` */
get id():number {
return this.config.get("instance.id");
+44 -11
View File
@@ -10,8 +10,8 @@ import ControllerUser from "./ControllerUser";
* @alias module:controller/src/UserManager
*/
export default class UserManager {
roles: Map<number, lib.Role> = new Map();
users: Map<string, ControllerUser> = new Map();
roles: Map<lib.Role["id"], lib.Role> = new Map();
users: Map<ControllerUser["id"], ControllerUser> = new Map();
dirty = false;
/**
@@ -24,6 +24,10 @@ export default class UserManager {
) {
}
getByName(name: string) {
return this.users.get(name.toLowerCase());
}
async load(filePath: string): Promise<void> {
try {
let content = JSON.parse(await fs.readFile(filePath, { encoding: "utf8" }));
@@ -32,9 +36,29 @@ export default class UserManager {
this.roles.set(role.id, role);
}
let duplicates = 0;
for (let serializedUser of content.users) {
let user = ControllerUser.fromJSON(serializedUser, this);
this.users.set(user.name, user);
const existingUser = this.users.get(user.id);
if (existingUser) {
// Required migration to all lowercase ids in alpha 19
duplicates += 1;
// We assume the user with the lower online time is the duplicate
if (user.playerStats.onlineTimeMs <= existingUser.playerStats.onlineTimeMs) {
existingUser.merge(user);
continue; // Skip users.set
}
user.merge(existingUser);
}
this.users.set(user.id, user);
}
if (duplicates) {
const backupPath = `${filePath}.${Date.now()}.bak`;
lib.logger.warn(
`A total of ${duplicates} users were merged, a backup was written to: ${backupPath}`
);
await lib.safeOutputFile(backupPath, JSON.stringify(content, null, "\t"));
}
} catch (err: any) {
@@ -48,7 +72,7 @@ export default class UserManager {
}
}
async save(filePath:string): Promise<void> {
async save(filePath: string): Promise<void> {
let serializedRoles = [];
for (let role of this.roles.values()) {
serializedRoles.push(role.toJSON());
@@ -72,8 +96,8 @@ export default class UserManager {
* @param name - Name of the user to create.
* @returns The created user.
*/
createUser(name:string): ControllerUser {
if (this.users.has(name)) {
createUser(name: string): ControllerUser {
if (this.getByName(name)) {
throw new Error(`User '${name}' already exists`);
}
@@ -84,20 +108,20 @@ export default class UserManager {
}
let user = new ControllerUser(this, 0, name, roles);
this.users.set(name, user);
this.users.set(user.id, user);
this.dirty = true;
return user;
}
/**
* Sign access token for the given user name
* Sign access token for the given user
*
* @param name - user name to sign token for
* @param user - user to sign token for
* @returns JWT access token for the user.
*/
signUserToken(name: string): string {
signUserToken(user: ControllerUser): string {
return jwt.sign(
{ aud: "user", user: name },
{ aud: "user", user: user.id },
Buffer.from(this._config.get("controller.auth_secret"), "base64")
);
}
@@ -113,4 +137,13 @@ export default class UserManager {
this.onlineUsers.delete(user);
}
}
clearStatsOfInstance(instanceId: number) {
for (const user of this.users.values()) {
this.notifyLeave(user, instanceId);
user.instanceStats.delete(instanceId);
user.recalculatePlayerStats();
}
this.dirty = true;
}
}
+32 -15
View File
@@ -78,11 +78,11 @@ export default class WsServer {
let disconnectTasks = [];
for (let controlConnection of this.controlConnections.values()) {
disconnectTasks.push(controlConnection.disconnect(1001, "Server Quit"));
disconnectTasks.push(controlConnection.disconnect(lib.ConnectionClosed.GoingAway, "Server Quit"));
}
for (let hostConnection of this.hostConnections.values()) {
disconnectTasks.push(hostConnection.disconnect(1001, "Server Quit"));
disconnectTasks.push(hostConnection.disconnect(lib.ConnectionClosed.GoingAway, "Server Quit"));
}
logger.info(`WsServer | Waiting for ${disconnectTasks.length} connectors to close`);
@@ -97,7 +97,7 @@ export default class WsServer {
}
for (let socket of this.pendingSockets) {
socket.close(1001, "Server Quit");
socket.close(lib.ConnectionClosed.GoingAway, "Server Quit");
}
}
@@ -170,7 +170,7 @@ export default class WsServer {
${err.stack}`
);
wsRejectedConnectionsCounter.inc();
socket.close(1011, "Unexpected error");
socket.close(lib.ConnectionClosed.InternalError, "Unexpected error");
});
});
}
@@ -184,7 +184,7 @@ ${err.stack}`
} catch (err: any) {
logger.verbose(`WsServer | closing ${this.remoteAddr(req)} after receiving invalid message`);
wsRejectedConnectionsCounter.inc();
socket.close(1002, `Invalid message: ${err.message}`);
socket.close(lib.ConnectionClosed.ProtocolError, `Invalid message: ${err.message}`);
return;
}
@@ -196,14 +196,14 @@ ${err.stack}`
if (message.type !== "registerHost" && message.type !== "registerControl") {
logger.verbose(`WsServer | closing ${this.remoteAddr(req)} after receiving invalid handshake`);
wsRejectedConnectionsCounter.inc();
socket.close(1002, "Bad handshake");
socket.close(lib.ConnectionClosed.ProtocolError, "Bad handshake");
return;
}
if (this.stopAcceptingNewSessions) {
logger.verbose(`WsServer | closing ${this.remoteAddr(req)}, server is shutting down`);
wsRejectedConnectionsCounter.inc();
socket.close(1001, "Shutting down");
socket.close(lib.ConnectionClosed.GoingAway, "Shutting down");
return;
}
@@ -263,15 +263,26 @@ ${err.stack}`
} catch (err: any) {
logger.verbose(`WsServer | authentication failed for ${this.remoteAddr(req)}`);
wsRejectedConnectionsCounter.inc();
socket.close(4003, `Authentication failed: ${err.message}`);
socket.close(lib.ConnectionClosed.Unauthorized, `Authentication failed: ${err.message}`);
return;
}
let [connector, sessionToken] = this.createSession(new lib.Address(lib.Address.host, data.id));
let connection = this.hostConnections.get(data.id);
if (connection) {
logger.verbose(`WsServer | disconnecting existing connection for host ${data.id}`);
await connection.disconnect(1008, "Registered from another connection");
if (connection.remoteAddress !== this.remoteAddr(req)) {
logger.verbose(
`WsServer | disallowed duplicate connection from ${this.remoteAddr(req)} for host ${data.id}`
);
wsRejectedConnectionsCounter.inc();
socket.close(lib.ConnectionClosed.PolicyViolation, "Host already connected from another address");
return;
}
logger.verbose(
`WsServer | disconnecting duplicate connection from host ${data.id}`
);
await connection.disconnect(
lib.ConnectionClosed.PolicyViolation, "Newer connection for host from same address"
);
}
logger.info(
@@ -284,7 +295,11 @@ ${err.stack}`
);
}
connection = new HostConnection(data, connector, this.controller, this.remoteAddr(req));
let [connector, sessionToken] = this.createSession(new lib.Address(lib.Address.host, data.id));
// Handle close before HostConnection does. This avoids a host
// connection event happening between the connection breaking and
// it being removed from the host connections list.
connector.on("close", () => {
if (this.hostConnections.get(data.id) === connection) {
this.hostConnections.delete(data.id);
@@ -292,6 +307,8 @@ ${err.stack}`
logger.warn("Unlisted HostConnection closed");
}
});
connection = new HostConnection(data, connector, this.controller, this.remoteAddr(req));
this.hostConnections.set(data.id, connection);
let src = new lib.Address(lib.Address.host, data.id);
connector.ready(socket, src, sessionToken, undefined);
@@ -310,7 +327,7 @@ ${err.stack}`
throw new Error("unexpected JsonWebToken type");
}
user = this.controller.userManager.users.get(tokenPayload.user);
user = this.controller.userManager.getByName(tokenPayload.user);
if (!user) {
throw new Error("invalid user");
}
@@ -322,7 +339,7 @@ ${err.stack}`
} catch (err: any) {
logger.verbose(`WsServer | authentication failed for ${this.remoteAddr(req)}`);
wsRejectedConnectionsCounter.inc();
socket.close(4003, `Authentication failed: ${err.message}`);
socket.close(lib.ConnectionClosed.Unauthorized, `Authentication failed: ${err.message}`);
return;
}
@@ -331,10 +348,10 @@ ${err.stack}`
let [connector, sessionToken] = this.createSession(new lib.Address(lib.Address.control, id));
logger.verbose(`WsServer | registered control ${id} from ${this.remoteAddr(req)}`);
let connection = new ControlConnection(data, connector, this.controller, user, id);
connector.on("close", () => {
this.controlConnections.delete(id);
});
let connection = new ControlConnection(data, connector, this.controller, user, id);
this.controlConnections.set(id, connection);
let account = new lib.AccountDetails(
user.name,
+3 -3
View File
@@ -185,7 +185,7 @@ function validateUserToken(req: Request, res: Response, next: any) {
if (typeof tokenPayload === "string") {
throw new Error("unexpected JsonWebToken type");
}
let user = req.app.locals.controller.userManager.users.get(tokenPayload.user);
let user = req.app.locals.controller.userManager.getByName(tokenPayload.user);
if (!user) {
throw new Error("invalid user");
}
@@ -218,7 +218,7 @@ async function uploadExport(req: Request, res: Response) {
res.sendStatus(400);
return;
}
let modPack = res.app.locals.controller.modPacks.get(modPackId);
let modPack = res.app.locals.controller.modPacks.getMutable(modPackId);
if (!modPack) {
res.sendStatus(400);
return;
@@ -261,7 +261,7 @@ async function uploadExport(req: Request, res: Response) {
modPack.exportManifest = new lib.ExportManifest(assets);
modPack.fillDefaultSettings(settingPrototypes, logger);
res.app.locals.controller.modPacksUpdated([modPack]);
res.app.locals.controller.modPacks.set(modPack);
res.sendStatus(200);
}
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.browser.json",
"references": [
{ "path": "../lib/tsconfig.browser.json" },
{ "path": "../web_ui/tsconfig.browser.json" },
],
"include": [ "web/**/*.tsx", "web/**/*.ts", "package.json" ],
"compilerOptions": {
"rootDir": "..",
}
}
+3 -6
View File
@@ -1,10 +1,7 @@
{
"extends": "../../tsconfig.node.json",
"files": [],
"references": [
{ "path": "../lib" },
{ "path": "./tsconfig.node.json" },
{ "path": "./tsconfig.browser.json" },
],
"include": ["index.ts", "controller.ts", "package.json", "src/**/*.ts"],
"compilerOptions": {
"outDir": "dist",
},
}
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.node.json",
"references": [
{ "path": "../lib/tsconfig.node.json" },
],
"include": ["index.ts", "controller.ts", "package.json", "src/**/*.ts"],
}
-12
View File
@@ -1,12 +0,0 @@
{
"extends": "../../tsconfig.web.json",
"references": [
{ "path": "../lib/tsconfig.web.json" },
{ "path": "../web_ui/tsconfig.web.json" },
],
"include": [ "web/**/*.tsx", "web/**/*.ts", "package.json" ],
"compilerOptions": {
"rootDir": "..",
"outDir": "dist/web/build",
}
}
+110 -11
View File
@@ -12,10 +12,24 @@ const util = require("util");
const yargs = require("yargs");
const { levels, logger, setLogLevel } = require("./logging");
const { copyPluginTemplates } = require("./template");
let dev = false;
const scriptExt = process.platform === "win32" ? ".cmd" : "";
const finished = util.promisify(stream.finished);
const { escapeArg } = require("./escape_arg");
// I hate this, there is a bug with rl.close on windows that was meant to have been fixed in Node14.4
// See nodejs/node#21771 and nodejs/node#30701 for the apparent fix that was applied
// One option is to not call rl.close: SBoudrias/Inquirer.js#767
// But this comes with its own issues: SBoudrias/Inquirer.js#899
// The solution below was adapted from aws-amplify/amplify-cli#12347
if (process.platform === "win32") {
const _inquirer_close = inquirer.ui.Prompt.prototype.close;
inquirer.ui.Prompt.prototype.close = function() {
this.rl.terminal = false;
_inquirer_close.call(this);
};
}
const availablePlugins = [
{ name: "Global Chat", value: "@clusterio/plugin-global_chat" },
@@ -93,15 +107,25 @@ async function safeOutputFile(file, data, options={}) {
}
async function execFile(cmd, args) {
logger.verbose(`executing ${cmd} ${args.join(" ")}`);
const escaped = args.map(escapeArg);
logger.verbose(`executing ${cmd} ${escaped.join(" ")}`);
return new Promise((resolve, reject) => {
let child = child_process.execFile(cmd, args, (err, stdout, stderr) => {
if (err) {
reject(err);
} else {
resolve({ stdout, stderr });
let child = child_process.execFile(
cmd,
escaped,
{
shell: true,
// eslint-disable-next-line node/no-process-env
env: process.platform === "win32" ? { ...process.env, pct: "%" } : undefined,
},
(err, stdout, stderr) => {
if (err) {
reject(err);
} else {
resolve({ stdout, stderr });
}
}
});
);
let stdout = new LineSplitter({ readableObjectMode: true });
stdout.on("data", line => { logger.verbose(line.toString()); });
child.stdout.pipe(stdout);
@@ -505,6 +529,7 @@ WantedBy=multi-user.target
async function inquirerMissingArgs(args) {
let answers = {};
if (args.mode) { answers.mode = args.mode; }
if (args["plugin-template"]) { answers.mode = "plugin-template"; }
answers = await inquirer.prompt([
{
type: "list",
@@ -517,6 +542,7 @@ async function inquirerMissingArgs(args) {
{ name: "Host only", value: "host" },
{ name: "Ctl only", value: "ctl" },
{ name: "Plugins only", value: "plugins" },
{ name: "Plugin template", value: "plugin-template" },
],
},
], answers);
@@ -536,6 +562,25 @@ async function inquirerMissingArgs(args) {
},
},
], answers);
if (args.httpPort) { answers.httpPort = args.httpPort; }
answers = await inquirer.prompt([
{
type: "input",
name: "httpPort",
message: "HTTP port to listen on",
default: 8080,
validate: input => {
if (!input) {
return "May not be empty";
}
if (!Number.isInteger(Number(input))) {
return "Must be a number";
}
return true;
},
},
], answers);
}
if (answers.mode === "host") {
@@ -644,6 +689,39 @@ async function inquirerMissingArgs(args) {
}
}
if (answers.mode === "plugin-template") {
if (args["plugin-template"] && args["plugin-template"].length > 0) {
answers.pluginTemplate = args["plugin-template"];
}
if (args["plugin-name"]) {
answers.pluginName = args["plugin-name"];
}
answers.plugins = [];
answers = await inquirer.prompt([
{
type: "checkbox",
name: "pluginTemplate",
message: "Plugin templates to use",
choices: [
{ name: "Controller", value: "controller" },
{ name: "Host", value: "host" },
{ name: "Instance", value: "instance" },
{ name: "Lua Module", value: "module" },
{ name: "Command Line", value: "ctl" },
{ name: "Web UI", value: "web" },
{ name: "No Typescript", value: "js" },
{ name: "No Config", value: "no_config" },
],
},
{
type: "input",
name: "pluginName",
message: "Name of your new plugin",
default: path.basename(path.resolve()),
},
], answers);
}
if (dev) {
answers.plugins = [];
} else if (args.plugins) {
@@ -668,7 +746,11 @@ async function downloadLinuxServer() {
const url = new URL(res.headers.location);
// get the filename of the latest factorio archive from redirected url
const filename = path.posix.basename(url.pathname);
const version = filename.match(/(?<=factorio_headless_x64_).*(?=\.tar\.xz)/)[0];
const match = filename.match(/(?<=factorio_headless_x64_|factorio-headless_linux_)\d+\.\d+\.\d+(?=\.tar\.xz)/);
if (!match || !match.length) {
throw Error(`Unable to extract version from filename: ${filename}`);
}
const version = match[0];
const tmpDir = "temp/create-temp/";
const archivePath = tmpDir + filename;
@@ -720,7 +802,7 @@ async function main() {
})
.option("mode", {
nargs: 1, describe: "Operating mode to install",
choices: ["standalone", "controller", "host", "ctl", "plugins"],
choices: ["standalone", "controller", "host", "ctl", "plugins", "plugin-template"],
})
.option("migrate-rename", {
nargs: 0, describe: "Migrate from before slave/master rename", default: false, type: "boolean",
@@ -728,6 +810,9 @@ async function main() {
.option("admin", {
nargs: 1, describe: "Admin account name [standalone/controller]", type: "string",
})
.option("http-port", {
nargs: 1, describe: "HTTP port to listen on [standalone/controller]", type: "number",
})
.option("host-name", {
nargs: 1, describe: "Host name [host]", type: "string",
})
@@ -746,6 +831,12 @@ async function main() {
.option("plugins", {
array: true, describe: "Plugins to install", type: "string",
})
.option("plugin-template", {
array: true, describe: "Plugin template to use [plugin-template]", type: "string",
})
.option("plugin-name", {
nargs: 1, describe: "Name of your new plugin [plugin-template]", type: "string",
})
.strict()
;
@@ -779,6 +870,13 @@ async function main() {
}
let answers = await inquirerMissingArgs(args);
logger.verbose(JSON.stringify(answers));
if (answers.pluginTemplate) {
await copyPluginTemplates(answers.pluginName, answers.pluginTemplate);
return;
}
if (answers.downloadHeadless) {
if (answers.factorioDir !== "factorio") {
throw new InstallError("--download-headless option requires --factorio-dir to be set to factorio");
@@ -786,8 +884,6 @@ async function main() {
await downloadLinuxServer();
}
logger.verbose(JSON.stringify(answers));
if (!dev) {
await installClusterio(answers.mode, answers.plugins);
}
@@ -796,6 +892,7 @@ async function main() {
if (["standalone", "controller"].includes(answers.mode)) {
logger.info("Setting up controller");
await execController(["bootstrap", "create-admin", answers.admin]);
await execController(["config", "set", "controller.http_port", answers.httpPort]);
let result = await execController(["bootstrap", "generate-user-token", answers.admin]);
adminToken = result.stdout.split("\n").slice(-2)[0];
}
@@ -810,6 +907,8 @@ async function main() {
result = await execController(["bootstrap", "generate-host-token", hostId]);
let hostToken = result.stdout.split("\n").slice(-2)[0];
// Default to localhost on correct port for host in standalone mode
await execHost(["config", "set", "host.controller_url", `http://localhost:${answers.httpPort}/`]);
await execHost(["config", "set", "host.controller_token", hostToken]);
await execHost(["config", "set", "host.public_address", answers.publicAddress]);
await execHost(["config", "set", "host.factorio_directory", answers.factorioDir]);
+62
View File
@@ -0,0 +1,62 @@
"use strict";
function escapeArg(arg) {
// When shell: true is used execFile does not escaping of arguments, they are
// simply joined by space and then passed to the shell.
// For Windows the command is cmd.exe /d /s /c "command arg arg ..."
// The parsing and escaping on windows is cursed. The following references are not for mortals:
// https://daviddeley.com/autohotkey/parameters/parameters.htm#WIN
// https://stackoverflow.com/a/4095133
// https://ss64.com/nt/syntax-esc.html
if (process.platform === "win32") {
if (!/[\t "%&<>\\^|]/.test(arg)) {
return arg;
}
const escaped = [];
let pos = 0;
while (pos < arg.length) {
if (arg[pos] === "\\") {
// If a backslash is followed by a quote or the end of the string, then
// double the number of backslashes because why not!
let peek = pos + 1;
while (peek !== arg.length && arg[peek] === "\\") {
peek += 1;
}
if (peek === arg.length || arg[peek] === '"') {
const count = peek - pos;
escaped.push("\\".repeat(count * 2));
pos = peek;
continue;
} else {
escaped.push("\\");
}
} else if (arg[pos] === '"') {
escaped.push('""');
} else if (arg[pos] === "%") {
// Expect a pct environment variable to contain %, because cmd does variable substitutions
// before parsing string quotes, and there's no mechanism for escaping % symbols, and this
// is a half sane workaround. The less sane workaround is to replace % with %%cd:~,%.
escaped.push("%pct%");
} else {
escaped.push(arg[pos]);
}
pos += 1;
}
return `"${escaped.join("")}"`;
}
// For all other platforms the command is /bin/sh -c <command arg arg ...>
// The command and its arguments are passed as a single argument to the shell.
// See https://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html#tag_18_02
// eslint-disable-next-line no-lonely-if
if (!/[|&;<>()$`\\"' \t*?\[#~=%]/.test(arg)) {
return arg;
}
return `"${arg.replace(/[$`"\\]/g, "\\$&")}"`;
}
module.exports = {
escapeArg,
};
+3 -3
View File
@@ -3,14 +3,14 @@
"description": "Installer for clusterio",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.16",
"version": "2.0.0-alpha.19",
"main": "create.js",
"bin": {
"clusteriocreate": "./create.js"
},
"keywords": [
"factorio",
"clusterio"
"clusterio",
"factorio"
],
"engines": {
"node": ">=18"
+194
View File
@@ -0,0 +1,194 @@
"use strict";
const { logger } = require("./logging");
const fs = require("fs-extra");
const path = require("path");
async function copyTemplateFile(src, dst, properties) {
logger.verbose(`Writing ${dst} from template ${src}`);
// Closure, get the property value or throw
function getProperty(property) {
if (!properties.hasOwnProperty(property)) {
throw new Error(`Unknown property (${property}) found in template file ${src}`);
}
return properties[property];
}
// Closure, Get the boolean conjunction of a set of properties
function propertyConjunction(props) {
for (const property of props) {
if (property.startsWith("!")) {
if (Boolean(getProperty(property.slice(1)))) {
return false;
}
} else if (!Boolean(getProperty(property))) {
return false;
}
}
return true;
}
// Process the the template file
const badChars = /[\(\)-+/*<>=]/;
const templateData = await fs.readFile(src, "utf8");
const outputData = templateData
// match preprocessor comments: //%//
.replace(/^\/\/%\/\/.*\n/gm, "")
// match preprocessor insert: __property__
.replace(/__([a-zA-Z][a-zA-Z0-9_]*?[a-zA-Z0-9]?)__/gm, (_, property) => getProperty(property))
// match preprocessor conditionals: //%if <CONDITION> [// COMMENT]\n <CONTENT> \n//%endif [COMMENT]\n
.replace(/^\/\/%if ([^\n\/]*)(?:\/\/.*?)?\n((?:.|\n)*?)\/\/%endif.*\n/gm, (_, condition, content) => {
let include = false;
const conjunctions = condition.split("|");
for (const conjunct of conjunctions) {
if (badChars.test(conjunct)) {
throw new Error("Preprocessor condition can only contain logical operators" +
"such as & | or ! it can not have arithmetic, comparisons or parenthesis"
);
}
const property_names = conjunct.split("&").map(s => s.trim());
if (propertyConjunction(property_names)) {
include = true;
break;
}
}
return include ? content : "";
});
// Attempt to write the output file, warn if it already exists
try {
await fs.outputFile(dst, outputData, { flag: "wx" });
} catch (err) {
if (err.code === "EEXIST") {
logger.warn(`Could not create file ${dst} because it already exists`);
} else {
throw err;
}
}
}
async function copyPluginTemplates(pluginName, templates) {
logger.info(`Please wait, coping templates for ${templates.join(", ")}`);
const files = new Map();
const prepare = [];
// Some basic flags for selecting files to include
const javascriptOnly = templates.includes("js");
const config = !templates.includes("no_config") && templates.some(value => (
["controller", "host", "instance", "ctl"].includes(value)
));
const webpack = config || templates.includes("controller") || templates.includes("web");
// A count of the number of isolated contexts the plugin runs under
let pluginContexts = 0;
// Get the file extension and path to the templates
const ext = javascriptOnly ? "js" : "ts";
const templatePath = path.resolve(__dirname, javascriptOnly ? "./templates/plugin-js" : "./templates/plugin-ts");
const commonPath = path.resolve(__dirname, "./templates/common");
// Files included in all templates
files.set(".gitignore", path.join(commonPath, ".gitignore"));
files.set(".npmignore", path.join(commonPath, ".npmignore"));
files.set("package.json", path.join(commonPath, "package.json"));
files.set(`index.${ext}`, path.join(templatePath, `index.${ext}`));
// Files and dependencies to support typescript
if (!javascriptOnly) {
prepare.push("tsc --build");
files.set("tsconfig.json", path.join(templatePath, "tsconfig.json"));
files.set("tsconfig.node.json", path.join(templatePath, "tsconfig.node.json"));
if (webpack) {
files.set("tsconfig.browser.json", path.join(templatePath, "tsconfig.browser.json"));
}
}
// Files and dependences to support webpack
if (webpack) {
prepare.push("webpack-cli --env production");
files.set("webpack.config.js", path.join(commonPath, "webpack.config.js"));
if (templates.includes("web")) {
files.set(`web/index.${ext}x`, path.join(templatePath, `web/plugin.${ext}x`));
pluginContexts += 1;
} else {
files.set(`web/index.${ext}x`, path.join(templatePath, `web/no_plugin.${ext}x`));
}
}
// Files for the controller
if (templates.includes("controller")) {
files.set(`controller.${ext}`, path.join(templatePath, `controller.${ext}`));
pluginContexts += 1;
}
// Files for hosts
if (templates.includes("host")) {
files.set(`host.${ext}`, path.join(templatePath, `host.${ext}`));
pluginContexts += 1;
}
// Files for instances
if (templates.includes("instance")) {
files.set(`instance.${ext}`, path.join(templatePath, `instance.${ext}`));
pluginContexts += 1;
}
// Files for lua modules
if (templates.includes("module")) {
files.set(".luacheckrc", path.join(commonPath, ".luacheckrc"));
files.set("module/module.json", path.join(commonPath, "module/module.json"));
files.set("module/control.lua", path.join(commonPath, "module/control.lua"));
files.set("module/module_exports.lua", path.join(commonPath, "module/module_exports.lua"));
if (templates.includes("instance")) {
files.set("module/globals.lua", path.join(commonPath, "module/globals.lua"));
} else {
files.set(`instance.${ext}`, path.join(templatePath, `instance_empty.${ext}`));
pluginContexts += 1;
}
}
// Files for the control tool
if (templates.includes("ctl")) {
files.set(`ctl.${ext}`, path.join(templatePath, `ctl.${ext}`));
pluginContexts += 1;
}
// If there are more than one contexts then include the message file
if (pluginContexts > 1) {
files.set(`messages.${ext}`, path.join(templatePath, `messages.${ext}`));
}
// Properties that will control the replacements in the templates
const properties = {
config: config,
typescript: !javascriptOnly,
controller: templates.includes("controller"),
host: templates.includes("host"),
instance: templates.includes("instance"),
module: templates.includes("module"),
ctl: templates.includes("ctl"),
web: templates.includes("web"),
multi_context: pluginContexts > 1,
plugin_name: pluginName,
prepare: prepare.join(" && "),
ext: ext,
};
// Write all the template files
const writes = [];
for (let [dst, src] of files) {
writes.push(copyTemplateFile(src, dst, properties));
}
// Wait for writes to complete
await Promise.all(writes);
logger.info("Successfully wrote all template files");
}
module.exports = {
copyPluginTemplates,
};
@@ -0,0 +1,3 @@
dist/
node_modules/
package-lock.json
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,3 @@
webpack.config.js
dist/web/build
dist/browser
@@ -0,0 +1,79 @@
local clusterio_api = require("modules/clusterio/api")
local compat = require("modules/clusterio/compat")
--- Top level module table, contains event handlers and public methods
--- @class __plugin_name__
local MyModule = {
}
--- script_data is 'synced' between players, you should use your plugin name to avoid name conflicts
-- The following helper function is desync safe and avoids name conflicts after being called during on_server_startup
-- There are edge cases in the following events: pre player left, player left, console command, and config changed
-- If you use any of these events and access globalData then make sure to call this function first
local script_data = {}
local function setup_script_data()
if compat.script_data["__plugin_name__"] == nil then
compat.script_data["__plugin_name__"] = {
-- starting values go here
}
end
MyModule.on_load()
end
--- The on_load function is called independently for each client when they first load the map
-- It should be used to restore global aliases and metatables not registered with script.register_metatable
function MyModule.on_load()
script_data = compat.script_data["__plugin_name__"]
end
--- Public methods should be available though your top level module table
function MyModule.foo()
game.print("foo")
end
--- Private methods should be local to the file, this will prevent others from calling it
local function bar()
game.print("bar")
end
--- Clusterio provides a few custom events, on_server_startup is the most useful and should be used in place of on_init
--- @param event EventData.on_server_startup
local function on_server_startup(event)
setup_script_data()
game.print(compat.table_to_json(event))
end
--- Triggered every time a player crafts an item, see below for how handlers are registered
--- @param event EventData.on_player_crafted_item
local function on_player_crafted_item(event)
game.print(compat.table_to_json(event))
//%if instance
local player = assert(game.get_player(event.player_index))
clusterio_api.send_json("__plugin_name__-plugin_example_ipc", {
tick = game.tick, player_name = player.name
})
//%endif
end
--- Run bar every 5 seconds, see below for how handlers are registered
local function on_nth_tick_300()
game.print(game.tick)
bar()
end
--- Factorio events are accessible through defines.events, you can have one handler per event per module
local e = defines.events
local events = {
[clusterio_api.events.on_server_startup] = on_server_startup,
[e.on_player_crafted_item] = on_player_crafted_item,
}
--- Nth tick is a special case that requires its own table, the index represents the time period between calls in ticks
local on_nth_tick = {
[300] = on_nth_tick_300,
}
--- Always return the top level module table from control, this is how clusterio will access your event handlers
MyModule.events = events --- @package
MyModule.on_nth_tick = on_nth_tick --- @package
return MyModule
@@ -0,0 +1,3 @@
--- Expose any globals here, typically just your control file if you want to access it through rcon or commands
-- luacheck: globals __plugin_name__
__plugin_name__ = require("modules/__plugin_name__/control")
@@ -0,0 +1,14 @@
{
"name": "__plugin_name__",
"load": [
"control.lua"
],
"require": [
//%if instance
"globals.lua"
//%endif
],
"dependencies": {
"clusterio": "*"
}
}
@@ -0,0 +1,4 @@
--- Can contain anything you want to allow other plugins to have access to, this example exposes the control file
--- If you only export a single file then it may be better to rename that file to module_exports and save the redirection
-- Access the exports from other modules using require("modules/__plugin_name__")
return require("modules/__plugin_name__/control")
@@ -0,0 +1,54 @@
{
"name": "__plugin_name__",
"version": "0.1.0",
"description": "Example Description. Package. Change me in package.json",
//%if typescript
"main": "dist/node/index.js",
//%endif
//%if !typescript
"main": "index.js",
//%endif
"scripts": {
"prepare": "__prepare__"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@clusterio/lib": "workspace:*"
},
"devDependencies": {
//%// Typescript and type declarations
//%if typescript
"typescript": "^5.5.3",
"@types/node": "^20.4.5",
//%endif
//%if typescript & web
"@types/react": "^18.2.21",
//%endif
//%// Everything else
//%if web
"antd": "^5.13.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
//%endif
//%if web | controller // The controller uses webpack even when there is no plugin code
"webpack": "^5.88.2",
"webpack-cli": "^5.1.4",
"webpack-merge": "^5.9.0",
"@clusterio/web_ui": "workspace:*",
//%endif
"@clusterio/lib": "workspace:*"
},
"dependencies": {
"@sinclair/typebox": "^0.30.4"
},
"publishConfig": {
"access": "public"
},
"keywords": [
"clusterio",
"clusterio-plugin",
"factorio"
]
}
@@ -0,0 +1,36 @@
"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 = {}) => merge(common(env), {
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",
//%if web
"./web": "./web/index.__ext__x",
//%endif
},
shared: {
"@clusterio/lib": { import: false },
"@clusterio/web_ui": { import: false },
//%if web
"antd": { import: false },
"react": { import: false },
"react-dom": { import: false },
//%endif
},
}),
],
});
@@ -0,0 +1,83 @@
"use strict";
const lib = require("@clusterio/lib");
const { BaseControllerPlugin } = require("@clusterio/controller");
//%if multi_context // Subscribing requires multi context
const {
PluginExampleEvent, PluginExampleRequest,
//%endif
//%if controller & web // Subscribing requires web content and the controller
ExampleSubscribableUpdate, ExampleSubscribableValue,
//%endif
//%if multi_context // Subscribing requires multi context
} = require("./messages");
//%endif
class ControllerPlugin extends BaseControllerPlugin {
//%if controller & web // Subscribing requires web content and the controller
exampleDatabase;
storageDirty = false;
//%endif
//%if multi_context // Subscribing requires multi context
async init() {
this.controller.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
this.controller.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
//%endif
//%if controller & web // Subscribing requires web content and the controller
this.controller.subscriptions.handle(ExampleSubscribableUpdate, this.handleExampleSubscription.bind(this));
this.exampleDatabase = new Map(); // If needed, replace with loading from database file
//%endif
//%if multi_context // Subscribing requires multi context
}
//%endif
async onControllerConfigFieldChanged(field, curr, prev) {
this.logger.info(`controller::onControllerConfigFieldChanged ${field}`);
}
//%if instance
async onInstanceConfigFieldChanged(instance, field, curr, prev) {
this.logger.info(`controller::onInstanceConfigFieldChanged ${instance.id} ${field}`);
}
//%endif
async onSaveData() {
this.logger.info("controller::onSaveData");
}
async onShutdown() {
this.logger.info("controller::onShutdown");
}
async onPlayerEvent(instance, event) {
this.logger.info(`controller::onPlayerEvent ${instance.id} ${JSON.stringify(event)}`);
}
//%if multi_context
async handlePluginExampleEvent(event) {
this.logger.info(JSON.stringify(event));
}
async handlePluginExampleRequest(request) {
this.logger.info(JSON.stringify(request));
return {
myResponseString: request.myString,
myResponseNumbers: request.myNumberArray,
};
}
//%endif
//%if controller & web // Subscribing requires web content and the controller
async handleExampleSubscription(request) {
const values = [...this.exampleDatabase.values()].filter(
value => value.updatedAtMs > request.lastRequestTimeMs,
);
return values.length ? new ExampleSubscribableUpdate(values) : null;
}
//%endif
}
module.exports = {
ControllerPlugin,
};
@@ -0,0 +1,32 @@
"use strict";
const { BaseCtlPlugin } = require("@clusterio/ctl");
const { CommandTree, Command } = require("@clusterio/lib");
//%// We do not check for multi context here because it doesn't make sense to have a ctl without messages
const { PluginExampleEvent, PluginExampleRequest } = require("./messages");
/* eslint-disable no-console */
const pluginCommands = new CommandTree({
name: "__plugin_name__", description: "The default description I forgot to change",
});
pluginCommands.add(new Command({
definition: ["my-command <foo>", "My command description that I really should change", (yargs) => {
yargs.positional("foo", { describe: "foo is foo!", type: "string" });
}],
handler: async function(args, control) {
const response = await control.sendTo("controller", new PluginExampleRequest(args.foo, [1, 2, 3]));
console.log(response);
console.log(args);
},
}));
/* eslint-enable no-console */
class CtlPlugin extends BaseCtlPlugin {
async addCommands(rootCommand) {
rootCommand.add(pluginCommands);
}
}
module.exports = {
CtlPlugin,
};
@@ -0,0 +1,41 @@
"use strict";
const lib = require("@clusterio/lib");
const { BaseHostPlugin } = require("@clusterio/host");
//%if multi_context
const { PluginExampleEvent, PluginExampleRequest } = require("./messages");
//%endif
class HostPlugin extends BaseHostPlugin {
//%if multi_context
async init() {
this.host.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
this.host.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
}
//%endif
async onHostConfigFieldChanged(field, curr, prev) {
this.logger.info(`host::onInstanceConfigFieldChanged ${field}`);
}
async onShutdown() {
this.logger.info("host::onShutdown");
}
//%if multi_context
async handlePluginExampleEvent(event) {
this.logger.info(JSON.stringify(event));
}
async handlePluginExampleRequest(request) {
this.logger.info(JSON.stringify(request));
return {
myResponseString: request.myString,
myResponseNumbers: request.myNumberArray,
};
}
//%endif
}
module.exports = {
HostPlugin,
};
@@ -0,0 +1,126 @@
"use strict";
const lib = require("@clusterio/lib");
//%if multi_context
const Messages = require("./messages");
lib.definePermission({
name: "__plugin_name__.example.permission.event",
title: "Example permission event",
description: "Example Description. Event. Change me in index.js",
});
lib.definePermission({
name: "__plugin_name__.example.permission.request",
title: "Example permission request",
description: "Example Description. Request. Change me in index.js",
});
//%endif
//%if controller & web // Subscribing requires web content and the controller
lib.definePermission({
name: "__plugin_name__.example.permission.subscribe",
title: "Example permission subscribe",
description: "Example Description. Subscribe. Change me in index.js",
});
//%endif
//%if web
lib.definePermission({
name: "__plugin_name__.page.view",
title: "Example page view permission",
description: "Example Description. View. Change me in index.js",
});
//%endif
const plugin = {
name: "__plugin_name__",
title: "__plugin_name__",
description: "Example Description. Plugin. Change me in index.js",
//%if controller | host & !config | instance & !config | ctl & !config // Blank line for formatting
//%endif
//%if controller
controllerEntrypoint: "./dist/node/controller",
//%endif
//%if controller & config
controllerConfigFields: {
"__plugin_name__.myControllerField": {
title: "My Controller Field",
description: "This should be removed from index.js",
type: "string",
initialValue: "Remove Me",
},
},
//%endif
//%if host & config // Blank line for formatting
//%endif
//%if host
hostEntrypoint: "./dist/node/host",
//%endif
//%if host & config
hostConfigFields: {
"__plugin_name__.myHostField": {
title: "My Host Field",
description: "This should be removed from index.js",
type: "string",
initialValue: "Remove Me",
},
},
//%endif
//%if instance & config | module & config // Blank line for formatting
//%endif
//%if instance | module // Modules load an empty instance plugin
instanceEntrypoint: "./dist/node/instance",
//%endif
//%if instance & config
instanceConfigFields: {
"__plugin_name__.myInstanceField": {
title: "My Instance Field",
description: "This should be removed from index.js",
type: "string",
initialValue: "Remove Me",
},
},
//%endif
//%if ctl & config // Blank line for formatting
//%endif
//%if ctl
ctlEntrypoint: "./dist/node/ctl",
//%endif
//%if ctl & config
controlConfigFields: {
"__plugin_name__.myControlField": {
title: "My Control Field",
description: "This should be removed from index.js",
type: "string",
initialValue: "Remove Me",
},
},
//%endif
//%if multi_context // Subscribing requires multi context
messages: [
Messages.PluginExampleEvent,
Messages.PluginExampleRequest,
//%endif
//%if controller & web // Subscribing requires web content and the controller
Messages.ExampleSubscribableUpdate,
//%endif
//%if multi_context // Subscribing requires multi context
],
//%endif
//%if web // Web content template has an example route which is the plugin name
webEntrypoint: "./web",
routes: [
"/__plugin_name__",
],
//%endif
};
module.exports = {
plugin,
};
@@ -0,0 +1,66 @@
"use strict";
const lib = require("@clusterio/lib");
const { BaseInstancePlugin } = require("@clusterio/host");
//%if multi_context
const { PluginExampleEvent, PluginExampleRequest } = require("./messages");
//%endif
class InstancePlugin extends BaseInstancePlugin {
//%if multi_context | module
async init() {
//%if multi_context
this.instance.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
this.instance.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
//%endif
//%if module
this.instance.server.handle("__plugin_name__-plugin_example_ipc", this.handlePluginExampleIPC.bind(this));
//%endif
//%if multi_context | module
}
//%endif
async onInstanceConfigFieldChanged(field, curr, prev) {
this.logger.info(`instance::onInstanceConfigFieldChanged ${field}`);
}
async onStart() {
this.logger.info("instance::onStart");
}
async onStop() {
this.logger.info("instance::onStop");
}
async onPlayerEvent(event) {
this.logger.info(`onPlayerEvent::onPlayerEvent ${JSON.stringify(event)}`);
//%if module
if (this.instance.status === "running") {
this.sendRcon("/sc __plugin_name__.foo()");
}
//%endif
}
//%if multi_context
async handlePluginExampleEvent(event) {
this.logger.info(JSON.stringify(event));
}
async handlePluginExampleRequest(request) {
this.logger.info(JSON.stringify(request));
return {
myResponseString: request.myString,
myResponseNumbers: request.myNumberArray,
};
}
//%endif
//%if module
async handlePluginExampleIPC(event) {
this.logger.info(JSON.stringify(event));
}
//%endif
}
module.exports = {
InstancePlugin,
};
@@ -0,0 +1,11 @@
"use strict";
const { BaseInstancePlugin } = require("@clusterio/host");
class InstancePlugin extends BaseInstancePlugin {
// This class is empty because an instance plugin must be defined for a module to be injected
// This requirement may change in the future to allow for standalone modules
}
module.exports = {
InstancePlugin,
};
@@ -0,0 +1,101 @@
"use strict";
const { plainJson, jsonArray, JsonBoolean, JsonNumber, JsonString, StringEnum } = require("@clusterio/lib");
const { Type } = require("@sinclair/typebox");
class PluginExampleEvent {
static type = "event";
static src = ["host", "control"];
static dst = ["controller", "host", "instance"];
static plugin = "__plugin_name__";
static permission = "__plugin_name__.example.permission.event";
constructor(myString, myNumberArray) {
this.myString = myString;
this.myNumberArray = myNumberArray;
}
static jsonSchema = Type.Object({
"myString": Type.String(),
"myNumberArray": Type.Array(Type.Number()),
});
static fromJSON(json) {
return new PluginExampleEvent(json.myString, json.myNumberArray);
}
}
class PluginExampleRequest {
static type = "request";
static src = ["host", "control"];
static dst = ["controller", "host", "instance"];
static plugin = "__plugin_name__";
static permission = "__plugin_name__.example.permission.request";
constructor(myString, myNumberArray) {
this.myString = myString;
this.myNumberArray = myNumberArray;
}
static jsonSchema = Type.Object({
"myString": Type.String(),
"myNumberArray": Type.Array(Type.Number()),
});
static fromJSON(json) {
return new PluginExampleRequest(json.myString, json.myNumberArray);
}
static Response = plainJson(Type.Object({
"myResponseString": Type.String(),
"myResponseNumbers": Type.Array(Type.Number()),
}));
}
//%if controller & web // Subscribing requires web content and the controller
class ExampleSubscribableValue {
constructor(id, updatedAtMs, isDeleted) {
this.id = id;
this.updatedAtMs = updatedAtMs;
this.isDeleted = isDeleted;
}
static jsonSchema = Type.Object({
id: Type.String(),
updatedAtMs: Type.Number(),
isDeleted: Type.Boolean(),
});
static fromJSON(json) {
return new this(json.id, json.updatedAtMs, json.isDeleted);
}
}
class ExampleSubscribableUpdate {
static type = "event";
static src = "controller";
static dst = "control";
static plugin = "__plugin_name__";
static permission = "__plugin_name__.example.permission.subscribe";
constructor(updates) {
this.updates = updates;
}
static jsonSchema = Type.Object({
"updates": Type.Array(ExampleSubscribableValue.jsonSchema),
});
static fromJSON(json) {
return new this(json.updates.map(update => ExampleSubscribableValue.fromJSON(update)));
}
}
//%endif
module.exports = {
PluginExampleEvent,
PluginExampleRequest,
//%if controller & web // Subscribing requires web content and the controller
ExampleSubscribableValue,
ExampleSubscribableUpdate,
//%endif
};
Whitespace-only changes.
@@ -0,0 +1,93 @@
import React, {
useContext, useEffect, useState,
//%if controller // Subscribing requires web content and the controller
useCallback, useSyncExternalStore,
//%endif
} from "react";
// import {
//
// } from "antd";
import {
BaseWebPlugin, PageLayout, PageHeader, Control, ControlContext, notifyErrorHandler,
} from "@clusterio/web_ui";
//%if multi_context // Subscribing requires multi context
import {
PluginExampleEvent, PluginExampleRequest,
//%endif
//%if controller // Subscribing requires web content and the controller
ExampleSubscribableUpdate, ExampleSubscribableValue,
//%endif
//%if multi_context // Subscribing requires multi context
} from "../messages";
//%endif
import * as lib from "@clusterio/lib";
function MyTemplatePage() {
let control = useContext(ControlContext);
//%if controller // Subscribing requires web content and the controller
const plugin = control.plugins.get("__plugin_name__");
const [subscribableData, synced] = plugin.useSubscribableData();
//%endif
return <PageLayout nav={[{ name: "__plugin_name__" }]}>
<PageHeader title="__plugin_name__" />
//%if controller // Subscribing requires web content and the controller
Synced: {String(synced)} Data: {JSON.stringify([...subscribableData.values()])}
//%endif
</PageLayout>;
}
export class WebPlugin extends BaseWebPlugin {
//%if controller // Subscribing requires web content and the controller
subscribableData = new lib.EventSubscriber(ExampleSubscribableUpdate, this.control);
//%endif
async init() {
this.pages = [
{
path: "/__plugin_name__",
sidebarName: "__plugin_name__",
// This permission is client side only, so it must match the permission string of a resource request to be secure
// An undefined value means that the page will always be visible
//%if controller // Subscribing requires web content and the controller
permission: "__plugin_name__.example.permission.subscribe",
//%endif
//%if !controller
permission: undefined, // "__plugin_name__.example.permission.request",
//%endif
content: <MyTemplatePage/>,
},
];
//%if multi_context
this.control.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
this.control.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
//%endif
}
//%if controller// Subscribing requires web content and the controller
useSubscribableData() {
const control = useContext(ControlContext);
const subscribe = useCallback((callback) => this.subscribableData.subscribe(callback), [control]);
return useSyncExternalStore(subscribe, () => this.subscribableData.getSnapshot());
}
//%endif
//%if multi_context
async handlePluginExampleEvent(event) {
this.logger.info(JSON.stringify(event));
}
async handlePluginExampleRequest(request) {
this.logger.info(JSON.stringify(request));
return {
myResponseString: request.myString,
myResponseNumbers: request.myNumberArray,
};
}
//%endif
}
@@ -0,0 +1,78 @@
import * as lib from "@clusterio/lib";
import { BaseControllerPlugin, InstanceInfo } from "@clusterio/controller";
//%if multi_context // Subscribing requires multi context
import {
PluginExampleEvent, PluginExampleRequest,
//%endif
//%if controller & web // Subscribing requires web content and the controller
ExampleSubscribableUpdate, ExampleSubscribableValue,
//%endif
//%if multi_context // Subscribing requires multi context
} from "./messages";
//%endif
export class ControllerPlugin extends BaseControllerPlugin {
//%if controller & web // Subscribing requires web content and the controller
exampleDatabase!: Map<string, ExampleSubscribableValue>;
storageDirty = false;
//%endif
//%if multi_context // Subscribing requires multi context
async init() {
this.controller.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
this.controller.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
//%endif
//%if controller & web // Subscribing requires web content and the controller
this.controller.subscriptions.handle(ExampleSubscribableUpdate, this.handleExampleSubscription.bind(this));
this.exampleDatabase = new Map(); // If needed, replace with loading from database file
//%endif
//%if multi_context // Subscribing requires multi context
}
//%endif
async onControllerConfigFieldChanged(field: string, curr: unknown, prev: unknown) {
this.logger.info(`controller::onControllerConfigFieldChanged ${field}`);
}
//%if instance
async onInstanceConfigFieldChanged(instance: InstanceInfo, field: string, curr: unknown, prev: unknown) {
this.logger.info(`controller::onInstanceConfigFieldChanged ${instance.id} ${field}`);
}
//%endif
async onSaveData() {
this.logger.info("controller::onSaveData");
}
async onShutdown() {
this.logger.info("controller::onShutdown");
}
async onPlayerEvent(instance: InstanceInfo, event: lib.PlayerEvent) {
this.logger.info(`controller::onPlayerEvent ${instance.id} ${JSON.stringify(event)}`);
}
//%if multi_context
async handlePluginExampleEvent(event: PluginExampleEvent) {
this.logger.info(JSON.stringify(event));
}
async handlePluginExampleRequest(request: PluginExampleRequest) {
this.logger.info(JSON.stringify(request));
return {
myResponseString: request.myString,
myResponseNumbers: request.myNumberArray,
};
}
//%endif
//%if controller & web // Subscribing requires web content and the controller
async handleExampleSubscription(request: lib.SubscriptionRequest) {
const values = [...this.exampleDatabase.values()].filter(
value => value.updatedAtMs > request.lastRequestTimeMs,
);
return values.length ? new ExampleSubscribableUpdate(values) : null;
}
//%endif
}
@@ -0,0 +1,27 @@
import { BaseCtlPlugin, type Control } from "@clusterio/ctl";
import { CommandTree, Command } from "@clusterio/lib";
//%// We do not check for multi context here because it doesn't make sense to have a ctl without messages
import { PluginExampleEvent, PluginExampleRequest } from "./messages";
/* eslint-disable no-console */
const pluginCommands = new CommandTree({
name: "__plugin_name__", description: "The default description I forgot to change",
});
pluginCommands.add(new Command({
definition: ["my-command <foo>", "My command description that I really should change", (yargs) => {
yargs.positional("foo", { describe: "foo is foo!", type: "string" });
}],
handler: async function(args: { foo: string }, control: Control) {
const response = await control.sendTo("controller", new PluginExampleRequest(args.foo, [1, 2, 3]));
console.log(response);
console.log(args);
},
}));
/* eslint-enable no-console */
export class CtlPlugin extends BaseCtlPlugin {
async addCommands(rootCommand: CommandTree) {
rootCommand.add(pluginCommands);
}
}
@@ -0,0 +1,36 @@
import * as lib from "@clusterio/lib";
import { BaseHostPlugin } from "@clusterio/host";
//%if multi_context
import { PluginExampleEvent, PluginExampleRequest } from "./messages";
//%endif
export class HostPlugin extends BaseHostPlugin {
//%if multi_context
async init() {
this.host.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
this.host.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
}
//%endif
async onHostConfigFieldChanged(field: string, curr: unknown, prev: unknown) {
this.logger.info(`host::onInstanceConfigFieldChanged ${field}`);
}
async onShutdown() {
this.logger.info("host::onShutdown");
}
//%if multi_context
async handlePluginExampleEvent(event: PluginExampleEvent) {
this.logger.info(JSON.stringify(event));
}
async handlePluginExampleRequest(request: PluginExampleRequest) {
this.logger.info(JSON.stringify(request));
return {
myResponseString: request.myString,
myResponseNumbers: request.myNumberArray,
};
}
//%endif
}
@@ -0,0 +1,149 @@
import * as lib from "@clusterio/lib";
//%if multi_context
import * as Messages from "./messages";
lib.definePermission({
name: "__plugin_name__.example.permission.event",
title: "Example permission event",
description: "Example Description. Event. Change me in index.ts",
});
lib.definePermission({
name: "__plugin_name__.example.permission.request",
title: "Example permission request",
description: "Example Description. Request. Change me in index.ts",
});
//%endif
//%if controller & web // Subscribing requires web content and the controller
lib.definePermission({
name: "__plugin_name__.example.permission.subscribe",
title: "Example permission subscribe",
description: "Example Description. Subscribe. Change me in index.ts",
});
//%endif
//%if web
lib.definePermission({
name: "__plugin_name__.page.view",
title: "Example page view permission",
description: "Example Description. View. Change me in index.ts",
});
//%endif
//%if config
declare module "@clusterio/lib" {
//%endif
//%if controller & config
export interface ControllerConfigFields {
"__plugin_name__.myControllerField": string;
}
//%endif
//%if host & config
export interface HostConfigFields {
"__plugin_name__.myHostField": string;
}
//%endif
//%if instance & config
export interface InstanceConfigFields {
"__plugin_name__.myInstanceField": string;
}
//%endif
//%if ctl & config
export interface ControlConfigFields {
"__plugin_name__.myControlField": string;
}
//%endif
//%if config
}
//%endif
export const plugin: lib.PluginDeclaration = {
name: "__plugin_name__",
title: "__plugin_name__",
description: "Example Description. Plugin. Change me in index.ts",
//%// There are a lot of statements here to make the formatting look good with and without 'config'
//%if controller | host & !config | instance & !config | ctl & !config // Blank line for formatting
//%endif
//%if controller
controllerEntrypoint: "./dist/node/controller",
//%endif
//%if controller & config
controllerConfigFields: {
"__plugin_name__.myControllerField": {
title: "My Controller Field",
description: "This should be removed from index.js",
type: "string",
initialValue: "Remove Me",
},
},
//%endif
//%if host & config // Blank line for formatting
//%endif
//%if host
hostEntrypoint: "./dist/node/host",
//%endif
//%if host & config
hostConfigFields: {
"__plugin_name__.myHostField": {
title: "My Host Field",
description: "This should be removed from index.js",
type: "string",
initialValue: "Remove Me",
},
},
//%endif
//%if instance & config | module & config // Blank line for formatting
//%endif
//%if instance | module // Modules load an empty instance plugin
instanceEntrypoint: "./dist/node/instance",
//%endif
//%if instance & config
instanceConfigFields: {
"__plugin_name__.myInstanceField": {
title: "My Instance Field",
description: "This should be removed from index.js",
type: "string",
initialValue: "Remove Me",
},
},
//%endif
//%if ctl & config // Blank line for formatting
//%endif
//%if ctl
ctlEntrypoint: "./dist/node/ctl",
//%endif
//%if ctl & config
controlConfigFields: {
"__plugin_name__.myControlField": {
title: "My Control Field",
description: "This should be removed from index.js",
type: "string",
initialValue: "Remove Me",
},
},
//%endif
//%if multi_context // Subscribing requires multi context
messages: [
Messages.PluginExampleEvent,
Messages.PluginExampleRequest,
//%endif
//%if controller & web // Subscribing requires web content and the controller
Messages.ExampleSubscribableUpdate,
//%endif
//%if multi_context // Subscribing requires multi context
],
//%endif
//%if web // Web content template has an example route which is the plugin name
webEntrypoint: "./web",
routes: [
"/__plugin_name__",
],
//%endif
};
@@ -0,0 +1,69 @@
import * as lib from "@clusterio/lib";
import { BaseInstancePlugin } from "@clusterio/host";
//%if multi_context
import { PluginExampleEvent, PluginExampleRequest } from "./messages";
//%endif
//%if module
type PuginExampleIPC = {
tick: number,
player_name: string,
};
//%endif
export class InstancePlugin extends BaseInstancePlugin {
//%if multi_context | module
async init() {
//%endif
//%if multi_context
this.instance.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
this.instance.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
//%endif
//%if module
this.instance.server.handle("__plugin_name__-plugin_example_ipc", this.handlePluginExampleIPC.bind(this));
//%endif
//%if multi_context | module
}
//%endif
async onInstanceConfigFieldChanged(field: string, curr: unknown, prev: unknown) {
this.logger.info(`instance::onInstanceConfigFieldChanged ${field}`);
}
async onStart() {
this.logger.info("instance::onStart");
}
async onStop() {
this.logger.info("instance::onStop");
}
async onPlayerEvent(event: lib.PlayerEvent) {
this.logger.info(`onPlayerEvent::onPlayerEvent ${JSON.stringify(event)}`);
//%if module
if (this.instance.status === "running") {
this.sendRcon("/sc __plugin_name__.foo()");
}
//%endif
}
//%if multi_context
async handlePluginExampleEvent(event: PluginExampleEvent) {
this.logger.info(JSON.stringify(event));
}
async handlePluginExampleRequest(request: PluginExampleRequest) {
this.logger.info(JSON.stringify(request));
return {
myResponseString: request.myString,
myResponseNumbers: request.myNumberArray,
};
}
//%endif
//%if module
async handlePluginExampleIPC(event: PuginExampleIPC) {
this.logger.info(JSON.stringify(event));
}
//%endif
}
@@ -0,0 +1,7 @@
import * as lib from "@clusterio/lib";
import { BaseInstancePlugin } from "@clusterio/host";
export class InstancePlugin extends BaseInstancePlugin {
// This class is empty because an instance plugin must be defined for a module to be injected
// This requirement may change in the future to allow for standalone modules
}
@@ -0,0 +1,97 @@
import { plainJson, jsonArray, JsonBoolean, JsonNumber, JsonString, StringEnum } from "@clusterio/lib";
import { Type, Static } from "@sinclair/typebox";
export class PluginExampleEvent {
declare ["constructor"]: typeof PluginExampleEvent;
static type = "event" as const;
static src = ["host", "control"] as const;
static dst = ["controller", "host", "instance"] as const;
static plugin = "__plugin_name__" as const;
static permission = "__plugin_name__.example.permission.event";
constructor(
public myString: string,
public myNumberArray: number[],
) {
}
static jsonSchema = Type.Object({
"myString": Type.String(),
"myNumberArray": Type.Array(Type.Number()),
});
static fromJSON(json: Static<typeof PluginExampleEvent.jsonSchema>) {
return new PluginExampleEvent(json.myString, json.myNumberArray);
}
}
export class PluginExampleRequest {
declare ["constructor"]: typeof PluginExampleRequest;
static type = "request" as const;
static src = ["host", "control"] as const;
static dst = ["controller", "host", "instance"] as const;
static plugin = "__plugin_name__" as const;
static permission = "__plugin_name__.example.permission.request";
constructor(
public myString: string,
public myNumberArray: number[],
) {
}
static jsonSchema = Type.Object({
"myString": Type.String(),
"myNumberArray": Type.Array(Type.Number()),
});
static fromJSON(json: Static<typeof PluginExampleRequest.jsonSchema>) {
return new PluginExampleRequest(json.myString, json.myNumberArray);
}
static Response = plainJson(Type.Object({
"myResponseString": Type.String(),
"myResponseNumbers": Type.Array(Type.Number()),
}));
}
//%if controller & web // Subscribing requires web content and the controller
export class ExampleSubscribableValue {
constructor(
public id: string,
public updatedAtMs: number,
public isDeleted: boolean,
) {
}
static jsonSchema = Type.Object({
id: Type.String(),
updatedAtMs: Type.Number(),
isDeleted: Type.Boolean(),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.id, json.updatedAtMs, json.isDeleted);
}
}
export class ExampleSubscribableUpdate {
declare ["constructor"]: typeof ExampleSubscribableUpdate;
static type = "event" as const;
static src = "controller" as const;
static dst = "control" as const;
static plugin = "__plugin_name__" as const;
static permission = "__plugin_name__.example.permission.subscribe";
constructor(
public updates: ExampleSubscribableValue[],
) { }
static jsonSchema = Type.Object({
"updates": Type.Array(ExampleSubscribableValue.jsonSchema),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.updates.map(update => ExampleSubscribableValue.fromJSON(update)));
}
}
//%endif
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.browser.json",
"references": [
{ "path": "../../packages/lib/tsconfig.browser.json" },
{ "path": "../../packages/web_ui/tsconfig.browser.json" },
],
"include": [ "web/**/*.tsx", "web/**/*.ts", "messages.ts", "package.json" ],
}
@@ -0,0 +1,9 @@
{
"files": [],
"references": [
//%if web
{ "path": "./tsconfig.browser.json" },
//%endif
{ "path": "./tsconfig.node.json" }
]
}
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.node.json",
"references": [
{ "path": "../../packages/lib/tsconfig.node.json" },
],
"include": ["./**/*.ts"],
"exclude": ["test/*", "./dist/*"],
}
Whitespace-only changes.
@@ -0,0 +1,93 @@
import React, {
useContext, useEffect, useState,
//%if controller & web // Subscribing requires web content and the controller
useCallback, useSyncExternalStore,
//%endif
} from "react";
// import {
//
// } from "antd";
import {
BaseWebPlugin, PageLayout, PageHeader, Control, ControlContext, notifyErrorHandler,
} from "@clusterio/web_ui";
//%if multi_context // Subscribing requires multi context
import {
PluginExampleEvent, PluginExampleRequest,
//%endif
//%if controller & web // Subscribing requires web content and the controller
ExampleSubscribableUpdate, ExampleSubscribableValue,
//%endif
//%if multi_context // Subscribing requires multi context
} from "../messages";
//%endif
import * as lib from "@clusterio/lib";
function MyTemplatePage() {
const control = useContext(ControlContext);
//%if controller // Subscribing requires web content and the controller
const plugin = control.plugins.get("__plugin_name__") as WebPlugin;
const [subscribableData, synced] = plugin.useSubscribableData();
//%endif
return <PageLayout nav={[{ name: "__plugin_name__" }]}>
<PageHeader title="__plugin_name__" />
//%if controller // Subscribing requires web content and the controller
Synced: {String(synced)} Data: {JSON.stringify([...subscribableData.values()])}
//%endif
</PageLayout>;
}
export class WebPlugin extends BaseWebPlugin {
//%if controller // Subscribing requires web content and the controller
subscribableData = new lib.EventSubscriber(ExampleSubscribableUpdate, this.control);
//%endif
async init() {
this.pages = [
{
path: "/__plugin_name__",
sidebarName: "__plugin_name__",
// This permission is client side only, so it must match the permission string of a resource request to be secure
// An undefined value means that the page will always be visible
//%if controller // Subscribing requires web content and the controller
permission: "__plugin_name__.example.permission.subscribe",
//%endif
//%if !controller
permission: undefined, // "__plugin_name__.example.permission.request",
//%endif
content: <MyTemplatePage/>,
},
];
//%if multi_context
this.control.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
this.control.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
//%endif
}
//%if controller // Subscribing requires web content and the controller
useSubscribableData() {
const control = useContext(ControlContext);
const subscribe = useCallback((callback: () => void) => this.subscribableData.subscribe(callback), [control]);
return useSyncExternalStore(subscribe, () => this.subscribableData.getSnapshot());
}
//%endif
//%if multi_context
async handlePluginExampleEvent(event: PluginExampleEvent) {
this.logger.info(JSON.stringify(event));
}
async handlePluginExampleRequest(request: PluginExampleRequest) {
this.logger.info(JSON.stringify(request));
return {
myResponseString: request.myString,
myResponseNumbers: request.myNumberArray,
};
}
//%endif
}
+114
View File
@@ -0,0 +1,114 @@
# Templates
Create has the ability to create new plugins from templates to avoid needing to manually copy lots of boiler plate. This folder contains all those templates for both Javascript and Typescript plugins. These templates have custom preprocessor instructions that can be used to alter a template based on developer input, this syntax is described below.
- [The Basics](#the-basics)
- [Insert Instruction](#insert-instruction) (`__variable__`)
- [Conditional Instruction](#conditional-instruction) (`//%if` `//%endif`)
- [Available Variables](#available-variables)
## The Basics
All files can contain preprocessor instructions whether json, js, lua, or anything else. All instructions use the same syntax although it has been designed to work well with js and ts files.
Except for the insert instruction, all preprocessors use a `//%` prefix and must be on their own line. These lines are never included in the output. Parsing is stopped when `//` is encountered to allow for comments on preprocessors. Eg. `//%if foo // this is a comment`.
Empty instructions, aka just comments, can be inserted into templates using `//%//` and are not included in the output file.
`templates/plugin-js` contains all the files which will be used by a javascript only plugin.
`templates/plugin-ts` contains all the files which will be used by a plugin that supports typescript.
`templates/common` contains files used by both types of plugins, this is mostly lua code or root files like `package.json`
Files are not copied via wildcards and instead are explicitly listed within `template.js` so any new templates will need to be added there.
## Insert Instruction
There are a number of template variables available to be inserted into templates. All variables are raw string values, that is they are inserted directly into the output file without any modifications. To signify a place to insert a variable you need only surround its identifier with double underscores, examples below.
Example Variables:
```sh
my_number=1
my_string=foo
my_property=bar: "baz"
```
Example Template:
```ts
const __my_string__: number = __my_number__;
console.log(__my_string__);
console.log({
foo: "Hello, __my_string__!",
__my_property__,
my_property: __my_string__,
});
```
Example Output:
```ts
const foo: number = 1;
console.log(foo);
console.log({
foo: "Hello, foo!",
bar: "baz",
my_property: foo,
});
```
## Conditional Instruction
Conditionals allow for code to be included/omitted based on the value of template variables. Currently only boolean logic in [disjunctive normal form](https://en.wikipedia.org/wiki/Disjunctive_normal_form) is supported and any variables that are not booleans are evaluated with the [Boolean constructor](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Boolean).
Variables used in condition statements do not require double underscores. Their values can be inverted using `!`. Arithmetic and comparison operations are not supported. Parentheses are also not supported, conditions must be in DNF.
Conversion to DNF:
`A & (B | !C)` -> `A & B | A & !C` evaluated as `(A and B) or (A and not C)`
Example Variables:
```sh
my_string=foo
say_hello=true
say_done=true
verbose=false
```
Example Template:
```ts
//%if verbose
console.log("Starting...");
//%endif
//%if say_hello & my_string
console.log("Hello, __my_string__!");
//%endif
//%if say_done | verbose
console.log("Done.");
//%endif
```
Example Output:
```ts
console.log("Hello, foo!");
console.log("Done.");
```
## Available Variables
| Identifier | Example | Description |
| - | - | - |
| typescript | `true` / `false` | `true` if typescript templates are included |
| controller | `true` / `false` | `true` if controller templates are included |
| host | `true` / `false` | `true` if host templates are included |
| instance | `true` / `false` | `true` if instance templates are included |
| module | `true` / `false` | `true` if module templates are included |
| ctl | `true` / `false` | `true` if control templates are included |
| web | `true` / `false` | `true` if web templates are included |
| plugin_name | `my_plugin` | name of the plugin |
| prepare | `tsc --build` | commands to run for the prepare script |
| ext | `ts` / `js` | file extension of source files |
+1 -8
View File
@@ -188,14 +188,7 @@ async function startControl() {
lib.handleUnhandledErrors();
logger.verbose(`Loading available plugins from ${args.pluginList}`);
let pluginList = new Map();
try {
pluginList = new Map(JSON.parse(await fs.readFile(args.pluginList, "utf8")));
} catch (err: any) {
if (err.code !== "ENOENT") {
throw err;
}
}
let pluginList = await lib.loadPluginList(args.pluginList);
// If the command is plugin management we don't try to load plugins
if (args._[0] === "plugin") {
+7 -6
View File
@@ -3,28 +3,29 @@
"description": "Clusterio command line management tool",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.16",
"main": "dist/index.js",
"version": "2.0.0-alpha.19",
"main": "dist/node/index.js",
"scripts": {
"prepare": "tsc --build",
"test": "echo \"Error: no test specified\" && exit 1"
},
"bin": {
"clusterioctl": "./dist/ctl.js"
"clusterioctl": "./dist/node/ctl.js"
},
"keywords": [
"clusterio",
"factorio"
],
"engines": {
"node": ">=18"
},
"dependencies": {
"@clusterio/lib": "^2.0.0-alpha.16",
"@clusterio/lib": "workspace:*",
"as-table": "^1.0.55",
"fs-extra": "^11.1.1",
"phin": "^3.7.0",
"set-blocking": "^2.0.0",
"winston": "^3.10.0",
"winston": "^3.17.0",
"yargs": "^17.7.2"
},
"publishConfig": {
@@ -35,6 +36,6 @@
"@types/node": "^20.4.5",
"@types/set-blocking": "^2.0.0",
"@types/yargs": "^17.0.24",
"typescript": "^5.1.6"
"typescript": "^5.5.3"
}
}
+2 -6
View File
@@ -1,10 +1,6 @@
{
"extends": "../../tsconfig.node.json",
"files": [],
"references": [
{ "path": "../lib" },
{ "path": "./tsconfig.node.json" },
],
"include": ["index.ts", "package.json", "ctl.ts", "src/**/*.ts"],
"compilerOptions": {
"outDir": "dist",
},
}
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.node.json",
"references": [
{ "path": "../lib/tsconfig.node.json" },
],
"include": ["index.ts", "package.json", "ctl.ts", "src/**/*.ts"],
}
+3 -10
View File
@@ -147,14 +147,7 @@ async function startHost() {
}
logger.info(`Loading available plugins from ${args.pluginList}`);
let pluginList = new Map();
try {
pluginList = new Map(JSON.parse(await fs.readFile(args.pluginList, "utf8")));
} catch (err: any) {
if (err.code !== "ENOENT") {
throw err;
}
}
let pluginList = await lib.loadPluginList(args.pluginList);
// If the command is plugin management we don't try to load plugins
if (command === "plugin") {
@@ -293,8 +286,8 @@ I version of clusterio. Expect things to break. I
`
);
startHost().catch(err => {
if (err instanceof lib.AuthenticationFailed) {
logger.fatal(err.message);
if (err instanceof lib.WebSocketError) {
// Already handled by connector.on("error") within Host
} else if (err instanceof lib.StartupError) {
logger.fatal(`
+3 -2
View File
@@ -1,4 +1,5 @@
-- Important: Keep this API in sync with modules/clusterio/api.lua
local compat = require("compat")
local api = {}
@@ -48,7 +49,7 @@ function api.send_json(channel, data)
return "\\x" .. string.format("%02x", match:byte())
end)
data = game.table_to_json(data)
data = compat.table_to_json(data)
-- If there's more than about 4kB of data users running with the Windows
-- console open will start to experience stuttering, otherwise we could
@@ -58,7 +59,7 @@ function api.send_json(channel, data)
else
local file_no = remote.call("clusterio_api", "get_file_no")
local file_name = "clst_" .. file_no .. ".json"
game.write_file(file_name, data, false, 0)
compat.write_file(file_name, data, false, 0)
print("\f$ipc:" .. channel .. "?f" .. file_name)
end
end
+7 -1
View File
@@ -12,6 +12,10 @@
{
"version": "0.1.2",
"factorio_version": "1.1"
},
{
"version": "0.1.3",
"factorio_version": "2.0"
}
],
"title": "Clusterio Library (Alpha)",
@@ -19,6 +23,8 @@
"homepage": "https://github.com/clusterio/clusterio",
"description": "Library for interfacing with Clusterio. Warning: work in progress alpha.",
"additional_files": {
"serialize.lua": ["..", "..", "modules", "clusterio", "serialize.lua"]
"compat.lua": ["..", "..", "modules", "clusterio", "compat.lua"],
"serialize.lua": ["..", "..", "modules", "clusterio", "serialize.lua"],
"json.lua": ["..", "..", "modules", "clusterio", "json.lua"]
}
}
+4 -3
View File
@@ -1,11 +1,12 @@
local function send_json(channel, data)
data = game.table_to_json(data)
data = helpers and helpers.table_to_json(data) or game.table_to_json(data)
print("\f$ipc:" .. channel .. "?j" .. data)
end
script.on_init(function()
send_json("mod_list", game.active_mods)
for name, mod_setting in pairs(game.mod_setting_prototypes) do
send_json("mod_list", script.active_mods)
local mod_settings = prototypes and prototypes.mod_setting or game.mod_setting_prototypes
for name, mod_setting in pairs(mod_settings) do
send_json("mod_setting_mod", { name = name, mod = mod_setting.mod })
end
end)
+9 -6
View File
@@ -1,6 +1,8 @@
-- Important: Keep this API in sync with mod/api.lua
local compat = require("modules/clusterio/compat")
local api = {}
clusterio_patch_number = clusterio_patch_number or 0 --- @type number
api.events = {
on_instance_updated = script.generate_event_name(),
@@ -8,11 +10,11 @@ api.events = {
}
function api.get_instance_name()
return global.clusterio.instance_name
return compat.script_data.clusterio.instance_name
end
function api.get_instance_id()
return global.clusterio.instance_id
return compat.script_data.clusterio.instance_id
end
function api.send_json(channel, data)
@@ -23,7 +25,7 @@ function api.send_json(channel, data)
return "\\x" .. string.format("%02x", match:byte())
end)
data = game.table_to_json(data)
data = compat.table_to_json(data)
-- If there's more than about 4kB of data users running with the Windows
-- console open will start to experience stuttering, otherwise we could
@@ -31,9 +33,10 @@ function api.send_json(channel, data)
if #data < 4000 then
print("\f$ipc:" .. channel .. "?j" .. data)
else
global.clusterio_file_no = (global.clusterio_file_no or 0) + 1
local file_name = "clst_" .. global.clusterio_file_no .. ".json"
game.write_file(file_name, data, false, 0)
local script_data = compat.script_data
script_data.clusterio_file_no = (script_data.clusterio_file_no or 0) + 1
local file_name = "clst_" .. script_data.clusterio_file_no .. ".json"
compat.write_file(file_name, data, false, 0)
print("\f$ipc:" .. channel .. "?f" .. file_name)
end
end
+221
View File
@@ -0,0 +1,221 @@
--[[
Adds various functions used to ensure compatibly between version including some polyfills.
Implementation Guidelines
- If a function is only available at runtime in one version, then all versions must be made to error.
- Where possible, directly assign the function / table to the member of compat.
- When not possible, then it should replace itself with a direct assignment asap.
- Versions are handled in ascending order covering all supported versions and ending with an error.
]]
local lib_json = require("json")
--- @diagnostic disable: deprecated
--- @class LibCompat
--- @field script_data table
--- @field prototypes LuaPrototypes
--- @field active_mods table<string, string>
--- @field write_file fun(filename: string, data: LocalisedString, append: boolean?, for_player: uint?)
--- @field table_to_json fun(data: table): string
--- @field json_to_table fun(json: string): AnyBasic?
local compat = {}
--- @class LibCompatMt
--- @package
local compat_mt = {}
--- @alias VersionTable { [1]: number, [2]: number, [3]: number }
--- Concert a version string into a version table
--- @param version string
--- @return VersionTable
local function version_to_table(version)
local index = 0
local rtn = { 0, 0, 0 }
for part in version:gmatch("%d+") do
index = index + 1
rtn[index] = tonumber(part)
end
return rtn
end
--- The current version of factorio, the earliest supported is 0.17.69
local current_version_str = "0.17.69"
local current_version = version_to_table(current_version_str)
do
local success, base_version = pcall(function()
return script.active_mods.base
end)
if success then
current_version_str = base_version
current_version = version_to_table(current_version_str)
end
end
--- Returns true if the current version is equal to the provided version
--- @param version string Version to compare against
--- @return boolean # True if versions are equal
function compat.version_eq(version)
local version_tbl = version_to_table(version)
for i = 1, 3 do
if version_tbl[i] ~= current_version[i] then
return false
end
end
return true
end
--- Returns true if the current version is greater than or equal to the the provided version
--- @param version string Version to compare against
--- @return boolean # True if the version is greater or equal to provided
function compat.version_ge(version)
local version_tbl = version_to_table(version)
for i = 1, 3 do
if version_tbl[i] > current_version[i] then
return false
elseif version_tbl[i] < current_version[i] then
return true
end
end
return true
end
--- Returns true if the current version is less than or equal to the the provided version
--- @param version string Version to compare against
--- @return boolean # True if the version is less or equal to provided
function compat.version_le(version)
local version_tbl = version_to_table(version)
for i = 1, 3 do
if version_tbl[i] < current_version[i] then
return false
elseif version_tbl[i] > current_version[i] then
return true
end
end
return true
end
--- The major versions of factorio we support
local v2 = compat.version_ge("2.0")
local v1 = compat.version_ge("1.0") and not compat.version_ge("2.0")
local v0 = compat.version_ge("0.17") and not compat.version_ge("1.0")
--- The minor versions of factorio we support
local v2_0 = compat.version_ge("2.0")
local v1_1 = compat.version_ge("1.1") and not compat.version_ge("2.0")
local v1_0 = compat.version_ge("1.0") and not compat.version_ge("1.1")
local v0_18 = compat.version_ge("0.18") and not compat.version_ge("1.0")
local v0_17 = compat.version_ge("0.17") and not compat.version_ge("0.18")
--- Raise an error because the current version is unsupported
local function unsupported_version()
error("Unsupported version: " .. current_version_str, 3)
end
--- Raise an error because the function is only available at runtime
local function runtime_only()
error("To maintain compatibly, this value can only be used during runtime.", 3)
end
--- Keys of this table will raise an unsupported error when accessed
local unsupported_properties = {}
--- Keys of this table can only be accessed at runtime
--- The value is a function which returns the real value
local runtime_properties = {}
--- Handles the indexing of the tables above
function compat_mt:__index(key)
if unsupported_properties[key] then
return unsupported_version()
elseif runtime_properties[key] then
if game == nil then
return runtime_only()
end
local value = runtime_properties[key]()
if value == nil then
return unsupported_version()
end
rawset(self, key, value)
return value
end
end
--- Prototype data contains all the definitions for the game, it is only available during runtime
function runtime_properties.prototypes()
if v0 or v1 then
--- Lazy loading of prototype data
--- Iteration is UB, we can fix this once someone actually needs it
return setmetatable({}, {
__index = function(self, key)
local value = game[key .. "_prototypes"]
rawset(self, key, value)
return value
end
})
elseif v2 then
return prototypes
end
end
--- List of all active mods
function runtime_properties.active_mods()
if v0_17 then
return game.active_mods
elseif v0 or v1 or v2 then
return script.active_mods
end
end
--- Writes a string to file
function runtime_properties.write_file()
if v0 or v1 then
return game.write_file
elseif v2 then
return helpers.write_file
end
end
--- Convert tables to and from json, we have backported this to work before runtime
if v0 or v1 then
compat.table_to_json = function(tbl)
if game then
compat.table_to_json = game.table_to_json
return game.table_to_json(tbl)
end
return lib_json.encode(tbl)
end
compat.json_to_table = function(json)
if game then
compat.json_to_table = game.json_to_table
return game.json_to_table(json)
end
return lib_json.decode(json)
end
elseif v2 then
compat.table_to_json = helpers.table_to_json
compat.json_to_table = helpers.json_to_table
else
unsupported_properties.table_to_json = true
unsupported_properties.json_to_table = true
end
--- Script data refers to lua data which is persisted between loads
--- This is called immediately, during init and during load
local function set_script_data()
if v0 or v1 then
--- @diagnostic disable-next-line
compat.script_data = global
elseif v2 then
compat.script_data = storage
else
unsupported_properties.script_data = true
end
end
set_script_data()
compat.on_init = set_script_data --- @package
compat.on_load = set_script_data --- @package
return setmetatable(compat, compat_mt)
+39 -11
View File
@@ -1,10 +1,14 @@
local api = require('modules/clusterio/api')
local compat = require("modules/clusterio/compat")
--- @class (exact) EventData.on_server_startup:EventData
local function check_patch()
if global.clusterio_patch_number ~= clusterio_patch_number then
global.clusterio_patch_number = clusterio_patch_number
script.raise_event(api.events.on_server_startup, {})
if compat.script_data.clusterio_patch_number ~= clusterio_patch_number then
compat.script_data.clusterio_patch_number = clusterio_patch_number
script.raise_event(api.events.on_server_startup, {
name = api.events.on_server_startup, tick = game.tick
})
end
end
@@ -14,8 +18,8 @@ impl.events = {}
impl.events[defines.events.on_tick] = check_patch
impl.events[api.events.on_server_startup] = function()
if not global.clusterio then
global.clusterio = {
if not compat.script_data.clusterio then
compat.script_data.clusterio = {
instance_id = nil,
instance_name = nil,
}
@@ -46,12 +50,35 @@ impl.events[defines.events.on_player_left_game] = function(event)
})
end
impl.events[defines.events.on_player_banned] = function(event)
api.send_json("player_event", {
type = "BAN",
name = event.player_name,
reason = event.reason,
})
end
impl.events[defines.events.on_player_unbanned] = function(event)
api.send_json("player_event", { type = "UNBANNED", name = event.player_name })
end
impl.events[defines.events.on_player_promoted] = function(event)
local player = game.players[event.player_index]
api.send_json("player_event", { type = "PROMOTE", name = player.name })
end
impl.events[defines.events.on_player_demoted] = function(event)
local player = game.players[event.player_index]
api.send_json("player_event", { type = "DEMOTE", name = player.name })
end
-- Internal API
clusterio_private = {}
function clusterio_private.update_instance(new_id, new_name)
check_patch()
global.clusterio.instance_id = new_id
global.clusterio.instance_name = new_name
local script_data = compat.script_data
script_data.clusterio.instance_id = new_id
script_data.clusterio.instance_name = new_name
script.raise_event(api.events.on_instance_updated, {
instance_id = new_id,
instance_name = new_name,
@@ -71,16 +98,17 @@ remote.add_interface('clusterio_api', {
end,
get_instance_id = function()
return global.clusterio.instance_id
return compat.script_data.clusterio.instance_id
end,
get_instance_name = function()
return global.clusterio.instance_name
return compat.script_data.clusterio.instance_name
end,
get_file_no = function()
global.clusterio_file_no = (global.clusterio_file_no or 0) + 1
return global.clusterio_file_no
local script_data = compat.script_data
script_data.clusterio_file_no = (script_data.clusterio_file_no or 0) + 1
return script_data.clusterio_file_no
end,
})
+388
View File
@@ -0,0 +1,388 @@
--
-- json.lua / https://github.com/rxi/json.lua
--
-- Copyright (c) 2020 rxi
--
-- Permission is hereby granted, free of charge, to any person obtaining a copy of
-- this software and associated documentation files (the "Software"), to deal in
-- the Software without restriction, including without limitation the rights to
-- use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
-- of the Software, and to permit persons to whom the Software is furnished to do
-- so, subject to the following conditions:
--
-- The above copyright notice and this permission notice shall be included in all
-- copies or substantial portions of the Software.
--
-- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-- SOFTWARE.
--
local json = { _version = "0.1.2" }
-------------------------------------------------------------------------------
-- Encode
-------------------------------------------------------------------------------
local encode
local escape_char_map = {
[ "\\" ] = "\\",
[ "\"" ] = "\"",
[ "\b" ] = "b",
[ "\f" ] = "f",
[ "\n" ] = "n",
[ "\r" ] = "r",
[ "\t" ] = "t",
}
local escape_char_map_inv = { [ "/" ] = "/" }
for k, v in pairs(escape_char_map) do
escape_char_map_inv[v] = k
end
local function escape_char(c)
return "\\" .. (escape_char_map[c] or string.format("u%04x", c:byte()))
end
local function encode_nil(val)
return "null"
end
local function encode_table(val, stack)
local res = {}
stack = stack or {}
-- Circular reference?
if stack[val] then error("circular reference") end
stack[val] = true
if rawget(val, 1) ~= nil or next(val) == nil then
-- Treat as array -- check keys are valid and it is not sparse
local n = 0
for k in pairs(val) do
if type(k) ~= "number" then
error("invalid table: mixed or invalid key types")
end
n = n + 1
end
if n ~= #val then
error("invalid table: sparse array")
end
-- Encode
for i, v in ipairs(val) do
table.insert(res, encode(v, stack))
end
stack[val] = nil
return "[" .. table.concat(res, ",") .. "]"
else
-- Treat as an object
for k, v in pairs(val) do
if type(k) ~= "string" then
error("invalid table: mixed or invalid key types")
end
table.insert(res, encode(k, stack) .. ":" .. encode(v, stack))
end
stack[val] = nil
return "{" .. table.concat(res, ",") .. "}"
end
end
local function encode_string(val)
return '"' .. val:gsub('[%z\1-\31\\"]', escape_char) .. '"'
end
local function encode_number(val)
-- Check for NaN, -inf and inf
if val ~= val or val <= -math.huge or val >= math.huge then
error("unexpected number value '" .. tostring(val) .. "'")
end
return string.format("%.14g", val)
end
local type_func_map = {
[ "nil" ] = encode_nil,
[ "table" ] = encode_table,
[ "string" ] = encode_string,
[ "number" ] = encode_number,
[ "boolean" ] = tostring,
}
encode = function(val, stack)
local t = type(val)
local f = type_func_map[t]
if f then
return f(val, stack)
end
error("unexpected type '" .. t .. "'")
end
function json.encode(val)
return ( encode(val) )
end
-------------------------------------------------------------------------------
-- Decode
-------------------------------------------------------------------------------
local parse
local function create_set(...)
local res = {}
for i = 1, select("#", ...) do
res[ select(i, ...) ] = true
end
return res
end
local space_chars = create_set(" ", "\t", "\r", "\n")
local delim_chars = create_set(" ", "\t", "\r", "\n", "]", "}", ",")
local escape_chars = create_set("\\", "/", '"', "b", "f", "n", "r", "t", "u")
local literals = create_set("true", "false", "null")
local literal_map = {
[ "true" ] = true,
[ "false" ] = false,
[ "null" ] = nil,
}
local function next_char(str, idx, set, negate)
for i = idx, #str do
if set[str:sub(i, i)] ~= negate then
return i
end
end
return #str + 1
end
local function decode_error(str, idx, msg)
local line_count = 1
local col_count = 1
for i = 1, idx - 1 do
col_count = col_count + 1
if str:sub(i, i) == "\n" then
line_count = line_count + 1
col_count = 1
end
end
error( string.format("%s at line %d col %d", msg, line_count, col_count) )
end
local function codepoint_to_utf8(n)
-- http://scripts.sil.org/cms/scripts/page.php?site_id=nrsi&id=iws-appendixa
local f = math.floor
if n <= 0x7f then
return string.char(n)
elseif n <= 0x7ff then
return string.char(f(n / 64) + 192, n % 64 + 128)
elseif n <= 0xffff then
return string.char(f(n / 4096) + 224, f(n % 4096 / 64) + 128, n % 64 + 128)
elseif n <= 0x10ffff then
return string.char(f(n / 262144) + 240, f(n % 262144 / 4096) + 128,
f(n % 4096 / 64) + 128, n % 64 + 128)
end
error( string.format("invalid unicode codepoint '%x'", n) )
end
local function parse_unicode_escape(s)
local n1 = tonumber( s:sub(1, 4), 16 )
local n2 = tonumber( s:sub(7, 10), 16 )
-- Surrogate pair?
if n2 then
return codepoint_to_utf8((n1 - 0xd800) * 0x400 + (n2 - 0xdc00) + 0x10000)
else
return codepoint_to_utf8(n1)
end
end
local function parse_string(str, i)
local res = ""
local j = i + 1
local k = j
while j <= #str do
local x = str:byte(j)
if x < 32 then
decode_error(str, j, "control character in string")
elseif x == 92 then -- `\`: Escape
res = res .. str:sub(k, j - 1)
j = j + 1
local c = str:sub(j, j)
if c == "u" then
local hex = str:match("^[dD][89aAbB]%x%x\\u%x%x%x%x", j + 1)
or str:match("^%x%x%x%x", j + 1)
or decode_error(str, j - 1, "invalid unicode escape in string")
res = res .. parse_unicode_escape(hex)
j = j + #hex
else
if not escape_chars[c] then
decode_error(str, j - 1, "invalid escape char '" .. c .. "' in string")
end
res = res .. escape_char_map_inv[c]
end
k = j + 1
elseif x == 34 then -- `"`: End of string
res = res .. str:sub(k, j - 1)
return res, j + 1
end
j = j + 1
end
decode_error(str, i, "expected closing quote for string")
end
local function parse_number(str, i)
local x = next_char(str, i, delim_chars)
local s = str:sub(i, x - 1)
local n = tonumber(s)
if not n then
decode_error(str, i, "invalid number '" .. s .. "'")
end
return n, x
end
local function parse_literal(str, i)
local x = next_char(str, i, delim_chars)
local word = str:sub(i, x - 1)
if not literals[word] then
decode_error(str, i, "invalid literal '" .. word .. "'")
end
return literal_map[word], x
end
local function parse_array(str, i)
local res = {}
local n = 1
i = i + 1
while 1 do
local x
i = next_char(str, i, space_chars, true)
-- Empty / end of array?
if str:sub(i, i) == "]" then
i = i + 1
break
end
-- Read token
x, i = parse(str, i)
res[n] = x
n = n + 1
-- Next token
i = next_char(str, i, space_chars, true)
local chr = str:sub(i, i)
i = i + 1
if chr == "]" then break end
if chr ~= "," then decode_error(str, i, "expected ']' or ','") end
end
return res, i
end
local function parse_object(str, i)
local res = {}
i = i + 1
while 1 do
local key, val
i = next_char(str, i, space_chars, true)
-- Empty / end of object?
if str:sub(i, i) == "}" then
i = i + 1
break
end
-- Read key
if str:sub(i, i) ~= '"' then
decode_error(str, i, "expected string for key")
end
key, i = parse(str, i)
-- Read ':' delimiter
i = next_char(str, i, space_chars, true)
if str:sub(i, i) ~= ":" then
decode_error(str, i, "expected ':' after key")
end
i = next_char(str, i + 1, space_chars, true)
-- Read value
val, i = parse(str, i)
-- Set
res[key] = val
-- Next token
i = next_char(str, i, space_chars, true)
local chr = str:sub(i, i)
i = i + 1
if chr == "}" then break end
if chr ~= "," then decode_error(str, i, "expected '}' or ','") end
end
return res, i
end
local char_func_map = {
[ '"' ] = parse_string,
[ "0" ] = parse_number,
[ "1" ] = parse_number,
[ "2" ] = parse_number,
[ "3" ] = parse_number,
[ "4" ] = parse_number,
[ "5" ] = parse_number,
[ "6" ] = parse_number,
[ "7" ] = parse_number,
[ "8" ] = parse_number,
[ "9" ] = parse_number,
[ "-" ] = parse_number,
[ "t" ] = parse_literal,
[ "f" ] = parse_literal,
[ "n" ] = parse_literal,
[ "[" ] = parse_array,
[ "{" ] = parse_object,
}
parse = function(str, idx)
local chr = str:sub(idx, idx)
local f = char_func_map[chr]
if f then
return f(str, idx)
end
decode_error(str, idx, "unexpected character '" .. chr .. "'")
end
function json.decode(str)
if type(str) ~= "string" then
error("expected argument of type string, got " .. type(str))
end
local res, idx = parse(str, next_char(str, 1, space_chars, true))
idx = next_char(str, idx, space_chars, true)
if idx <= #str then
decode_error(str, idx, "trailing garbage")
end
return res
end
return json
+4 -1
View File
@@ -2,5 +2,8 @@
"name": "clusterio",
"version": "0.0.0",
"dependencies": {},
"load": ["impl.lua"]
"load": [
"impl.lua",
"compat.lua"
]
}
+10 -30
View File
@@ -1,44 +1,22 @@
-- Library for serializing items in Factorio
-- Based on code from playerManager and trainTeleports
local compat = require("modules/clusterio/compat")
local serialize = {}
local function version_to_table(version)
local t = {}
for p in string.gmatch(version, "%d+") do
t[#t + 1] = tonumber(p)
end
return t
end
-- 0.17 compatibility
local supports_bar, get_bar, set_bar, version
if (pcall(function() local mods = script.active_mods end)) then
local supports_bar, get_bar, set_bar
if compat.version_ge("1.0.0") then
supports_bar = "supports_bar"
get_bar = "get_bar"
set_bar = "set_bar"
version = version_to_table(script.active_mods.base)
else
supports_bar = "hasbar"
get_bar = "getbar"
set_bar = "setbar"
version = version_to_table("0.17.69")
end
-- returns true if the game version is greater than or equal to the given version
local function version_ge(comp)
comp = version_to_table(comp)
for i=1, 3 do
if comp[i] > version[i] then
return false
elseif comp[i] < version[i] then
return true
end
end
return true
end
local has_create_grid = version_ge("1.1.7")
local has_create_grid = compat.version_ge("1.1.7")
local has_quality = compat.version_ge("2.0.0")
-- Equipment Grids are serialized into an array of equipment entries
-- where ench entry is a table with the following fields:
@@ -109,6 +87,7 @@ end
-- Item stacks are serialized into a table with the following fields:
-- n: name
-- c: count
-- q: quality (optional)
-- h: health (optional)
-- d: durability (optional)
-- a: ammo count (optional)
@@ -141,8 +120,9 @@ function serialize.serialize_item_stack(slot, entry)
entry.n = slot.name
entry.c = slot.count
if has_quality and slot.quality.level ~= 0 then entry.q = slot.quality.name end
if slot.health < 1 then entry.h = slot.health end
if slot.durability then entry.d = slot.durability end
if slot.type == "tool" and slot.durability then entry.d = slot.durability end
if slot.type == "ammo" then entry.a = slot.ammo end
if slot.is_item_with_label then
local label = {}
@@ -178,6 +158,7 @@ function serialize.deserialize_item_stack(slot, entry)
name = entry.n,
count = entry.c,
}
if entry.q then item_stack.quality = entry.q end
if entry.h then item_stack.health = entry.h end
if entry.d then item_stack.durability = entry.d end
if entry.a then item_stack.ammo = entry.a end
@@ -244,7 +225,7 @@ function serialize.serialize_inventory(inventory)
end
if item.n or item.f or item.e then
local item_serialized = game.table_to_json(item)
local item_serialized = compat.table_to_json(item)
if item_serialized == previous_serialized then
local previous_item = serialized.i[#serialized.i]
previous_item.r = (previous_item.r or 0) + 1
@@ -305,5 +286,4 @@ function serialize.deserialize_inventory(inventory, serialized)
end
end
return serialize
+7 -6
View File
@@ -3,23 +3,24 @@
"description": "Implementation of Clusterio host server",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.16",
"main": "dist/index.js",
"version": "2.0.0-alpha.19",
"main": "dist/node/index.js",
"scripts": {
"prepare": "tsc --build",
"test": "echo \"Error: no test specified\" && exit 1"
},
"bin": {
"clusteriohost": "./dist/host.js"
"clusteriohost": "./dist/node/host.js"
},
"keywords": [
"clusterio",
"factorio"
],
"engines": {
"node": ">=18"
},
"dependencies": {
"@clusterio/lib": "^2.0.0-alpha.16",
"@clusterio/lib": "workspace:*",
"@sinclair/typebox": "^0.30.4",
"fs-extra": "^11.1.1",
"jimp": "^0.22.8",
@@ -29,7 +30,7 @@
"rcon-client": "^4.2.3",
"semver": "^7.5.4",
"set-blocking": "^2.0.0",
"winston": "^3.10.0",
"winston": "^3.17.0",
"winston-daily-rotate-file": "^4.7.1",
"yargs": "^17.7.2"
},
@@ -43,6 +44,6 @@
"@types/semver": "^7.5.0",
"@types/set-blocking": "^2.0.0",
"@types/yargs": "^17.0.24",
"typescript": "^5.1.6"
"typescript": "^5.5.3"
}
}
+19 -8
View File
@@ -140,7 +140,7 @@ export class HostRouter {
return false;
}
origin.connector.sendResponseError(
new lib.ResponseError("Instance is not running."), message.src
new lib.ResponseError(msg || "Instance is not running."), message.src
);
}
return true;
@@ -346,13 +346,17 @@ export default class Host extends lib.Link {
logger.fatal(`Unexpected error reconnecting to controller:\n${err.stack}`);
return this.shutdown();
});
} else {
logger.fatal("Controller connection was unexpectedly closed");
this.shutdown();
}
});
this.connector.on("error", err => {
logger.fatal(err.message);
this.shutdown();
});
for (let event of ["connect", "drop", "resume", "close"] as const) {
this.connector.on(event, () => {
let message = new lib.ControllerConnectionEvent(event);
@@ -600,8 +604,9 @@ export default class Host extends lib.Link {
try {
modInfo = await this.modStore.loadMod(mod.name, mod.version);
} catch (err: any) {
// A broken zip file may have been left in the mods folder
if (err.code !== "ENOENT") {
throw err;
logger.error(`Error loading mod ${mod.name} ${mod.version}: ${err.message}`);
}
}
if (modInfo && (!mod.sha1 || mod.sha1 === modInfo.sha1)) {
@@ -613,11 +618,12 @@ export default class Host extends lib.Link {
}
async fetchMods(mods: Iterable<lib.ModRecord>) {
// This is better than the previous hard coded names
// But it really shouldn't be a hard coded version either
const builtinModNames = lib.ModPack.getBuiltinModNames("2.0");
const modInfos: Promise<lib.ModInfo>[] = [];
for (const mod of mods) {
// XXX These mods ship with Factorio, but the list of mods may
// change and should not be hardcoded here.
if (["core", "base"].includes(mod.name)) {
if (builtinModNames.includes(mod.name)) {
continue;
}
modInfos.push(this.fetchMod(mod));
@@ -635,6 +641,7 @@ export default class Host extends lib.Link {
} else {
instanceInfo = this.discoveredInstanceInfos.get(instanceId);
if (instanceInfo) {
await Instance.populate_folders(instanceInfo.path);
instanceInfo.config.update(config, true, "controller");
} else {
@@ -644,7 +651,7 @@ export default class Host extends lib.Link {
let instanceDir = await this._createNewInstanceDir(instanceConfig.get("instance.name"));
logger.info(`Creating ${instanceDir}`);
await Instance.create(instanceDir, this.config.get("host.factorio_directory"));
await Instance.populate_folders(instanceDir);
instanceInfo = {
path: instanceDir,
config: instanceConfig,
@@ -666,6 +673,9 @@ export default class Host extends lib.Link {
instanceId,
instanceConnection ? instanceConnection.instance.status : "stopped",
instanceConnection ? instanceConnection.instance.server.gamePort : this.gamePort(instanceId),
instanceConnection
? instanceConnection.instance.server.version
: instanceInfo.config.get("factorio.version"),
)
);
@@ -735,7 +745,7 @@ export default class Host extends lib.Link {
}
assignGamePort(instanceId: number) {
const availablePorts = lib.parseRanges(this.config.get("host.factorio_port_range"), 1, 2**16 - 1);
const availablePorts = lib.parseRanges(this.config.get("host.factorio_port_range"), 1, 2 ** 16 - 1);
for (const [id, instance] of this.instanceInfos) {
if (id === instanceId) {
continue;
@@ -1059,6 +1069,7 @@ export default class Host extends lib.Link {
instanceInfo.config.toRemote("controller"),
instanceConnection ? instanceConnection.instance.status : "stopped",
instanceConnection ? instanceConnection.instance.server.gamePort : this.gamePort(instanceId),
instanceConnection ? instanceConnection.instance.server.version : undefined,
));
}
await this.send(new lib.InstancesUpdateRequest(list));
+235 -62
View File
@@ -4,6 +4,8 @@ import pidusage from "pidusage";
import phin from "phin";
import util from "util";
import type { Static } from "@sinclair/typebox";
import { exec } from "child_process";
const execAsync = util.promisify(exec);
// internal libraries
import * as lib from "@clusterio/lib";
@@ -14,6 +16,12 @@ import { exportData } from "./export";
import type Host from "./Host";
import BaseInstancePlugin from "./BaseInstancePlugin";
const scriptCommands = [
"/cheat", "/editor",
"/command", "/c",
"/measured-command", "/mc",
"/silent-command", "/sc",
];
const instanceRconCommandDuration = new lib.Histogram(
"clusterio_instance_rcon_command_duration_seconds",
@@ -91,6 +99,38 @@ const serverSettingsActions = {
},
};
/**
* Get process stats for a Windows process
* Due to wmic being removed in later versions of Windows, pidusage does not work
* until this PR is merged: https://github.com/soyuka/pidusage/pull/143
* @param pid - Process ID
* @param logger - Logger instance
* @returns Process stats
*/
async function getWindowsProcessStats(pid: number, logger: lib.Logger) {
try {
const { stdout } = await execAsync(
`powershell "Get-Process -Id ${pid} | Select-Object TotalProcessorTime,WorkingSet | ConvertTo-Json"`
);
const stats = JSON.parse(stdout);
// Convert ticks (100-nanosecond intervals) to milliseconds
const totalTimeMs = stats.TotalProcessorTime.Ticks / 10000;
return {
ctime: totalTimeMs,
memory: stats.WorkingSet,
};
} catch (err) {
logger.warn("Failed to get process stats:", err);
return { ctime: 0, memory: 0 };
}
}
async function getProcessStats(pid: number, logger: lib.Logger) {
if (process.platform === "win32") {
return getWindowsProcessStats(pid, logger);
}
return pidusage(pid);
}
/**
* Keeps track of the runtime parameters of an instance
@@ -127,7 +167,7 @@ export default class Instance extends lib.Link {
_playerCheckInterval: ReturnType<typeof setInterval> | undefined;
_hadPlayersOnline = false;
_playerAutosaveSlot = 1;
_expectedUserUpdates: { action: string, name: string, reason: string }[] = [];
constructor(
host: Host,
@@ -153,7 +193,9 @@ export default class Instance extends lib.Link {
this._configFieldChanged = (field: string, curr: unknown, prev: unknown) => {
let hook = () => lib.invokeHook(this.plugins, "onInstanceConfigFieldChanged", field, curr, prev);
if (field === "factorio.settings") {
if (field === "factorio.shutdown_timeout") {
this.server.shutdownTimeoutMs = curr as number * 1000;
} else if (field === "factorio.settings") {
this.updateFactorioSettings(curr as any, prev as any).finally(hook);
} else if (field === "factorio.enable_whitelist") {
this.updateFactorioWhitelist(curr as any).finally(hook);
@@ -169,6 +211,7 @@ export default class Instance extends lib.Link {
let serverOptions = {
logger: this.logger,
version: this.config.get("factorio.version"),
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,
@@ -177,6 +220,7 @@ export default class Instance extends lib.Link {
verboseLogging: this.config.get("factorio.verbose_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(
@@ -214,6 +258,10 @@ export default class Instance extends lib.Link {
this._recordPlayerJoin(event.name);
} else if (event.type === "leave") {
this._recordPlayerLeave(event.name, event.reason);
} else if (["BAN", "UNBANNED", "PROMOTE", "DEMOTE"].includes(event.type)) {
this._recordUserUpdate(event.type, event.name, event.reason);
} else {
this.logger.warn(`Unknown type from player event ipc: ${event.type}`);
}
});
@@ -236,13 +284,15 @@ export default class Instance extends lib.Link {
this.handle(lib.InstanceSendRconRequest, this.handleInstanceSendRconRequest.bind(this));
}
_watchPlayerJoinsByChat() {
_watchServerLogActions() {
this.server.on("output", (parsed: lib.ParsedFactorioOutput, line: string) => {
if (parsed.type !== "action") {
return;
}
let name = /^([^ ]+)/.exec(parsed.message)![1];
// Detect player leave and join
if (parsed.action === "JOIN") {
this._recordPlayerJoin(name);
} else if (["LEAVE", "KICK", "BAN"].includes(parsed.action)) {
@@ -253,6 +303,14 @@ export default class Instance extends lib.Link {
}[parsed.action]!;
this._recordPlayerLeave(name, reason);
}
// Detect banned and admin state change, whitelist changes are not logged
if (parsed.action === "BAN") {
const reason = /\. Reason: (.+)\.$/.exec(parsed.message)![1];
this._recordUserUpdate(parsed.action, name, reason !== "unspecified" ? reason : "");
} else if (["UNBANNED", "PROMOTE", "DEMOTE"].includes(parsed.action)) {
this._recordUserUpdate(parsed.action as "UNBANNED" | "PROMOTE" | "DEMOTE", name);
}
});
// Leave log entries are unreliable and sometimes don't show up.
@@ -263,6 +321,22 @@ export default class Instance extends lib.Link {
}, 60e3);
}
// Needed to cover promote/demote when player is not on map, not required when _watchServerLogActions is used
_watchPlayerPromote() {
this.server.on("output", (parsed: lib.ParsedFactorioOutput, line: string) => {
if (
parsed.type !== "action"
|| !["PROMOTE", "DEMOTE"].includes(parsed.action)
|| !parsed.message.includes("be promoted upon joining the game.")
) {
return;
}
const name = /^([^ ]+)/.exec(parsed.message)![1];
this._recordUserUpdate(parsed.action as "PROMOTE" | "DEMOTE", name);
});
}
async _checkOnlinePlayers() {
if (this.playersOnline.size) {
let actualPlayers = (await this.sendRcon("/players online"))
@@ -367,6 +441,17 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " ");
}
async sendRcon(message: string, expectEmpty = false, plugin = "") {
const trimmedMessage = message.trim();
if (
!this.config.get("factorio.enable_script_commands")
&& scriptCommands.find(cmd => trimmedMessage.startsWith(cmd))
) {
throw new Error(
"Attempted to use script command while disabled. See config factorio.enable_script_commands.\n" +
`Command: ${message}`
);
}
let instanceId = String(this.id);
let observeDuration = instanceRconCommandDuration.labels(instanceId).startTimer();
try {
@@ -453,6 +538,7 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " ");
this.id,
status,
this.server.gamePort,
status === "running"? this.server.version : this.config.get("factorio.version"),
),
);
}
@@ -469,6 +555,7 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " ");
notifyExit() {
this._loadedSave = null;
this.notifyStatus("stopped");
this.connector.emit("close");
this.config.off("fieldChanged", this._configFieldChanged);
clearTimeout(this._playerCheckInterval);
@@ -577,11 +664,32 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " ");
* entries from the given settings object.
*
* @param overrides - Server settings to override.
* @param includeCredentials - Include Factorio username and token from host/controller config.
* @returns
* server example settings with the given settings applied over it.
*/
async resolveServerSettings(overrides: Record<string, unknown>) {
async resolveServerSettings(overrides: Record<string, unknown>, includeCredentials: boolean) {
let serverSettings = await this.server.exampleSettings();
if (includeCredentials && overrides.username === undefined && overrides.token === undefined) {
let credentials = {
username: this._host.config.get("host.factorio_username") ?? undefined,
token: this._host.config.get("host.factorio_token") ?? undefined,
};
if (!credentials.username && !credentials.token) {
Object.assign(credentials, await this.sendTo("controller", new lib.GetFactorioCredentialsRequest()));
if (credentials.username || credentials.token) {
this.logger.info("Using Factorio credentials from controller config");
} else {
this.logger.warn("No Factorio credentials found");
}
} else {
this.logger.info("Using Factorio credentials from host config");
}
if (credentials.username) { serverSettings.username = credentials.username; }
if (credentials.token) { serverSettings.token = credentials.token; }
} else if (includeCredentials) {
this.logger.info("Using Factorio credentials from instance config");
}
for (let [key, value] of Object.entries(overrides)) {
if (!Object.hasOwnProperty.call(serverSettings, key)) {
@@ -599,12 +707,14 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " ");
* Generate the server-settings.json file from the example file in the
* data directory and override any settings configured in the instance's
* factorio_settings config entry.
*
* @param includeCredentials - Include Factorio username and token from host/controller config.
*/
async writeServerSettings() {
async writeServerSettings(includeCredentials: boolean) {
const warning = "Changes to this file will be overwitten by the factorio.settings config on the instance.";
const serverSettings = {
"_comment_warning": warning,
...await this.resolveServerSettings(this.config.get("factorio.settings")),
...await this.resolveServerSettings(this.config.get("factorio.settings"), includeCredentials),
};
await lib.safeOutputFile(
this.server.writePath("server-settings.json"),
@@ -615,14 +725,12 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " ");
/**
* Creates a new empty instance directory
*
* Creates the neccessary files for starting up a new instance into the
* provided instance directory.
* Ensures the neccessary files for starting up a new instance into the
* provided instance directory are present.
*
* @param instanceDir -
* Directory to create the new instance into.
* @param factorioDir - Path to factorio installation.
*/
static async create(instanceDir: string, factorioDir: string) {
static async populate_folders(instanceDir: string) {
await fs.ensureDir(path.join(instanceDir, "script-output"));
await fs.ensureDir(path.join(instanceDir, "saves"));
}
@@ -708,9 +816,10 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " ");
*/
async prepare() {
this.logger.verbose("Writing server-settings.json");
await this.writeServerSettings();
await this.writeServerSettings(true);
this._expectedUserUpdates = [];
if (this.config.get("factorio.sync_adminlist")) {
if (this.config.get("factorio.sync_adminlist") !== "disabled") {
this.logger.verbose("Writing server-adminlist.json");
lib.safeOutputFile(
this.server.writePath("server-adminlist.json"),
@@ -718,7 +827,7 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " ");
);
}
if (this.config.get("factorio.sync_banlist")) {
if (this.config.get("factorio.sync_banlist") !== "disabled") {
this.logger.verbose("Writing server-banlist.json");
lib.safeOutputFile(
this.server.writePath("server-banlist.json"),
@@ -728,7 +837,7 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " ");
);
}
if (this.config.get("factorio.sync_whitelist")) {
if (this.config.get("factorio.sync_whitelist") !== "disabled") {
this.logger.verbose("Writing server-whitelist.json");
lib.safeOutputFile(
this.server.writePath("server-whitelist.json"),
@@ -800,7 +909,7 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " ");
// Find stand alone modules to load
// XXX for now only the included clusterio module is loaded
let modulesDirectory = path.join(__dirname, "..", "..", "modules");
let modulesDirectory = path.join(__dirname, "..", "..", "..", "modules");
for (let entry of await fs.readdir(modulesDirectory, { withFileTypes: true })) {
if (entry.isDirectory()) {
if (modules.has(entry.name)) {
@@ -831,11 +940,12 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " ");
this._loadedSave = saveName;
await this.server.start(saveName);
if (this.config.get("factorio.enable_save_patching")) {
if (this.config.get("factorio.enable_save_patching") && this.config.get("factorio.enable_script_commands")) {
await this.server.disableAchievements();
await this.updateInstanceData();
this._watchPlayerPromote();
} else {
this._watchPlayerJoinsByChat();
this._watchServerLogActions();
}
await this.sendSaveListUpdate();
@@ -862,7 +972,7 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " ");
this.server.on("exit", () => this.notifyExit());
await this.server.startScenario(scenario, seed, mapGenSettings, mapSettings);
this._watchPlayerJoinsByChat();
this._watchServerLogActions();
await lib.invokeHook(this.plugins, "onStart");
@@ -878,8 +988,8 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " ");
}
async updateFactorioSettings(current: Record<string, unknown>, previous: Record<string, unknown>) {
current = await this.resolveServerSettings(current);
previous = await this.resolveServerSettings(previous);
current = await this.resolveServerSettings(current, false);
previous = await this.resolveServerSettings(previous, false);
for (let [key, action] of Object.entries(serverSettingsActions)) {
if (current[key] !== undefined && !util.isDeepStrictEqual(current[key], previous[key])) {
@@ -899,7 +1009,7 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " ");
await this.sendRcon("/whitelist disable");
}
if (this.config.get("factorio.sync_whitelist")) {
if (this.config.get("factorio.sync_whitelist") !== "disabled") {
await this.sendRcon("/whitelist clear");
for (let player of this._host.whitelist) {
await this.sendRcon(`/whitelist ${player}`);
@@ -911,33 +1021,82 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " ");
}
}
_recordUserUpdate(
action: "BAN" | "UNBANNED" | "PROMOTE" | "DEMOTE" | "WHITELISTED" | "UNWHITELISTED",
name: string,
reason: string = ""
) {
// TODO: Implement bidirectional whitelist sync, likely by watching the json file
const addr = lib.Address.fromShorthand("allInstances");
const expectedIndex = this._expectedUserUpdates.findIndex(
expected => expected.name === name && expected.action === action && expected.reason === reason
);
if (expectedIndex >= 0) {
this._expectedUserUpdates = this._expectedUserUpdates.splice(expectedIndex, 1);
} else if (action === "BAN") {
if (this.config.get("factorio.sync_banlist") !== "bidirectional") { return; }
this.sendTo(addr, new lib.InstanceBanlistUpdateEvent(name, true, reason ?? ""));
} else if (action === "UNBANNED") {
if (this.config.get("factorio.sync_banlist") !== "bidirectional") { return; }
this.sendTo(addr, new lib.InstanceBanlistUpdateEvent(name, false, ""));
} else if (action === "PROMOTE") {
if (this.config.get("factorio.sync_adminlist") !== "bidirectional") { return; }
this.sendTo(addr, new lib.InstanceAdminlistUpdateEvent(name, true));
} else if (action === "DEMOTE") {
if (this.config.get("factorio.sync_adminlist") !== "bidirectional") { return; }
this.sendTo(addr, new lib.InstanceAdminlistUpdateEvent(name, false));
} else if (action === "WHITELISTED") {
if (this.config.get("factorio.sync_whitelist") !== "bidirectional") { return; }
this.sendTo(addr, new lib.InstanceWhitelistUpdateEvent(name, true));
} else if (action === "UNWHITELISTED") {
if (this.config.get("factorio.sync_whitelist") !== "bidirectional") { return; }
this.sendTo(addr, new lib.InstanceWhitelistUpdateEvent(name, false));
} else {
throw new Error(`Unexpected Action: ${action} for ${name}`);
}
}
async handleInstanceAdminlistUpdateEvent(request: lib.InstanceAdminlistUpdateEvent) {
if (!this.config.get("factorio.sync_adminlist")) {
const { name, admin } = request;
const sync = this.config.get("factorio.sync_adminlist");
if (sync === "disabled") {
return;
} else if (sync === "bidirectional") {
this._expectedUserUpdates.push({action: admin ? "PROMOTE" : "DEMOTE", name: name, reason: "" });
}
let { name, admin } = request;
let command = admin ? `/promote ${name}` : `/demote ${name}`;
await this.sendRcon(command);
}
async handleInstanceBanlistUpdateEvent(request: lib.InstanceBanlistUpdateEvent) {
if (!this.config.get("factorio.sync_banlist")) {
const { name, banned, reason } = request;
const sync = this.config.get("factorio.sync_banlist");
if (sync === "disabled") {
return;
} else if (sync === "bidirectional") {
this._expectedUserUpdates.push({action: banned ? "BAN" : "UNBANNED", name: name, reason: reason});
}
let { name, banned, reason } = request;
let command = banned ? `/ban ${name} ${reason}` : `/unban ${name}`;
await this.sendRcon(command);
}
async handleInstanceWhitelistUpdateEvent(request: lib.InstanceWhitelistUpdateEvent) {
if (!this.config.get("factorio.sync_whitelist")) {
const { name, whitelisted } = request;
const sync = this.config.get("factorio.sync_whitelist");
if (this.config.get("factorio.sync_whitelist") === "disabled") {
return;
} else if (sync === "bidirectional") {
// Factorio does not contain log events for whitelist so these values currently have no special meaning
this._expectedUserUpdates.push({
action: whitelisted ? "WHITELISTED" : "UNWHITELISTED",
name: name,
reason: "",
});
}
let { name, whitelisted } = request;
let command = whitelisted ? `/whitelist add ${name}` : `/whiteliste remove ${name}`;
let command = whitelisted ? `/whitelist add ${name}` : `/whitelist remove ${name}`;
await this.sendRcon(command);
}
@@ -991,9 +1150,13 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " ");
let pid = this.server.pid;
if (pid) {
let stats = await pidusage(pid);
instanceFactorioCpuTime.labels(String(this.id)).set(stats.ctime / 1000);
instanceFactorioMemoryUsage.labels(String(this.id)).set(stats.memory);
try {
let stats = await getProcessStats(pid, this.logger);
instanceFactorioCpuTime.labels(String(this.id)).set(stats.ctime / 1000);
instanceFactorioMemoryUsage.labels(String(this.id)).set(stats.memory);
} catch (err) {
this.logger.warn("Failed to get process stats:", err);
}
}
return new lib.InstanceMetricsRequest.Response(results);
@@ -1002,21 +1165,26 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " ");
async handleInstanceStartRequest(request: lib.InstanceStartRequest) {
let saveName = request.save;
try {
await this.prepare();
saveName = await this.prepareSave(saveName);
} catch (err: any) {
this.logger.error(`Error preparing instance: ${err.message}`);
this.notifyExit();
await this.sendSaveListUpdate();
throw err;
}
try {
await this.prepare();
saveName = await this.prepareSave(saveName);
} catch (err: any) {
this.logger.error(`Error preparing instance: ${err.message}`);
this.notifyExit();
await this.sendSaveListUpdate();
throw err;
}
try {
await this.start(saveName);
} catch (err: any) {
this.logger.error(`Error starting ${saveName}: ${err.message}`);
await this.stop();
throw err;
try {
await this.start(saveName);
} catch (err: any) {
this.logger.error(`Error starting ${saveName}: ${err.message}`);
await this.stop();
throw err;
}
} finally {
this.logger.verbose("Wiping credentials from server-settings.json");
await this.writeServerSettings(false);
}
}
@@ -1027,21 +1195,26 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " ");
}
try {
await this.prepare();
} catch (err: any) {
this.logger.error(`Error preparing instance: ${err.message}`);
this.notifyExit();
await this.sendSaveListUpdate();
throw err;
}
try {
await this.prepare();
} catch (err: any) {
this.logger.error(`Error preparing instance: ${err.message}`);
this.notifyExit();
await this.sendSaveListUpdate();
throw err;
}
let { scenario, seed, mapGenSettings, mapSettings } = request;
try {
await this.startScenario(scenario, seed, mapGenSettings, mapSettings);
} catch (err: any) {
this.logger.error(`Error starting scenario ${scenario}: ${err.message}`);
await this.stop();
throw err;
let { scenario, seed, mapGenSettings, mapSettings } = request;
try {
await this.startScenario(scenario, seed, mapGenSettings, mapSettings);
} catch (err: any) {
this.logger.error(`Error starting scenario ${scenario}: ${err.message}`);
await this.stop();
throw err;
}
} finally {
this.logger.verbose("Wiping credentials from server-settings.json");
await this.writeServerSettings(false);
}
}
@@ -1053,7 +1226,7 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " ");
this.notifyStatus("creating_save");
try {
this.logger.verbose("Writing server-settings.json");
await this.writeServerSettings();
await this.writeServerSettings(false);
await this.syncMods();
} catch (err: any) {
@@ -1081,7 +1254,7 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " ");
this.notifyStatus("exporting_data");
try {
this.logger.verbose("Writing server-settings.json");
await this.writeServerSettings();
await this.writeServerSettings(false);
this.logger.info("Exporting data .....");
await this.syncMods();
+39 -25
View File
@@ -5,7 +5,7 @@ import Jimp from "jimp";
import JSZip from "jszip";
import * as lib from "@clusterio/lib";
import * as libBuildMod from "@clusterio/lib/dist/build_mod";
import * as libBuildMod from "@clusterio/lib/dist/node/build_mod";
import type { FactorioServer } from "./server";
interface Prototype {
@@ -15,21 +15,20 @@ interface Prototype {
}
interface SimpleIconSpecification {
icon: string;
icon_size: number;
icon_size?: number;
icon_mipmaps?: number;
}
type Color = { r: number, g: number, b: number, a: number };
interface IconLayer {
icon: string;
icon_size: number;
tint?: Color,
icon_size?: number;
tint?: lib.FactorioColor,
shift?: [number, number],
scale?: number,
icon_mipmaps?: number,
}
interface LayeredIconSpecification {
icons: IconLayer[];
icon_size: number;
icon_size?: number;
icon_mipmaps?: number;
}
type IconSpecification = LayeredIconSpecification | SimpleIconSpecification;
@@ -62,10 +61,10 @@ async function generateExportMod(server: FactorioServer) {
clean: false,
build: true,
pack: true,
sourceDir: path.join(__dirname, "..", "..", "lua", "export"),
sourceDir: path.join(__dirname, "..", "..", "..", "lua", "export"),
outputDir: server.writePath("mods"),
bumpPatch: false,
factorioVersion: server.version.replace(/\.\d+$/, ""),
factorioVersion: server.version?.replace(/\.\d+$/, ""),
dependencies,
});
}
@@ -104,7 +103,8 @@ async function loadFile(server: FactorioServer, modVersions: Map<string, string>
let [, mod, filePath] = match;
if (["core", "base"].includes(mod)) {
const builtinModNames = ["core", ...lib.ModPack.getBuiltinModNames(server.version!)];
if (builtinModNames.includes(mod)) {
try {
return await fs.readFile(server.dataPath(mod, filePath));
} catch (err: any) {
@@ -165,9 +165,9 @@ async function loadSimpleIcon(
size: number,
iconCache: IconCache,
) {
let icon = await loadIcon(server, modVersions, item.icon, item.icon_size, 0, iconCache);
let icon = await loadIcon(server, modVersions, item.icon, item.icon_size ?? 64, 0, iconCache);
if (icon) {
let iconScale = size / item.icon_size;
let iconScale = size / (item.icon_size ?? 64);
if (iconScale !== 1) {
icon = icon.clone();
icon.scale(iconScale);
@@ -183,7 +183,7 @@ async function loadLayeredIcon(
size: number,
iconCache: IconCache,
) {
let baseLayerSize = item.icons[0].icon_size || item.icon_size;
let baseLayerSize = (item.icons[0].icon_size || item.icon_size) ?? 64;
let icon = await Jimp.create(size, size);
// The scaling factor of the base layer
@@ -193,7 +193,7 @@ async function loadLayeredIcon(
let baseUnit = size / (baseLayerSize * baseLayerScale);
for (let layer of item.icons) {
let layerSize = layer.icon_size || item.icon_size;
let layerSize = (layer.icon_size || item.icon_size) ?? 64;
let iconLayer = await loadIcon(server, modVersions, layer.icon, layerSize, layer.icon_mipmaps || 0, iconCache);
if (!iconLayer) {
@@ -202,15 +202,9 @@ async function loadLayeredIcon(
iconLayer = iconLayer.clone();
let tint: Color;
let tint: lib.Color;
if (layer.tint) {
let divisor = (layer.tint.r > 1 || layer.tint.g > 1 || layer.tint.b > 1) ? 255 : 1;
tint = {
r: (layer.tint.r || 0) / divisor,
g: (layer.tint.g || 0) / divisor,
b: (layer.tint.b || 0) / divisor,
a: layer.tint.a || 1,
};
tint = lib.normalizeColor(layer.tint);
} else {
tint = { r: 1, b: 1, g: 1, a: 1 };
}
@@ -252,6 +246,18 @@ async function loadLayeredIcon(
return icon;
}
function fixIcons(item: ItemPrototype) {
const icons = item.icons;
if (typeof icons === "object" && !(icons instanceof Array) && icons !== null) {
// It's possible to specify icons as an object with arbitrary keys
// and the game will still accept it, cast the value to array if
// this is the case.
item = { ...item };
item.icons = Object.values(icons);
}
return item;
}
const itemTypes = new Set([
"item",
"ammo",
@@ -284,7 +290,8 @@ const itemTypes = new Set([
function filterItems(prototypes: Prototypes): ItemPrototype[] {
return Object.entries(prototypes)
.filter(([type, _]) => itemTypes.has(type))
.flatMap(([_, typePrototypes]) => Object.values(typePrototypes)) as ItemPrototype[]
.flatMap(([_, typePrototypes]) => Object.values(typePrototypes) as ItemPrototype[])
.map(fixIcons)
;
}
@@ -390,11 +397,18 @@ async function exportLocale(
}
}
let baseLocaleFilePath = server.dataPath("base", "locale", languageCode, "base.cfg");
mergeLocale(lib.parse(await fs.readFile(baseLocaleFilePath, "utf8")));
const builtinModNames = [
// Filter if on builtin mods so "core" does not need to be specified in modOrder
"core", ...lib.ModPack.getBuiltinModNames(server.version!).filter(mod => modOrder.includes(mod)),
];
for (const builtinModName of builtinModNames) {
const localeFilePath = server.dataPath(builtinModName, "locale", languageCode, `${builtinModName}.cfg`);
mergeLocale(lib.parse(await fs.readFile(localeFilePath, "utf8")));
}
const builtinModNamesExport = ["export", ...builtinModNames];
for (let mod of modOrder) {
if (["core", "base", "export"].includes(mod)) {
if (builtinModNamesExport.includes(mod)) {
continue;
}
+23 -11
View File
@@ -77,9 +77,16 @@ export class SaveModule {
static async fromSave(json: Static<typeof SaveModule.jsonSchema>, root: JSZip) {
const module = new this(lib.ModuleInfo.fromJSON(json));
module.files = new Map(
await Promise.all(json.files.map(async f => [f, await root.file(f)!.async("nodebuffer")] as const))
);
module.files = new Map(await Promise.all(json.files
.map(filename => ({filename, file: root.file(filename)}))
.filter(({filename, file}) => {
if (file === null) {
lib.logger.warn(`Missing file ${filename} in save`);
}
return file !== null;
})
.map(async ({filename, file}) => [filename, await file!.async("nodebuffer")] as const)
));
return module;
}
@@ -195,12 +202,6 @@ export class PatchInfo {
}
}
const knownScenarios: Record<string, lib.ModuleInfo> = {
// First seen in 0.17.63
"4e866186ebe297f1038fd325b09df1a1f5e2fdd1": new lib.ModuleInfo("freeplay", "0.17.63", ["freeplay", "silo-script"]),
};
/**
* Generates control.lua code for loading the Clusterio modules
*
@@ -342,6 +343,13 @@ function reorderDependencies(modules: SaveModule[]) {
}
}
const knownScenarios: Record<string, lib.ModuleInfo> = {
// First seen in 0.17.63
"4e866186ebe297f1038fd325b09df1a1f5e2fdd1": new lib.ModuleInfo("freeplay", "0.17.63", [], ["scenario"]),
// First seen in 2.0
"bcbdde18ce4ec16ebfd93bd694cd9b12ef969c9a": new lib.ModuleInfo("freeplay", "2.0.0", [], ["scenario"]),
};
/**
* Patch a save with the given modules
*
@@ -380,6 +388,7 @@ export async function patch(savePath: string, modules: SaveModule[]) {
if (controlHash in knownScenarios) {
patchInfo = new PatchInfo(0, knownScenarios[controlHash], []);
root.file("scenario.lua", controlFile.nodeStream("nodebuffer"));
} else {
throw new Error(`Unable to patch save, unknown scenario (${controlHash})`);
}
@@ -395,8 +404,11 @@ export async function patch(savePath: string, modules: SaveModule[]) {
}
} else {
for (let module of patchInfo.modules) {
for (let file of module.files.keys()) {
zip.remove(root.file(file)!.name);
for (let filepath of module.files.keys()) {
const file = root.file(filepath);
if (file !== null) {
zip.remove(file.name);
}
}
}
patchInfo.modules = [];
+262 -167
View File
@@ -351,20 +351,37 @@ const outputHeuristics: Heuristic[] = [
},
},
// Message indicating the server is shutting down
// Last messages before the server hangs during shutdown waiting for console input on Windows.
{
filter: {
type: "generic",
message: /^Quitting: /,
message: "Quitting: multiplayer error.",
},
action: function(parsed) {
this.emit("_quitting");
// On windows the Factorio server will wait for input on the console input buffer
// when an error occurs. Since we can't send input there we terminate the server
// before shutting down. Since we can't send input there we terminate the server
// process when this happens.
if (process.platform === "win32" && parsed.message === "Quitting: multiplayer error.") {
if (process.platform === "win32") {
this._server!.kill();
// Notify that the server is being killed after the last message
// is logged so that it doesn't look like it was killed unnecessarily.
process.nextTick(() => {
this._logger.info("Killing Factorio process to avoid it hanging forever.");
});
}
},
},
{
filter: {
type: "log",
message: /^updateTick\(\d+\) changing state from\(Disconnected\) to\(Closed\)$/,
},
action: function() {
if (process.platform === "win32") {
this._server!.kill();
process.nextTick(() => {
this._logger.info("Killing Factorio process to avoid it hanging forever.");
});
}
},
},
@@ -407,6 +424,8 @@ export interface FactorioServerOptions {
* the latest version found in `factorioDir`.
*/
version?: string,
/** Path to executable to invoke when starting the server */
executablePath?: string;
/** UDP port to host game on. */
gamePort?: number,
/** TCP port to use for RCON. */
@@ -426,8 +445,11 @@ export interface FactorioServerOptions {
* connection.
*/
maxConcurrentCommands?: number
/** Timeout in ms to wait for a response to a shutdown command. */
hangTimeoutMs?: number,
/**
* Timeout in ms to wait after a shutdown is requested before killing the
* process. Defaults to 0 meaning no timeout
*/
shutdownTimeoutMs?: number,
}
/**
@@ -450,6 +472,8 @@ export interface FactorioServerOptions {
* @extends events.EventEmitter
*/
export class FactorioServer extends events.EventEmitter {
/** Path to executable to invoke when starting the server */
executablePath?: string;
/** UDP port used for hosting the Factorio game server on */
gamePort: number;
/** TCP port used for RCON on the Factorio game server */
@@ -462,8 +486,8 @@ export class FactorioServer extends events.EventEmitter {
enableAuthserverBans: boolean;
/** Enable verbose logging */
verboseLogging: boolean;
/** Timeout in ms to wait for a response to a shutdown command */
hangTimeoutMs = 5000;
_shutdownTimeoutMs = 0;
_stopTimeoutId?: ReturnType<typeof setTimeout>;
_factorioDir: string;
_writeDir: string;
@@ -504,6 +528,7 @@ export class FactorioServer extends events.EventEmitter {
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 */
@@ -518,8 +543,9 @@ export class FactorioServer extends events.EventEmitter {
this.verboseLogging = options.verboseLogging || false;
/** Maximum number of RCON commands transmitted in parallel on the RCON connection */
this.maxConcurrentCommands = options.maxConcurrentCommands || 5;
/** Timeout in ms to wait for a response to a shutdown command */
this.hangTimeoutMs = options.hangTimeoutMs ?? 5000;
if (options.shutdownTimeoutMs !== undefined) {
this.shutdownTimeoutMs = options.shutdownTimeoutMs;
}
if (options.stripPaths) {
let charSet = new Set(path.resolve(this.writePath("temp")));
@@ -562,6 +588,13 @@ export class FactorioServer extends events.EventEmitter {
}
}
// TODO at the moment this is just a helper function, it has the potential for schema checking
handle<Event>(eventName: string, handler: (event: Event) => Promise<void>) {
this.on(`ipc-${eventName}`, (event) => handler(event).catch((err: Error) => {
this._logger.error(`Error handling ipc event:\n${err.stack ?? err.message}`);
}));
}
async _handleIpc(line: Buffer) {
let channelEnd = line.indexOf("?");
if (channelEnd === -1) {
@@ -654,10 +687,6 @@ export class FactorioServer extends events.EventEmitter {
}
async _startRcon() {
if (this._state === "stopping") {
return;
}
if (this._rconClient !== null) {
throw Error("RCON client is already started");
}
@@ -671,16 +700,39 @@ export class FactorioServer extends events.EventEmitter {
};
this._rconClient = new Rcon(config);
this._rconClient.on("error", () => { /* Ignore */ });
this._rconClient.on("authenticated", () => { this._rconReady = true; this.emit("rcon-ready"); });
this._rconClient.on("error", () => {
// Errors before being authenticated might not emit end
this._rconClient = null;
if (!this._rconReady) {
this._rconReady = true;
this.emit("rcon-ready");
}
});
this._rconClient.on("authenticated", () => {
this._rconReady = true;
this.emit("rcon-ready");
});
this._rconClient.on("end", () => {
this._rconClient = null;
if (!this._rconReady) {
this._rconReady = true;
this.emit("rcon-ready");
}
});
// XXX: Workaround to suppress bogus event listener warning
if (this._rconClient.emitter instanceof events.EventEmitter) {
this._rconClient.emitter.setMaxListeners(this.maxConcurrentCommands + 5);
}
await this._rconClient.connect();
try {
await this._rconClient.connect();
} catch (err: any) {
this._logger.error(`Failed to start RCON connection:\n${err?.stack ?? err?.message ?? err}`);
this._rconClient = null;
if (!this._rconReady) {
this._rconReady = true;
this.emit("rcon-ready");
}
}
}
/** Maximum number of RCON commands transmitted in parallel on the RCON connection */
@@ -718,12 +770,12 @@ export class FactorioServer extends events.EventEmitter {
}
/**
* The version of Factorio in use. This will be the actual version if
* "latest" (the default) was specified as `factorioVersion` to the
* constructor.
* 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.
*/
get version() {
return this._version!;
get version(): string | undefined {
return this._version ?? undefined;
}
/**
@@ -752,6 +804,7 @@ export class FactorioServer extends events.EventEmitter {
this._rconReady = false;
this._gameReady = false;
this._unexpected = [];
this._killed = false;
this._runningAutosave = null;
}
@@ -786,6 +839,9 @@ export class FactorioServer extends events.EventEmitter {
`\n- ${this._unexpected.join("\n- ")}`
));
}
} else { // if state === "stopping"
this._clearStopTimeout();
}
if (this._rconClient) {
@@ -840,28 +896,36 @@ export class FactorioServer extends events.EventEmitter {
this._check(["init"]);
this._state = "create";
await this._writeConfigIni();
await this._writeMapSettings(mapGenSettings, mapSettings);
this._server = child_process.spawn(
this.binaryPath(),
[
"--config", this.writePath("config.ini"),
"--create", this.writePath("saves", name),
...(seed !== undefined ? ["--map-gen-seed", String(seed)] : []),
...(mapGenSettings !== undefined
? ["--map-gen-settings", this.writePath("map-gen-settings.json")] : []
),
...(mapSettings !== undefined ? ["--map-settings", this.writePath("map-settings.json")] : []),
...(this.verboseLogging ? ["--verbose"] : []),
],
{
detached: true,
stdio: "pipe",
}
);
this._attachStdio();
this._server.once("exit", () => this.emit("exit"));
try {
// Everything between _state and once exit must be sync and call _resetState on error
// If this this thread yields then the server will be left in an invalid transient state
/* eslint-disable node/no-sync */
this._writeConfigIniSync();
this._writeMapSettingsSync(mapGenSettings, mapSettings);
this._server = child_process.spawn(
this.binaryPath(),
[
"--config", this.writePath("config.ini"),
"--create", this.writePath("saves", name),
...(seed !== undefined ? ["--map-gen-seed", String(seed)] : []),
...(mapGenSettings !== undefined
? ["--map-gen-settings", this.writePath("map-gen-settings.json")] : []
),
...(mapSettings !== undefined ? ["--map-settings", this.writePath("map-settings.json")] : []),
...(this.verboseLogging ? ["--verbose"] : []),
],
{
detached: true,
stdio: "pipe",
}
);
this._attachStdio();
this._server.once("exit", () => this.emit("exit"));
/* eslint-enable node/no-sync */
} catch (err: any) {
this._resetState();
throw new lib.EnvironmentError(`Unexpected error:\n${err.stack}`);
}
try {
let [code, signal] = await events.once(this._server, "exit");
@@ -893,27 +957,36 @@ export class FactorioServer extends events.EventEmitter {
this._check(["init"]);
this._state = "running";
await this._writeConfigIni();
this._server = child_process.spawn(
this.binaryPath(),
[
"--config", this.writePath("config.ini"),
"--start-server", this.writePath("saves", save),
"--port", String(this.gamePort),
"--rcon-port", String(this.rconPort),
"--rcon-password", this.rconPassword,
...(this.enableWhitelist ? ["--use-server-whitelist"] : []),
...(this.enableAuthserverBans ? ["--use-authserver-bans"] : []),
...(this.verboseLogging ? ["--verbose"] : []),
],
{
detached: true,
stdio: "pipe",
}
);
try {
// Everything between _state and _watchExist must be sync and call _resetState on error
// If this this thread yields then the server will be left in an invalid transient state
/* eslint-disable node/no-sync */
this._writeConfigIniSync();
this._server = child_process.spawn(
this.binaryPath(),
[
"--config", this.writePath("config.ini"),
"--start-server", this.writePath("saves", save),
"--port", String(this.gamePort),
"--rcon-port", String(this.rconPort),
"--rcon-password", this.rconPassword,
...(this.enableWhitelist ? ["--use-server-whitelist"] : []),
...(this.enableAuthserverBans ? ["--use-authserver-bans"] : []),
...(this.verboseLogging ? ["--verbose"] : []),
],
{
detached: true,
stdio: "pipe",
}
);
this._attachStdio();
this._watchExit();
/* eslint-enable node/no-sync */
} catch (err: any) {
this.emit("error", new lib.EnvironmentError(`Unexpected error:\n${err.stack}`));
this._resetState();
}
this._attachStdio();
this._watchExit();
await this._waitForReady();
}
@@ -934,33 +1007,42 @@ export class FactorioServer extends events.EventEmitter {
this._check(["init"]);
this._state = "running";
await this._writeConfigIni();
await this._writeMapSettings(mapGenSettings, mapSettings);
this._server = child_process.spawn(
this.binaryPath(),
[
"--config", this.writePath("config.ini"),
"--start-server-load-scenario", scenario,
...(seed !== undefined ? ["--map-gen-seed", String(seed)] : []),
...(mapGenSettings !== undefined
? ["--map-gen-settings", this.writePath("map-gen-settings.json")] : []
),
...(mapSettings !== undefined ? ["--map-settings", this.writePath("map-settings.json")] : []),
"--port", String(this.gamePort),
"--rcon-port", String(this.rconPort),
"--rcon-password", this.rconPassword,
...(this.enableWhitelist ? ["--use-server-whitelist"] : []),
...(this.enableAuthserverBans ? ["--use-authserver-bans"] : []),
...(this.verboseLogging ? ["--verbose"] : []),
],
{
detached: true,
stdio: "pipe",
}
);
try {
// Everything between _state and _watchExist must be sync and call _resetState on error
// If this this thread yields then the server will be left in an invalid transient state
/* eslint-disable node/no-sync */
this._writeConfigIniSync();
this._writeMapSettingsSync(mapGenSettings, mapSettings);
this._server = child_process.spawn(
this.binaryPath(),
[
"--config", this.writePath("config.ini"),
"--start-server-load-scenario", scenario,
...(seed !== undefined ? ["--map-gen-seed", String(seed)] : []),
...(mapGenSettings !== undefined
? ["--map-gen-settings", this.writePath("map-gen-settings.json")] : []
),
...(mapSettings !== undefined ? ["--map-settings", this.writePath("map-settings.json")] : []),
"--port", String(this.gamePort),
"--rcon-port", String(this.rconPort),
"--rcon-password", this.rconPassword,
...(this.enableWhitelist ? ["--use-server-whitelist"] : []),
...(this.enableAuthserverBans ? ["--use-authserver-bans"] : []),
...(this.verboseLogging ? ["--verbose"] : []),
],
{
detached: true,
stdio: "pipe",
}
);
this._attachStdio();
this._watchExit();
/* eslint-enable node/no-sync */
} catch (err: any) {
this.emit("error", new lib.EnvironmentError(`Unexpected error:\n${err.stack}`));
this._resetState();
}
this._attachStdio();
this._watchExit();
await this._waitForReady();
}
@@ -993,6 +1075,44 @@ export class FactorioServer extends events.EventEmitter {
return response;
}
/**
* Timeout in ms to wait after a shutdown is requested before killing the
* process. Defaults to 0 meaning no timeout
*/
get shutdownTimeoutMs() {
return this._shutdownTimeoutMs;
}
set shutdownTimeoutMs(newTimeoutMs: number) {
this._shutdownTimeoutMs = newTimeoutMs;
if (this._state === "stopping") {
this._clearStopTimeout();
this._setStopTimeout();
}
}
_setStopTimeout() {
if (this._shutdownTimeoutMs !== 0) {
this._stopTimeoutId = setTimeout(
this.onStopTimeout.bind(this),
this.shutdownTimeoutMs
);
}
}
_clearStopTimeout() {
clearTimeout(this._stopTimeoutId);
this._stopTimeoutId = undefined;
}
onStopTimeout() {
this._logger.error("Factorio appears to have hanged, killing process");
if (this._rconClient) {
this._rconClient.end().catch(() => {});
}
this._server!.kill("SIGKILL");
}
/**
* Stop the server
*
@@ -1001,88 +1121,54 @@ export class FactorioServer extends events.EventEmitter {
*/
async stop() {
this._check(["running"]);
let hanged = true;
let stopped = false;
function setAlive() { hanged = false; }
let timeoutId = setTimeout(() => {
if (hanged) {
this._logger.error("Factorio appears to have hanged, sending SIGKILL");
if (this._rconClient) {
this._rconClient.end().catch(() => {});
}
this._server!.kill("SIGKILL");
}
}, this.hangTimeoutMs);
// It's possible the server decides to exit on its own, in which
// case the stop operation has to be cancelled.
this._server!.once("exit", () => {
stopped = true;
clearTimeout(timeoutId);
});
this._state = "stopping";
// On Windows we need to save the map as there's no graceful shutdown.
if (this._rconClient && process.platform === "win32") {
// Not using events.once here as that will reject on error.
let saved = new Promise(resolve => this.once("_saved", resolve));
this.on("_saving", setAlive);
try {
await this.sendRcon("/server-save");
await saved;
// It's possible the server doesn't stop if it's stuck in an infinite
// loop or overloaded, kill it if the shutdown takes too long.
this._setStopTimeout();
} catch (err) {
// Ignore
}
clearTimeout(timeoutId);
this.off("_saving", setAlive);
}
if (this._rconClient) {
// If RCON is not yet fully connected that operation needs to
// complete before the RCON connection can be closed cleanly.
if (!this._rconReady) {
await events.once(this, "rcon-ready");
}
await this._rconClient.end();
// If RCON is not yet fully connected that operation needs to
// complete before the RCON connection can be used
if (!this._rconReady) {
// Not using events.once here to avoid throwing on error events.
await new Promise(resolve => this.once("rcon-ready", resolve));
}
// The Factorio server may have decided to get ahead of us and
// stop by itself before we had the chance to signal it.
if (stopped) {
if (this._state !== "stopping") {
return;
}
// On linux this sends SIGTERM to the process. On windows this
// will terminate the process with no cleanup. You can send your
// complaint for the lack of graceful shutdown to the Factorio
// developers. Rcon does not recognize /quit, stdin is not
// recognized, and there's no "send CTRL+C to process" on Windows.
this._server!.kill();
const waitForExit = events.once(this._server!, "exit");
waitForExit.catch(() => {}); // Prevent unhandled promise rejection
// There appears to be an race condition where sending SIGTERM
// immediatly before the RCON interface comes online causes the
// Factorio server to hang. It's also possible the server doesn't
// stop if it's stuck with an infinite lua code loop. In either
// case there's no recovering from it.
if (process.platform !== "win32") {
this.on("_quitting", setAlive);
await events.once(this._server!, "exit");
clearTimeout(timeoutId);
this.off("_quitting", setAlive);
// On windows the process is terminated immediately, but to keep
// ordering wait until after the exit event here.
// Stop the server
if (this._rconClient) {
// Use RCON by default to stop the the server as it's available on
// all platforms and simplifies the code.
try {
await this.sendRcon("/quit");
} catch (err: any) {
// Ignore RCON connection being closed while sending the quit command.
if (err.message !== "Connection closed") {
throw err;
}
}
} else {
await events.once(this._server!, "exit");
// No rcon connection is available, fallback to signal. On linux
// this sends SIGTERM to the process. On windows this
// will terminate the process without saving. You can send your
// complaint for the lack of graceful shutdown options to the
// Factorio developers. I haven't found any way to make stdin work
// and there's no "send CTRL+C to process" API on Windows.
if (process.platform === "win32") {
this._logger.warn("No RCON connection, falling back to killing the server.");
}
this._server!.kill();
}
await waitForExit;
}
/**
@@ -1148,11 +1234,15 @@ export class FactorioServer extends events.EventEmitter {
/**
* Get Factorio binary path
*
* Get the path to the factorio binary depending on the platform (MacOS support)
* Get the path to the factorio binary depending on the configuration
* and or the platform (MacOS support)
*
* @returns Path to factorio binary
*/
binaryPath() {
if (this.executablePath) {
return this.dataPath("..", this.executablePath);
}
if (process.platform === "darwin") {
return this.dataPath("..", "MacOS", "factorio");
}
@@ -1183,24 +1273,29 @@ export class FactorioServer extends events.EventEmitter {
return JSON.parse(await fs.readFile(this.dataPath("server-settings.example.json"), "utf-8"));
}
async _writeConfigIni() {
_writeConfigIniSync() {
let content = lib.stringify({
path: {
"read-data": this.dataPath(),
"write-data": this.writePath(),
},
});
await lib.safeOutputFile(this.writePath("config.ini"), content);
// Must be sync to allow process spawn without awaiting
// eslint-disable-next-line node/no-sync
fs.writeFileSync(this.writePath("config.ini"), content);
}
async _writeMapSettings(mapGenSettings?: object, mapSettings?: object) {
_writeMapSettingsSync(mapGenSettings?: object, mapSettings?: object) {
// Must be sync to allow process spawn without awaiting
if (mapGenSettings) {
await lib.safeOutputFile(
// eslint-disable-next-line node/no-sync
fs.writeFileSync(
this.writePath("map-gen-settings.json"), JSON.stringify(mapGenSettings, null, "\t")
);
}
if (mapSettings) {
await lib.safeOutputFile(
// eslint-disable-next-line node/no-sync
fs.writeFileSync(
this.writePath("map-settings.json"), JSON.stringify(mapSettings, null, "\t")
);
}
+2 -6
View File
@@ -1,10 +1,6 @@
{
"extends": "../../tsconfig.node.json",
"files": [],
"references": [
{ "path": "../lib" },
{ "path": "./tsconfig.node.json" },
],
"include": ["index.ts", "host.ts", "package.json", "src/**/*.ts", "src/**/*.js"],
"compilerOptions": {
"outDir": "dist",
},
}
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.node.json",
"references": [
{ "path": "../lib/tsconfig.node.json" },
],
"include": ["index.ts", "host.ts", "package.json", "src/**/*.ts", "src/**/*.js"],
}
+1 -1
View File
@@ -1 +1 @@
dist/web/build
dist/browser
+1
View File
@@ -6,6 +6,7 @@ export * from "./src/factorio/exchange_string";
export * from "./src/helpers";
export * from "./src/ini";
export * from "./src/link";
export * from "./src/lua_tools";
export * from "./src/logging";
export * from "./src/permissions";
export * from "./src/plugin";
+2
View File
@@ -28,6 +28,8 @@ export * from "./src/stream";
export * from "./src/system_collectors";
export * from "./src/zip_ops";
export * from "./src/subscriptions";
export * from "./src/datastore";
export * from "./src/load_plugin_list";
export { default as ExponentialBackoff } from "./src/ExponentialBackoff";
export { default as ModStore } from "./src/ModStore";
+6 -5
View File
@@ -1,16 +1,17 @@
{
"name": "@clusterio/lib",
"description": "Shared library for Clusterio",
"version": "2.0.0-alpha.16",
"version": "2.0.0-alpha.19",
"repository": "https://github.com/clusterio/clusterio",
"license": "MIT",
"scripts": {
"prepare": "tsc --build && pnpm run compile_validator",
"compile_validator": "node scripts/compile_validator.js"
},
"main": "dist/index.js",
"main": "dist/node/index.js",
"browser": "browser.ts",
"keywords": [
"clusterio",
"factorio"
],
"engines": {
@@ -26,8 +27,8 @@
"klaw": "^4.1.0",
"set-blocking": "^2.0.0",
"triple-beam": "^1.4.1",
"winston": "^3.10.0",
"winston-transport": "^4.5.0",
"winston": "^3.17.0",
"winston-transport": "^4.9.0",
"ws": "^8.13.0",
"yargs": "^17.7.2"
},
@@ -41,6 +42,6 @@
"@types/triple-beam": "^1.3.2",
"@types/ws": "^8.5.5",
"@types/yargs": "^17.0.24",
"typescript": "^5.1.6"
"typescript": "^5.5.3"
}
}
+3 -3
View File
@@ -7,9 +7,9 @@ const standaloneCode = require("ajv/dist/standalone").default;
/* eslint-disable node/no-sync */
// Provide an empty file for message_core to load
fs.outputFileSync(path.join(__dirname, "../dist/src/data/message_validate.js"), "");
fs.outputFileSync(path.join(__dirname, "../dist/node/src/data/message_validate.js"), "");
const { Message } = require("../dist/src/data/messages_core");
const { Message } = require("../dist/node/src/data/messages_core");
const ajv = new Ajv({
allowUnionTypes: true,
@@ -28,5 +28,5 @@ function write(output) {
`// generated by scripts/compile_validator.js\n// @ts-nocheck\n/* eslint-disable */\n${moduleCode}`
);
}
write("../dist/src/data/message_validate.js");
write("../dist/node/src/data/message_validate.js");
write("../src/data/message_validate.js");
+36
View File
@@ -1,8 +1,10 @@
import fs from "fs-extra";
import path from "path";
import { Static } from "@sinclair/typebox";
import { logger } from "./logging";
import { ModInfo } from "./data";
import TypedEventEmitter from "./TypedEventEmitter";
import { safeOutputFile } from "./file_ops";
export interface ModStoreEvents {
/** A stored mod was created, updated or deleted */
@@ -74,6 +76,21 @@ export default class ModStore extends TypedEventEmitter<keyof ModStoreEvents, Mo
}
static async fromDirectory(modsDirectory: string) {
type ModInfoCache = Static<typeof ModInfo.jsonSchema>[];
const cacheFilename = path.join(modsDirectory, "mod-info-cache.json");
let cache = new Map<string, ModInfo>();
try {
const content = await fs.readFile(cacheFilename, "utf8");
cache = new Map((JSON.parse(content) as ModInfoCache).map(item => {
const mod = ModInfo.fromJSON(item);
return [mod.filename, mod];
}));
} catch (err: any) {
if (err.code !== "ENOENT") {
logger.warn(`Error loading ${cacheFilename}:\n${err.stack}`);
}
}
const files = new Map<string, ModInfo>();
for (let entry of await fs.readdir(modsDirectory, { withFileTypes: true })) {
if (entry.isDirectory()) {
@@ -81,10 +98,23 @@ export default class ModStore extends TypedEventEmitter<keyof ModStoreEvents, Mo
continue;
}
if (!entry.name.toLowerCase().endsWith(".zip")) {
continue;
}
if (entry.name.toLowerCase().endsWith(".tmp.zip")) {
continue;
}
const cachedModInfo = cache.get(entry.name);
if (cachedModInfo) {
const stat = await fs.stat(path.join(modsDirectory, entry.name));
if (cachedModInfo.mtimeMs === stat.mtimeMs) {
files.set(cachedModInfo.filename, cachedModInfo);
continue;
}
}
logger.info(`Loading info for Mod ${entry.name}`);
let modInfo: ModInfo;
try {
@@ -96,6 +126,12 @@ export default class ModStore extends TypedEventEmitter<keyof ModStoreEvents, Mo
files.set(modInfo.filename, modInfo);
}
if (files.size) {
logger.info(`Loaded info for ${files.size} mods`);
const content: ModInfoCache = [...files.values()].map(mod => mod.toJSON());
await safeOutputFile(cacheFilename, JSON.stringify(content));
}
return new this(modsDirectory, files);
}
}
+106 -32
View File
@@ -7,7 +7,7 @@ import { basicType } from "../helpers";
import * as libSchema from "../schema";
import { StringEnum } from "../data/composites";
const ConfigLocation = StringEnum(["controller", "host", "instance", "control"]);
const ConfigLocation = StringEnum(["controller", "host", "control"]);
export type ConfigLocation = Static<typeof ConfigLocation>;
/**
@@ -77,6 +77,12 @@ export interface FieldDefinition {
* Text used to describe the config field in user interfaces.
*/
description?: string;
/**
* Value to use for the autocomplete attribute in the Web UI's input element.
* Useful for making browsers behave properly with credential inputs.
* Only used for string and number fields.
*/
autoComplete?: string;
/**
* Web UI component to use for rending this input. Can be extended by
* @{link "@clusterio/web_ui".BaseWebPlugin.inputComponents}
@@ -111,6 +117,22 @@ export interface FieldDefinition {
* modified from.
*/
access?: ConfigLocation[];
/**
* If present treat the config value as a credential.
* Only the locations specified will be able to read the current value.
* Access can be used to specify which locations are able to overwrite the value.
*/
credential?: ConfigLocation[];
/**
* If present treat the config value as readonly.
* Only the locations specified will be able to update the config value.
* Access can be used to specify which locations are able to read the value.
*/
readonly?: ConfigLocation[];
/**
* True if graphical interfaces should hide the config value
*/
hidden?: boolean;
}
export type ConfigDefs<Fields> = {
@@ -131,6 +153,12 @@ export type ConfigEvents = {
fieldChanged: (name: string, curr: FieldValue, prev: FieldValue) => void;
}
export enum ConfigAccess {
read = 0x1,
write = 0x2,
readWrite = 0x3,
}
/**
* Collection of config entries
*
@@ -159,6 +187,16 @@ export class Config<
declare static fieldDefinitions: ConfigDefs<any>;
declare ["constructor"]: typeof Config;
/**
* Handle migration between clusterio versions
*
* @param config - Input config read and validated from file
* @returns Output config to be loaded into the class
* @protected
*/
static migrations(config: Static<typeof this.jsonSchema>) {
return config;
}
fields: Fields;
_unknownFields: Record<string, FieldValue> = {};
@@ -188,14 +226,14 @@ export class Config<
Object.entries(
this.constructor.fieldDefinitions
).filter(
([name, def]) => this._checkAccess(name, def, location, false)
([name, def]) => this._checkAccess(name, def, location, ConfigAccess.read, false)
).map(
([name, def]) => [name, defaultValue(def)]
)
) as Fields;
if (fields) {
this.update(fields, false, location);
this.update(this.constructor.migrations(fields), false, location);
}
}
@@ -204,21 +242,31 @@ export class Config<
*
* @param name - Name of the field to check.
* @param def - definition for for the field to check.
* @param remote - location of remote access to check for.
* @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}
* if access is not granted and error is true.
* @returns true if access is granted
* @private
*/
_checkAccess(name: string, def: FieldDefinition, remote: ConfigLocation, error: boolean) {
for (let loc of [remote, this.location]) {
if (!(def.access ?? this.defaultAccess).includes(loc)) {
if (error) {
throw new InvalidAccess(`Field '${name}' is not accessible from ${loc}`);
} else {
return false;
}
_checkAccess(name: string, def: FieldDefinition, location: ConfigLocation, mode: ConfigAccess, error: boolean) {
// If credential array is present it acts as read permission and the access array as write permission,
// If readonly array is present it acts as write permission and the access array as the read permission.
// If neither is present access array acts as a combined read/write permission check.
if (
def.credential && mode & ConfigAccess.read && !(def.credential).includes(location)
|| def.readonly && mode & ConfigAccess.write && !(def.readonly).includes(location)
|| (
(!def.credential || mode & ConfigAccess.write)
&& (!def.readonly || mode & ConfigAccess.read)
&& !(def.access ?? this.defaultAccess).includes(location)
)
) {
if (error) {
throw new InvalidAccess(`Field '${name}' is not accessible from ${location}`);
} else {
return false;
}
}
return true;
@@ -263,14 +311,17 @@ export class Config<
/**
* Serialize the config to a plain JavaScript object
*
* @param location - Location used for access control.
* @returns JSON serializable representation of the group.
* @param remote - Location this serialised representation is for
* @returns JSON serializable representation of the config.
*/
toRemote(location: ConfigLocation): Static<typeof Config.jsonSchema> {
toRemote(remote: ConfigLocation): Static<typeof Config.jsonSchema> {
let fields: Record<string, FieldValue> = {};
for (let [name, value] of Object.entries(this.fields)) {
let def = this.constructor.fieldDefinitions[name];
if (!this._checkAccess(name, def, location, false)) {
if (
this.location !== "control" && !this._checkAccess(name, def, this.location, ConfigAccess.write, false)
|| this.location !== remote && !this._checkAccess(name, def, remote, ConfigAccess.read, false)
) {
continue;
}
@@ -293,12 +344,12 @@ export class Config<
* @param json -
* Result from a previous call to .toRemote().
* @param notify - Invoke fieldChanged events if true.
* @param location - Location used for access control.
* @param remote - Location this serialised representation was received from.
*/
update(
json: Static<typeof Config.jsonSchema>,
notify: boolean,
location = this.location
remote = this.location
) {
const valid = Config.validate(json);
if (!valid) {
@@ -316,7 +367,9 @@ export class Config<
continue;
}
if (!this._checkAccess(name, def, location, false)) {
if (
this.location !== "control" && !this._checkAccess(name, def, remote, ConfigAccess.write, false)
) {
continue;
}
@@ -346,30 +399,45 @@ export class Config<
* Check if field can be accessed from the given location
*
* @param name - Name of field to check.
* @param mode - Mode to access the field as.
* @param location - Location used for access control.
* @returns true if field is accessible
*/
canAccess(name: string, location = this.location) {
canAccess(name: string, mode: ConfigAccess, location = this.location) {
if (typeof mode !== "number") {
throw new TypeError("mode argument is required to canAccess");
}
let def = this.constructor.fieldDefinitions[name];
if (!def) {
throw new InvalidField(`No field named '${name}'`);
}
return this._checkAccess(name, def, location, false);
return this._checkAccess(name, def, location, mode, false);
}
/**
* Get value for field
*
* @param name - Name of field to get.
* @param location - Location used for access control.
* @param remote - Location this value is being read from.
* @returns Value stored for the field.
*/
get<Field extends keyof Fields & string>(name: Field, location = this.location) {
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}'`);
}
this._checkAccess(name, def, location, true);
// If remote access is requested require it to be able to read the field
if (remote !== this.location) {
this._checkAccess(name, def, remote, ConfigAccess.read, true);
}
// Require that the field is either read or writable locally so that you can get the value
// back of write only fields recently written to.
if (
!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}`);
}
return this.fields[name];
}
@@ -377,22 +445,25 @@ export class Config<
/**
* Set value of field
*
* The field must be defined for the config group and the value is
* The field must be defined for the config and the value is
* type checked against the field definition.
*
* @param name - Name of field to set.
* @param newValue - Value to set for field.
* @param location - Location used for access control.
* @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.
*/
set<Field extends keyof Fields & string>(name: Field, newValue: Fields[Field], location = this.location) {
set<Field extends keyof Fields & string>(name: Field, newValue: Fields[Field], remote = this.location) {
let value: FieldValue = newValue;
let def = this.constructor.fieldDefinitions[name];
if (!def) {
throw new InvalidField(`No field named '${name}'`);
}
this._checkAccess(name, def, location, true);
this._checkAccess(name, def, remote, ConfigAccess.write, true);
if (remote !== this.location) {
this._checkAccess(name, def, this.location, ConfigAccess.write, true);
}
// Empty strings are treates as null
if (value === "") {
@@ -447,12 +518,12 @@ export class Config<
* Set property of object field
*
* Update value of stored object field by setting the specified property
* on it. The field must be defined as an object for the config group.
* on it. The field must be defined as an object for the config.
*
* @param name - Name of field to set property on.
* @param prop - Name of property to set on field.
* @param value - the value to set the property to.
* @param location - Location used for access control.
* @param remote - Location this property is being set from
* @throws {InvalidField} if field is not defined.
* @throws {InvalidValue} if field is not an object.
*/
@@ -460,13 +531,16 @@ export class Config<
name: Field,
prop: string,
value?: unknown,
location = this.location,
remote = this.location,
) {
let def = this.constructor.fieldDefinitions[name];
if (!def) {
throw new InvalidField(`No field named '${name}'`);
}
this._checkAccess(name, def, location, true);
this._checkAccess(name, def, remote, ConfigAccess.write, true);
if (remote !== this.location) {
this._checkAccess(name, def, this.location, ConfigAccess.write, true);
}
if (def.type !== "object") {
throw new InvalidValue(`Cannot set property on non-object field '${name}'`);
Loaded 100 of 273 files, more files were not shown because too many files have changed in this diff. Show more