Compare commits

..
Author SHA1 Message Date
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
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
Hornwitser c2b6a6e1c0 v2.0.0-alpha.16 2024-01-26 13:04:07 +01:00
Hornwitser f5255d6f76 Fix message seq reuse on forwarding (#579)
When messages were forwarded they were sent using the low level
connector .send method which accepts a message and sends it over the
link.  This method sends the message as is with no modification which
means that at no point during the forwarding logic was the message
sequence number that originated from Link A renumbered to the correct
sequence number to send out on Link B which it was forwarded on.

This in turn caused the logic which drops acked messages from the send
buffer to break which in turn meant the resume could cause duplication
of message and/or messages to be lost.  With enough duplicated messages
the resume of one connection could cause other connections to drop and
also initiate resume.

Fix by renumbering the sequence on forwarded messages.  In the future
the sequences sent and received should also be checked to make sure this
kind of problem does not happen again, this is not done here because
partially updating a running cluster with sequence problems to one that
checks and enforces sequences numbers will be problematic.
2024-01-26 13:02:26 +01:00
Hornwitser 7d6eac6558 v2.0.0-alpha.15 2024-01-24 04:40:21 +01:00
Hornwitser 6732397061 Fix Web UI not clearing config props (#578) 2024-01-24 04:35:24 +01:00
Cooldude2606 2020024b68 Merge pull request #577 from Hornwitser/small-things-15
Small things 15
2024-01-24 03:18:07 +00:00
Hornwitser 499e973db9 Fix InstanceConfigSetPropRequest schema
Fix the value incorrectly being set as required in the schema and thus
preventing props on config field objects of the instance from being
removed.
2024-01-24 03:43:21 +01:00
Hornwitser e3cfc090d3 Fix string format of Address
When host and instance id in Address was reordered in 1e66f11 the
toString method Address was updated to account for this.  Fix toString
showing host addresses as being instance addresses and vice versa.
2024-01-24 03:43:21 +01:00
Hornwitser 38dcff2225 Use h: as short form of host in log lines
Before the rename from hosts were called slave which is why s: made
sense. Use h: instead.
2024-01-24 03:43:21 +01:00
Cooldude2606 a1109b9354 Merge pull request #576 from Hornwitser/fix-plugin-broadcast
Fix plugin broadcasts not being filtered
2024-01-24 02:42:30 +00:00
Hornwitser 2a57ddb02e Add note about send and bcast test functions 2024-01-24 03:08:24 +01:00
Hornwitser 847945cfdd Fix plugin broadcasts not being filtered
When a plugin broadcasts an event to instances there may be hosts and
instances in the cluster which does not have the plugin loaded.  This
would cause an error to be logged for every instance which does not have
the plugin loaded.

Filter out broadcast events sent to hosts and instances such that
instances and hosts that does not have the event do not received them.
2024-01-24 01:56:01 +01:00
Cooldude2606 58231cc02c Add IPC to templates and handle helper function server 2024-01-24 00:40:22 +00:00
Cooldude2606 ab88955111 Merge pull request #574 from Hornwitser/fix-auto-start
Fix instance auto start
Fix host hang on stopping during create save
2024-01-23 18:41:44 +00:00
Hornwitser fc5701f243 Fix instance auto start
Bypass the Link layer and invoke the handleInstanceStartRequest method
directly on the Instance object during the host's instance auto start
phase.
2024-01-23 16:23:09 +01:00
Hornwitser 256b455315 Fix host hang on stopping during create save
If during the startup of an instance the Factorio server is started up
to create a new save and the host shutdown in initiated then the
instance will be commanded to stop while the FactorioServer state is
"create".  This was was not handled by Instance.stop leaving the
child_process running after the host is done shutting down.

Kill the FactorioServer on stop if the current state of it is stopping
or create to make sure the server actually stops.
2024-01-23 16:18:21 +01: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 2733f2e3c5 Merge pull request #571 from Hornwitser/link-bugfixes
Link bugfixes
2024-01-22 21:57:50 +00:00
Hornwitser 96bd5fb1c2 Handle virtual links in Link._clearPendingRequests
When a request is forwarded from a virtual link to a WebSocket link as
is the case when an instance sends a request to the controller the
origin for the request is a virtual link.  Handle this case in
_clearPendingRequests instead of throwing an assertion error.
2024-01-22 20:48:01 +01:00
Hornwitser 123148e277 Fix pending request not deleted on response
Fix memory leak due to the recorded _pendingRequests entry for a request
not being deleted when the response for the request has been received.
2024-01-22 20:48:01 +01:00
Hornwitser d7edae99c5 Add metrics for pending request
Track the number of requests currently awaiting a response from a
request handler both from interally issued requests and requests
forwarded from other parties.
2024-01-22 20:48:01 +01: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
Hornwitser 51fe6c9519 Merge pull request #569 from Hornwitser/bugfixes
Bugfixes for Alpha 14
2024-01-22 00:35:28 +01:00
Hornwitser e0161114e7 Fix --no-download-headless conflict in installer
Fix installer incorrectly exiting with a conflicting options errors when
the --no-download-headless option was used in conjunction with the
--factorio-dir option.
2024-01-21 23:49:19 +01:00
Hornwitser 2872faab98 Skip loading plugins without entrypoint on hosts
If a plugin only has entrypoints at the controller level the config will
skip adding a "load_plugin" config option for the given plugin to avoid
making options that do nothing.  If such a plugin is added to a host it
will still reach loadPlugin which in turn tries to check the load_plugin
config and throw an error on startup due the field being missing.

Skip trying to load plugins without an entrypoint at the instance or
host level in hosts before checking the load_plugin config field.
2024-01-21 23:49:19 +01:00
Hornwitser 1d1e80fbf0 Export Control and related types from ctl 2024-01-21 23:49:19 +01:00
Hornwitser 3d7c11a8b6 Add missing enable source maps for host service 2024-01-21 23:49:19 +01:00
Hornwitser 72264f0c53 Add suport for color mod setting (#568)
Implement the color-setting type in ModPack and display a color picker
for it in the Web UI. This type of setting was added in Factorio 1.1.77.
2024-01-21 23:48:26 +01:00
Hornwitser d6d6c052aa Add known issue about being kicked out of the Web UI 2024-01-21 02:17:12 +01:00
Hornwitser 901ce4e08e Add broken instance data export to known issues 2024-01-21 01:01:08 +01:00
Hornwitser 5fdc585212 Add known issues section
Some problems were not caught during testing and is not straight forward
to fix.  Add them to the known issues section for now.
2024-01-20 22:48:20 +01:00
Hornwitser 0aa330561b Update migration guide
Add a note about double checking which version of Node.js is installed
to avoid accidentally updating on an outdated version, and fix migrate
arguments.
2024-01-20 22:44:28 +01: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
192 changed files with 5269 additions and 607 deletions

No files matched your search

+17 -2
View File
@@ -35,6 +35,7 @@ module.exports = {
"rules": {
"prefer-arrow-callback": "off",
"no-invalid-this": "off",
"valid-jsdoc": "off",
"node/no-unpublished-import": "off",
"node/no-unpublished-require": "off",
},
@@ -43,6 +44,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": {
@@ -93,6 +95,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": {
@@ -319,7 +334,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"],
@@ -390,7 +405,7 @@ module.exports = {
"node/process-exit-as-throw": "error",
"node/shebang": ["error", {
"convertPath": {
"*.ts": ["^(.+)\\.ts$", "dist/$1.js"],
"*.ts": ["^(.+)\\.ts$", "dist/node/$1.js"],
},
}],
},
+1
View File
@@ -0,0 +1 @@
auto-install-peers=false
+105
View File
@@ -1,5 +1,110 @@
# 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.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
- Fixed message sequences not being renumbered when forwarded between links causing reconnects to send duplicated messages.
## Version 2.0.0-alpha.15
### Features
- Added support for `color-settings` Mod settings [#568](https://github.com/clusterio/clusterio/issues/568).
- Added `clusterio_controller_pending_requests` and `clusterio_host_pending_requests` metrics.
### Changes
- Fixed generated systemd file for clusteriohost not enabling source maps.
- Fixed ctl not exporting types used for adding commands to ctl plugins.
- Fixed controller only plugins throwing an error about missing a config field on startup out when added to hosts.
- Fixed installer incorrectly claiming the options --factorio-dir and --no-download-headless conflicts with each other.
- Fixed a memory leak on every request sent over the WebSocket.
- Fixed assertion error on closing or invalidating a link with messages forwarded from a virtual link.
- Fixed host hanging if stopped while an instance is in the process of creating a save during startup.
- Fixed `instance.auto_start` option.
- Fixed host log spammed with errors when plugins broadcast messages to instances with the plugin disabled.
- Fixed properties of object config fields not being possible to remove.
## Version 2.0.0-alpha.14
This release does not retain backwards compatibility with 2.0.0-alpha.13 meaning all parts of the cluster needs to updated at the same time.
+19
View File
@@ -39,6 +39,7 @@ If you are starting a new cluster, it's highly recommended to use the 2.0 alpha.
* [Running Clusterio](#running-clusterio)
* [Setting up remote hosts](#setting-up-remote-hosts)
* [Setting up clusterioctl](#setting-up-clusterioctl)
* [Known issues](#known-issues)
* [Common problems](#Common-problems)
@@ -265,6 +266,24 @@ If you want to use a different user for the command line interface, you can gene
npx clusteriocontroller bootstrap generate-user-token <username>
## Known Issues
- When the `server_select` plugin is installed you may see `[error] Unhandled event server_select:UpdateInstancesEvent` logged when starting an instance.
This should be ignored as it does not indicate an error occured.
- After updating Factorio you may get the error `Error: Expected empty response but got "Cannot execute command. Error: [string "clusterio_private.update_instance(..."]:1: attempt to index global 'clusterio_private' (a nil value)?` on instance start.
This is due to Factorio applying a migration replacing the patched content of the save with the original scenario, attempting to start the instance again should resolve the issue.
- In the mod pack view of the web interface the "base" mod is displayed with an error complaining about the mod being missing from the controller storage.
This is due to the "base" mod currently not being connectly handled as a built-in mod to Factorio in the web interface, this error message should be ignored.
- After migrating from Alpha 13 to Alpha 14 running an instance data export fails with the error `Upload failed: 401 Unauthorized` due to the host token being issued to a slave.
To workaround this find the id of the host in the host config and generate a new host token with the same id.
Stop the host and replace the value of the `host.controller_token` field in `config-host.json` with the new host token before starting it up again.
- After migrating from Alpha 13 to Alpha 14 you get kicked out from the web interface due to the master to controller rename.
If you don't have the authentication token readily available you can manually migrate the token by opening the Inspect/Developer Tools on the page and running the following piece of JavaScript in the Console:
```
localStorage.controller_token = localStorage.master_token
```
## Common problems
### EACCESS [...] LISTEN 443
+6
View File
@@ -0,0 +1,6 @@
coverage:
status:
project:
default:
if_ci_failed: success
informational: true
+5 -2
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.
@@ -316,6 +319,6 @@ You will need commit access to the repository as well publish access to the @clu
4. Publish the packages.
```sh
# Warning: Double check your current working directory is the repository before running this
pnpm -r --filter='!./external_plugins/**' publish
rm -r ./{plugins,packages}/*/dist
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 -1
View File
@@ -17,6 +17,8 @@ Mod Pack strings looks something like this:
If you have multiple mod configuration create a mod pack string for each and note them down.
Double check that you are on a supported version of Node.js by running `node --version`, the version should be v18 or later, if not you will need to update Node.js before continuing.
## Migrate installations
@@ -24,7 +26,7 @@ If you have multiple mod configuration create a mod pack string for each and not
Shutdown all slaves and the master server and then migrate each installations by running the following command:
npm exec @clusterio/create --migrate-rename --log-level=verbose
npm exec @clusterio/create -y -- --migrate-rename --log-level=verbose
This will convert the existing installation's usage of the master and slave names in packages, configs, database and logs to the new controller and host terms and update the installed packages.
+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,
}
```
```
+3 -1
View File
@@ -3,7 +3,8 @@
"private": true,
"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 --check-leaks -R spec --recursive test \"plugins/*/test/**\" --exclude \"test/manual/**\"",
"fast-test": "FAST_TEST=y pnpm test",
"cover": "nyc pnpm test",
"fast-cover": "FAST_TEST=y nyc pnpm test",
@@ -27,6 +28,7 @@
"@clusterio/ctl": "workspace:*",
"@clusterio/host": "workspace:*",
"@clusterio/lib": "workspace:*",
"@swc/core": "^1.4.0",
"@typescript-eslint/eslint-plugin": "^6.4.1",
"@typescript-eslint/parser": "^6.4.1",
"eslint": "^8.45.0",
+1 -1
View File
@@ -1,2 +1,2 @@
webpack.config.js
dist/web/build
dist/browser
+18
View File
@@ -37,6 +37,24 @@ import { version } from "./package.json";
// globals
let controller: Controller;
void new lib.Gauge(
"clusterio_controller_pending_requests",
"Count of pending link requests currently waiting in memory on the controller.",
{
callback: (gauge) => {
let count = 0;
if (controller) {
for (const host of controller.wsServer.hostConnections.values()) {
count += host.pendingRequestCount;
}
for (const control of controller.wsServer.controlConnections.values()) {
count += control.pendingRequestCount;
}
}
gauge.set(count);
},
}
);
void new lib.Gauge(
"clusterio_controller_host_mapping",
+2
View File
@@ -1,7 +1,9 @@
import { bootstrap } from "./controller";
export { default as Controller } from "./src/Controller";
export { default as ControllerUser } from "./src/ControllerUser";
export { default as ControllerRouter } from "./src/ControllerRouter";
export { default as ControlConnection } from "./src/ControlConnection";
export { default as HostConnection } from "./src/HostConnection";
export { default as InstanceInfo } from "./src/InstanceInfo";
export { default as BaseControllerPlugin } from "./src/BaseControllerPlugin";
export { default as UserManager } from "./src/UserManager";
+8 -5
View File
@@ -3,14 +3,14 @@
"description": "Implementation of Clusterio controller",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.14",
"main": "dist/index.js",
"version": "2.0.0-alpha.18",
"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": [
"factorio"
@@ -19,11 +19,12 @@
"node": ">=18"
},
"dependencies": {
"@clusterio/lib": "^2.0.0-alpha.14",
"@clusterio/lib": "^2.0.0-alpha.18",
"@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",
@@ -35,10 +36,12 @@
"yargs": "^17.7.2"
},
"devDependencies": {
"@clusterio/web_ui": "^2.0.0-alpha.14",
"@clusterio/web_ui": "^2.0.0-alpha.18",
"@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",
+1 -2
View File
@@ -6,7 +6,6 @@ import type WsServerConnector from "./WsServerConnector";
import * as lib from "@clusterio/lib";
const { logger } = lib;
import ControllerRouter from "./ControllerRouter";
import * as routes from "./routes";
@@ -24,7 +23,7 @@ export default class BaseConnection extends lib.Link {
public _controller: Controller
) {
super(connector);
this.router = new ControllerRouter(this._controller);
this.router = _controller.router;
for (let [Request, handler] of this._controller._registeredRequests) { this.handle(Request, handler); }
for (let [Request, handler] of this._controller._fallbackedRequests) { this.fallbackRequest(Request, handler); }
for (let [Event, handler] of this._controller._registeredEvents) { this.handle(Event, handler); }
+54 -9
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";
@@ -26,6 +27,7 @@ import WsServer from "./WsServer";
import HostConnection from "./HostConnection";
import HostInfo from "./HostInfo";
import BaseControllerPlugin from "./BaseControllerPlugin";
import ControllerRouter from "./ControllerRouter";
const endpointDurationSummary = new Summary(
"clusterio_controller_http_endpoint_duration_seconds",
@@ -74,6 +76,7 @@ export default class Controller {
/** WebSocket server */
wsServer: WsServer;
router = new ControllerRouter(this);
trustedProxies: BlockList;
debugEvents: events.EventEmitter = new events.EventEmitter();
private _events: events.EventEmitter = new events.EventEmitter();
@@ -272,7 +275,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"]) {
@@ -284,6 +287,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
@@ -331,7 +354,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();
@@ -719,14 +742,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();
});
@@ -735,7 +773,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
@@ -874,9 +912,11 @@ export default class Controller {
*/
clearSavesOfInstance(instanceId: number) {
const updates = [];
const now = Date.now();
for (const [id, save] of this.saves) {
if (save.instanceId === instanceId) {
save.isDeleted = true;
save.updatedAtMs = now;
updates.push(save);
this.saves.delete(id);
}
@@ -937,9 +977,9 @@ export default class Controller {
);
} else {
instance.status = "unassigned";
instance.updatedAtMs = Date.now();
this.instanceDetailsUpdated([instance]);
}
instance.updatedAtMs = Date.now();
this.instanceDetailsUpdated([instance]);
}
/**
@@ -960,6 +1000,7 @@ export default class Controller {
this.instancesDirty = true;
this.clearSavesOfInstance(instanceId);
this.userManager.clearStatsOfInstance(instanceId);
let prev = instance.status;
instance.status = "deleted";
@@ -1222,7 +1263,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"))
@@ -1374,7 +1415,11 @@ export default class Controller {
}
} else if (dst.id === lib.Address.instance || dst.id === lib.Address.host) {
const plugin = event.constructor.plugin;
for (let hostConnection of this.wsServer.hostConnections.values()) {
if (plugin && !hostConnection.plugins.has(plugin)) {
continue;
}
hostConnection.sendEvent(event, dst);
}
+12 -10
View File
@@ -7,8 +7,6 @@ import ControlConnection from "./ControlConnection";
const { logger } = lib;
export default class ControllerRouter {
pendingRequests: Map<any, any> = new Map();
constructor(
public controller: Controller
) {}
@@ -16,12 +14,14 @@ export default class ControllerRouter {
/**
* @param origin - Source link of the message.
* @param message - Link the message originated from.
* @param entry - message entry if message is a request or an event.
* @param hasFallback - true if fallback handling is available.
* @returns true if the message was handled
*/
forwardMessage(
origin: ControlConnection | HostConnection,
message: lib.MessageRoutable,
entry: lib.RequestEntry | lib.EventEntry | undefined,
hasFallback: boolean
): boolean {
if (!["request", "response", "responseError", "event"].includes(message.type)) {
@@ -32,7 +32,7 @@ export default class ControllerRouter {
let nextHop: ControlConnection | HostConnection | undefined;
let msg: string = "";
if (dst.type === lib.Address.broadcast) {
this.broadcastMessage(origin, message);
this.broadcastMessage(origin, message, entry as lib.EventEntry);
return true;
} else if (dst.type === lib.Address.host) {
nextHop = this.controller.wsServer.hostConnections.get(dst.id);
@@ -84,7 +84,7 @@ export default class ControllerRouter {
if (message.type === "request") {
nextHop.forwardRequest(message as lib.MessageRequest, origin);
} else {
nextHop.connector.send(message);
nextHop.connector.forward(message);
}
} else {
this.warnUnrouted(message, msg);
@@ -96,20 +96,24 @@ export default class ControllerRouter {
broadcastMessage(
origin: lib.Link,
message: lib.MessageRoutable,
entry: lib.EventEntry,
) {
let dst = message.dst;
if (message.type !== "event") {
this.warnUnrouted(message, `Unexpected broadcast of ${message.type}`);
} else if (dst.id === lib.Address.host || dst.id === lib.Address.instance) {
return;
}
const plugin = entry.Event.plugin;
if (dst.id === lib.Address.host || dst.id === lib.Address.instance) {
for (let hostConnection of this.controller.wsServer.hostConnections.values()) {
if (hostConnection !== origin) {
hostConnection.connector.send(message);
if (hostConnection !== origin && (!plugin || hostConnection.plugins.has(plugin))) {
hostConnection.connector.forward(message);
}
}
} else if (dst.id === lib.Address.control) {
for (let controlConnection of this.controller.wsServer.controlConnections.values()) {
if (controlConnection !== origin) {
controlConnection.connector.send(message);
controlConnection.connector.forward(message);
}
}
} else {
@@ -126,5 +130,3 @@ export default class ControllerRouter {
logger.warn(msg ? `${baseMsg}: ${msg}.` : `${baseMsg}.`);
}
}
module.exports = ControllerRouter;
+4 -1
View File
@@ -178,7 +178,10 @@ 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;
}
+9
View File
@@ -113,4 +113,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;
}
}
+6 -2
View File
@@ -284,7 +284,9 @@ ${err.stack}`
);
}
connection = new HostConnection(data, connector, this.controller, this.remoteAddr(req));
// 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 +294,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);
@@ -331,10 +335,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,
+12
View File
@@ -0,0 +1,12 @@
{
"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": "..",
"outDir": "dist/browser",
}
}
+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",
},
}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.node.json",
"references": [
{ "path": "../lib/tsconfig.node.json" },
],
"include": ["index.ts", "controller.ts", "package.json", "src/**/*.ts"],
"compilerOptions": {
"outDir": "dist/node",
},
}
-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",
}
}
+69 -7
View File
@@ -12,10 +12,23 @@ 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);
// 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" },
@@ -95,7 +108,7 @@ async function safeOutputFile(file, data, options={}) {
async function execFile(cmd, args) {
logger.verbose(`executing ${cmd} ${args.join(" ")}`);
return new Promise((resolve, reject) => {
let child = child_process.execFile(cmd, args, (err, stdout, stderr) => {
let child = child_process.execFile(cmd, args, { shell: true }, (err, stdout, stderr) => {
if (err) {
reject(err);
} else {
@@ -488,6 +501,7 @@ Group=${await groupIdToName(os.userInfo().gid)}
WorkingDirectory=${process.cwd()}
KillMode=mixed
KillSignal=SIGINT
Environment=NODE_OPTIONS=--enable-source-maps
ExecStart=${process.cwd()}/node_modules/.bin/clusteriohost run --log-level=warn --can-restart
Restart=on-failure
RestartPreventExitStatus=8
@@ -504,6 +518,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",
@@ -516,6 +531,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);
@@ -611,7 +627,7 @@ async function inquirerMissingArgs(args) {
},
], answers);
if (answers.downloadHeadless) {
if (!answers.factorioDir && answers.downloadHeadless) {
answers.factorioDir = "factorio";
}
}
@@ -643,6 +659,38 @@ 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" },
],
},
{
type: "input",
name: "pluginName",
message: "Name of your new plugin",
default: path.basename(path.resolve()),
},
], answers);
}
if (dev) {
answers.plugins = [];
} else if (args.plugins) {
@@ -719,7 +767,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",
@@ -745,6 +793,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()
;
@@ -759,7 +813,7 @@ async function main() {
"Can be set to false using --no-download-headless.",
type: "boolean",
})
.conflicts("factorio-dir", "download-headless");
;
}
args = args.argv;
@@ -778,11 +832,19 @@ async function main() {
}
let answers = await inquirerMissingArgs(args);
if (answers.downloadHeadless) {
await downloadLinuxServer();
logger.verbose(JSON.stringify(answers));
if (answers.pluginTemplate) {
await copyPluginTemplates(answers.pluginName, answers.pluginTemplate);
return;
}
logger.verbose(JSON.stringify(answers));
if (answers.downloadHeadless) {
if (answers.factorioDir !== "factorio") {
throw new InstallError("--download-headless option requires --factorio-dir to be set to factorio");
}
await downloadLinuxServer();
}
if (!dev) {
await installClusterio(answers.mode, answers.plugins);
+1 -1
View File
@@ -3,7 +3,7 @@
"description": "Installer for clusterio",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.14",
"version": "2.0.0-alpha.18",
"main": "create.js",
"bin": {
"clusteriocreate": "./create.js"
+171
View File
@@ -0,0 +1,171 @@
"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 = [];
const javascriptOnly = templates.includes("js");
const webpack = templates.includes("controller") || templates.includes("web");
// 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(`messages.${ext}`, path.join(templatePath, `messages.${ext}`));
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"));
files.set(`web/index.${ext}x`, path.join(templatePath,
templates.includes("web") ? `web/plugin.${ext}x` : `web/no_plugin.${ext}x`
));
}
// Files for the controller
if (templates.includes("controller")) {
files.set(`controller.${ext}`, path.join(templatePath, `controller.${ext}`));
}
// Files for hosts
if (templates.includes("host")) {
files.set(`host.${ext}`, path.join(templatePath, `host.${ext}`));
}
// Files for instances
if (templates.includes("instance")) {
files.set(`instance.${ext}`, path.join(templatePath, `instance.${ext}`));
}
// 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"));
}
}
// Files for the control tool
if (templates.includes("ctl")) {
files.set(`ctl.${ext}`, path.join(templatePath, `ctl.${ext}`));
}
// Properties that will control the replacements in the templates
const properties = {
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"),
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,54 @@
local clusterio_api = require("modules/clusterio/api")
--- Top level module table, contains event handlers and public methods
local MyModule = {
events = {},
on_nth_tick = {},
}
--- global is 'synced' between players, you should use your plugin name to avoid conflicts
-- setupGlobalData should either be removed or called during clusterio_api.events.on_server_startup
local globalData = {}
local function setupGlobalData()
if global["__plugin_name__"] == nil then
global["__plugin_name__"] = {
-- starting values go here
}
end
globalData = global["__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_load
MyModule.events[clusterio_api.events.on_server_startup] = function(event)
setupGlobalData()
game.print(game.table_to_json(event))
end
--- Factorio events are accessible through defines.events, you can have one handler per event per module
MyModule.events[defines.events.on_player_crafted_item] = function(event)
game.print(game.table_to_json(event))
//%if instance
clusterio_api.send_json("__plugin_name__-plugin_example_ipc", {
tick = game.tick, player_name = game.get_player(event.player_index).name
})
//%endif
end
--- Nth tick is a special case that requires its own table, the index represents the time period between calls in ticks
MyModule.on_nth_tick[300] = function()
game.print(game.tick)
bar()
end
--- Always return the top level module table from control, this is how clusterio will access your event handlers
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,3 @@
--- Can contain anything you want to allow other plugins to have access to, this example exposes the control file
-- Access the exports from other modules using require("modules/__plugin_name__")
return require("modules/__plugin_name__/control")
@@ -0,0 +1,53 @@
{
"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": "^2.0.0-alpha.17"
},
"devDependencies": {
//%// Typescript and type declarations
//%if typescript
"typescript": "^5.1.6",
"@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": "^2.0.0-alpha.17",
//%endif
"@clusterio/lib": "^2.0.0-alpha.17"
},
"dependencies": {
"@sinclair/typebox": "^0.30.4"
},
"publishConfig": {
"access": "public"
},
"keywords": [
"clusterio",
"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,73 @@
"use strict";
const lib = require("@clusterio/lib");
const { BaseControllerPlugin } = require("@clusterio/controller");
const {
PluginExampleEvent, PluginExampleRequest,
//%if controller & web // Subscribing requires web content and the controller
ExampleSubscribableUpdate, ExampleSubscribableValue,
//%endif
} = require("./messages");
class ControllerPlugin extends BaseControllerPlugin {
//%if controller & web // Subscribing requires web content and the controller
exampleDatabase;
storageDirty = false;
//%endif
async init() {
this.controller.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
this.controller.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
//%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
}
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)}`);
}
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,
};
}
//%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,31 @@
"use strict";
const { BaseCtlPlugin } = require("@clusterio/ctl");
const { CommandTree, Command } = require("@clusterio/lib");
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,35 @@
"use strict";
const lib = require("@clusterio/lib");
const { BaseHostPlugin } = require("@clusterio/host");
const { PluginExampleEvent, PluginExampleRequest } = require("./messages");
class HostPlugin extends BaseHostPlugin {
async init() {
this.host.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
this.host.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
}
async onHostConfigFieldChanged(field, curr, prev) {
this.logger.info(`host::onInstanceConfigFieldChanged ${field}`);
}
async onShutdown() {
this.logger.info("host::onShutdown");
}
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,
};
}
}
module.exports = {
HostPlugin,
};
@@ -0,0 +1,109 @@
"use strict";
const lib = require("@clusterio/lib");
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",
});
//%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
controllerEntrypoint: "./controller",
controllerConfigFields: {
"__plugin_name__.myControllerField": {
title: "My Controller Field",
description: "This should be removed from index.js",
type: "string",
initialValue: "Remove Me",
},
},
//%endif
//%if host
hostEntrypoint: "./host",
hostConfigFields: {
"__plugin_name__.myHostField": {
title: "My Host Field",
description: "This should be removed from index.js",
type: "string",
initialValue: "Remove Me",
},
},
//%endif
//%if instance
instanceEntrypoint: "./instance",
instanceConfigFields: {
"__plugin_name__.myInstanceField": {
title: "My Instance Field",
description: "This should be removed from index.js",
type: "string",
initialValue: "Remove Me",
},
},
//%endif
//%if ctl
ctlEntrypoint: "./ctl",
controlConfigFields: {
"__plugin_name__.myControlField": {
title: "My Control Field",
description: "This should be removed from index.js",
type: "string",
initialValue: "Remove Me",
},
},
//%endif
messages: [
Messages.PluginExampleEvent,
Messages.PluginExampleRequest,
//%if controller & web // Subscribing requires web content and the controller
Messages.ExampleSubscribableUpdate,
//%endif
],
//%if web // Web content template has an example route which is the plugin name
webEntrypoint: "./web",
routes: [
"/__plugin_name__",
],
//%endif
//%if controller & !web // The controller always includes web entry even if there is no content
webEntrypoint: "./web",
routes: [],
//%endif
};
module.exports = {
plugin,
};
@@ -0,0 +1,55 @@
"use strict";
const lib = require("@clusterio/lib");
const { BaseInstancePlugin } = require("@clusterio/host");
const { PluginExampleEvent, PluginExampleRequest } = require("./messages");
class InstancePlugin extends BaseInstancePlugin {
async init() {
this.instance.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
this.instance.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
//%if module
this.instance.server.handle("__plugin_name__-plugin_example_ipc", this.handlePluginExampleIPC.bind(this));
//%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)}`);
}
async handlePluginExampleEvent(event) {
this.logger.info(JSON.stringify(event));
//%if module
this.sendRcon("/sc __plugin_name__.foo()");
//%endif
}
async handlePluginExampleRequest(request) {
this.logger.info(JSON.stringify(request));
return {
myResponseString: request.myString,
myResponseNumbers: request.myNumberArray,
};
}
//%if module
async handlePluginExampleIPC(event) {
this.logger.info(JSON.stringify(event));
}
//%endif
}
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,78 @@
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, Control, ControlContext, notifyErrorHandler,
} from "@clusterio/web_ui";
import {
PluginExampleEvent, PluginExampleRequest,
//%if controller & web // Subscribing requires web content and the controller
ExampleSubscribableUpdate,
//%endif
} from "../messages";
import * as lib from "@clusterio/lib";
function MyTemplatePage() {
let control = useContext(ControlContext);
//%if controller & web // 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__" }]}>
<h2>__plugin_name__</h2>
//%if controller & web // Subscribing requires web content and the controller
Synced: {String(synced)} Data: {JSON.stringify([...subscribableData.values()])}
//%endif
</PageLayout>;
}
export class WebPlugin extends BaseWebPlugin {
//%if controller & web // 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__",
permission: "__plugin_name__.page.view",
content: <MyTemplatePage/>,
},
];
this.control.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
this.control.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
}
//%if controller & web // 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
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,
};
}
}
@@ -0,0 +1,68 @@
import * as lib from "@clusterio/lib";
import { BaseControllerPlugin, InstanceInfo } from "@clusterio/controller";
import {
PluginExampleEvent, PluginExampleRequest,
//%if controller & web // Subscribing requires web content and the controller
ExampleSubscribableUpdate, ExampleSubscribableValue,
//%endif
} from "./messages";
export class ControllerPlugin extends BaseControllerPlugin {
//%if controller & web // Subscribing requires web content and the controller
exampleDatabase!: Map<string, ExampleSubscribableValue>;
storageDirty = false;
//%endif
async init() {
this.controller.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
this.controller.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
//%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
}
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)}`);
}
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,
};
}
//%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 @@
type Control = any; // import type { Control } from "@clusterio/ctl";
import { BaseCtlPlugin } from "@clusterio/ctl";
import { CommandTree, Command } from "@clusterio/lib";
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,30 @@
import * as lib from "@clusterio/lib";
import { BaseHostPlugin } from "@clusterio/host";
import { PluginExampleEvent, PluginExampleRequest } from "./messages";
export class HostPlugin extends BaseHostPlugin {
async init() {
this.host.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
this.host.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
}
async onHostConfigFieldChanged(field: string, curr: unknown, prev: unknown) {
this.logger.info(`host::onInstanceConfigFieldChanged ${field}`);
}
async onShutdown() {
this.logger.info("host::onShutdown");
}
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,
};
}
}
@@ -0,0 +1,127 @@
import * as lib from "@clusterio/lib";
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",
});
//%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
declare module "@clusterio/lib" {
//%if controller
export interface ControllerConfigFields {
"__plugin_name__.myControllerField": string;
}
//%endif
//%if host
export interface HostConfigFields {
"__plugin_name__.myHostField": string;
}
//%endif
//%if instance
export interface InstanceConfigFields {
"__plugin_name__.myInstanceField": string;
}
//%endif
//%if ctl
export interface ControlConfigFields {
"__plugin_name__.myControlField": string;
}
//%endif
}
export const plugin: lib.PluginDeclaration = {
name: "__plugin_name__",
title: "__plugin_name__",
description: "Example Description. Plugin. Change me in index.ts",
//%if controller
controllerEntrypoint: "./dist/node/controller",
controllerConfigFields: {
"__plugin_name__.myControllerField": {
title: "My Controller Field",
description: "This should be removed from index.js",
type: "string",
initialValue: "Remove Me",
},
},
//%endif
//%if host
hostEntrypoint: "./dist/node/host",
hostConfigFields: {
"__plugin_name__.myHostField": {
title: "My Host Field",
description: "This should be removed from index.js",
type: "string",
initialValue: "Remove Me",
},
},
//%endif
//%if instance
instanceEntrypoint: "./dist/node/instance",
instanceConfigFields: {
"__plugin_name__.myInstanceField": {
title: "My Instance Field",
description: "This should be removed from index.js",
type: "string",
initialValue: "Remove Me",
},
},
//%endif
//%if ctl
ctlEntrypoint: "./dist/node/ctl",
controlConfigFields: {
"__plugin_name__.myControlField": {
title: "My Control Field",
description: "This should be removed from index.js",
type: "string",
initialValue: "Remove Me",
},
},
//%endif
messages: [
Messages.PluginExampleEvent,
Messages.PluginExampleRequest,
//%if controller & web // Subscribing requires web content and the controller
Messages.ExampleSubscribableUpdate,
//%endif
],
//%if web // Web content template has an example route which is the plugin name
webEntrypoint: "./web",
routes: [
"/__plugin_name__",
],
//%endif
//%if controller & !web // The controller always includes web entry even if there is no content
webEntrypoint: "./web",
routes: [],
//%endif
};
@@ -0,0 +1,57 @@
import * as lib from "@clusterio/lib";
import { BaseInstancePlugin } from "@clusterio/host";
import { PluginExampleEvent, PluginExampleRequest } from "./messages";
//%if module
type PuginExampleIPC = {
tick: number,
player_name: string,
};
//%endif
export class InstancePlugin extends BaseInstancePlugin {
async init() {
this.instance.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
this.instance.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
//%if module
this.instance.server.handle("__plugin_name__-plugin_example_ipc", this.handlePluginExampleIPC.bind(this));
//%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
this.sendRcon("/sc __plugin_name__.foo()");
//%endif
}
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,
};
}
//%if module
async handlePluginExampleIPC(event: PuginExampleIPC) {
this.logger.info(JSON.stringify(event));
}
//%endif
}
@@ -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,11 @@
{
"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" ],
"compilerOptions": {
"outDir": "dist/browser",
}
}
@@ -0,0 +1,9 @@
{
"files": [],
"references": [
//%if web
{ "path": "./tsconfig.browser.json" },
//%endif
{ "path": "./tsconfig.node.json" }
]
}
@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.node.json",
"references": [
{ "path": "../../packages/lib/tsconfig.node.json" },
],
"include": ["./**/*.ts"],
"exclude": ["test/*", "./dist/*"],
"compilerOptions": {
"outDir": "dist/node",
},
}
Whitespace-only changes.
@@ -0,0 +1,85 @@
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, Control, ControlContext, notifyErrorHandler,
} from "@clusterio/web_ui";
import {
PluginExampleEvent, PluginExampleRequest,
//%if controller & web // Subscribing requires web content and the controller
ExampleSubscribableUpdate,
//%endif
} from "../messages";
import * as lib from "@clusterio/lib";
function MyTemplatePage() {
const control = useContext(ControlContext);
//%if controller & web // 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__" }]}>
<h2>__plugin_name__</h2>
//%if controller & web // Subscribing requires web content and the controller
Synced: {String(synced)} Data: {JSON.stringify([...subscribableData.values()])}
//%endif
</PageLayout>;
}
export class WebPlugin extends BaseWebPlugin {
//%if controller & web // 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 & web // Subscribing requires web content and the controller
permission: "__plugin_name__.example.permission.subscribe",
//%endif
//%if !controller | !web
permission: undefined, // "__plugin_name__.example.permission.request",
//%endif
content: <MyTemplatePage/>,
},
];
this.control.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
this.control.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
}
//%if controller & web // 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
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,
};
}
}
+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
View File
@@ -1,4 +1,5 @@
import { bootstrap } from "./ctl";
export * from "./ctl";
export { default as BaseCtlPlugin } from "./src/BaseCtlPlugin";
if (module === require.main) {
+4 -4
View File
@@ -3,14 +3,14 @@
"description": "Clusterio command line management tool",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.14",
"main": "dist/index.js",
"version": "2.0.0-alpha.18",
"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": [
"factorio"
@@ -19,7 +19,7 @@
"node": ">=18"
},
"dependencies": {
"@clusterio/lib": "^2.0.0-alpha.14",
"@clusterio/lib": "^2.0.0-alpha.18",
"as-table": "^1.0.55",
"fs-extra": "^11.1.1",
"phin": "^3.7.0",
+18 -1
View File
@@ -1097,7 +1097,13 @@ modPackCommands.add(new lib.Command({
function setModPackSettings(
modPack: lib.ModPack,
args: { boolSetting?: string[], intSetting?: string[], doubleSetting?: string[], stringSetting?: string[] },
args: {
boolSetting?: string[],
intSetting?: string[],
doubleSetting?: string[],
stringSetting?: string[],
colorSetting?: string[],
},
) {
function doSettings(settings: string[], cast: (value: string) => (string | number | boolean)) {
for (let i = 0; i + 2 < settings.length; i += 3) {
@@ -1134,6 +1140,13 @@ function setModPackSettings(
return number;
});
doSettings(args.stringSetting || [], value => value);
doSettings(args.colorSetting || [], value => {
let color = JSON.parse(value);
if (typeof color !== "object" || color === null) {
throw new lib.CommandError("color value must be a JSON object with r, g, b, a properties");
}
return color;
});
}
function setModPackMods(modPack: lib.ModPack, mods: string[] | undefined) {
@@ -1173,6 +1186,7 @@ modPackCommands.add(new lib.Command({
"int-setting": { describe: "Set int setting", array: true, nargs: 3, type: "string" },
"double-setting": { describe: "Set double setting", array: true, nargs: 3, type: "string" },
"string-setting": { describe: "Set string setting", array: true, nargs: 3, type: "string" },
"color-setting": { describe: "Set color setting", array: true, nargs: 3, type: "string" },
});
}],
handler: async function(
@@ -1186,6 +1200,7 @@ modPackCommands.add(new lib.Command({
intSetting?: string[],
doubleSetting?: string[],
stringSetting?: string[],
colorSetting?: string[],
},
control: Control,
) {
@@ -1244,6 +1259,7 @@ modPackCommands.add(new lib.Command({
"int-setting": { describe: "Set int setting", array: true, nargs: 3, type: "string" },
"double-setting": { describe: "Set double setting", array: true, nargs: 3, type: "string" },
"string-setting": { describe: "Set string setting", array: true, nargs: 3, type: "string" },
"color-setting": { describe: "Set color setting", array: true, nargs: 3, type: "string" },
"remove-setting": { describe: "Remove a setting", array: true, nargs: 2, type: "string" },
});
}],
@@ -1261,6 +1277,7 @@ modPackCommands.add(new lib.Command({
intSetting?: string[],
doubleSetting?: string[],
stringSetting?: string[],
colorSetting?: string[],
removeSetting?: string[],
},
control: Control,
+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",
},
}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.node.json",
"references": [
{ "path": "../lib/tsconfig.node.json" },
],
"include": ["index.ts", "package.json", "ctl.ts", "src/**/*.ts"],
"compilerOptions": {
"outDir": "dist/node",
},
}
+26 -5
View File
@@ -26,6 +26,27 @@ import { ConsoleTransport, levels, logger } from "@clusterio/lib";
import Host from "./src/Host";
let host: Host | undefined;
void new lib.Gauge(
"clusterio_host_pending_requests",
"Count of pending link requests currently waiting in memory on the host.",
{
labels: ["host_id"],
callback: (gauge) => {
if (!host) {
return;
}
let count = host.pendingRequestCount;
for (const connection of host.instanceConnections.values()) {
count += connection.pendingRequestCount;
count += connection.instance.pendingRequestCount;
}
gauge.labels(String(host.config.get("host.id"))).set(count);
},
}
);
export class HostConnector extends lib.WebSocketClientConnector {
constructor(
@@ -205,7 +226,7 @@ async function startHost() {
}
let hostConnector = new HostConnector(hostConfig, tlsCa, pluginInfos);
let host = new Host(
host = new Host(
hostConnector,
args.config,
hostConfig,
@@ -227,7 +248,7 @@ async function startHost() {
secondSigint = true;
logger.info("Caught interrupt signal, shutting down");
host.shutdown();
host!.shutdown();
});
let secondSigterm = false;
process.on("SIGTERM", () => {
@@ -240,11 +261,11 @@ async function startHost() {
secondSigterm = true;
logger.info("Caught termination signal, shutting down");
host.shutdown();
host!.shutdown();
});
process.on("SIGHUP", () => {
logger.info("Terminal closed, shutting down");
host.shutdown();
host!.shutdown();
});
try {
@@ -255,7 +276,7 @@ async function startHost() {
}
hostConnector.once("connect", () => {
logger.add(new lib.LinkTransport({ link: host }));
logger.add(new lib.LinkTransport({ link: host! }));
});
await hostConnector.connect();
+3
View File
@@ -1,6 +1,9 @@
import { bootstrap } from "./host";
export { default as BaseHostPlugin } from "./src/BaseHostPlugin";
export { default as BaseInstancePlugin } from "./src/BaseInstancePlugin";
export { default as Host, HostRouter } from "./src/Host";
export { default as Instance } from "./src/Instance";
export { default as InstanceConnection } from "./src/InstanceConnection";
if (module === require.main) {
bootstrap();
+4 -4
View File
@@ -3,14 +3,14 @@
"description": "Implementation of Clusterio host server",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.14",
"main": "dist/index.js",
"version": "2.0.0-alpha.18",
"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": [
"factorio"
@@ -19,7 +19,7 @@
"node": ">=18"
},
"dependencies": {
"@clusterio/lib": "^2.0.0-alpha.14",
"@clusterio/lib": "^2.0.0-alpha.18",
"@sinclair/typebox": "^0.30.4",
"fs-extra": "^11.1.1",
"jimp": "^0.22.8",
+36 -17
View File
@@ -82,7 +82,7 @@ const instanceStartingMessages = new Set([
lib.InstanceExportDataRequest.name,
]);
class HostRouter {
export class HostRouter {
constructor(
public host: Host
) { }
@@ -93,16 +93,22 @@ class HostRouter {
* @param origin -
* Link the message originated from.
* @param message - Message to process.
* @param entry - message entry if message is a request or an event.
* @param hasFallback - is fallback available
* @returns true if the message was handled, false if fallback
* is requested
*/
forwardMessage(origin: lib.Link, message: lib.MessageRoutable, hasFallback: boolean) {
forwardMessage(
origin: lib.Link,
message: lib.MessageRoutable,
entry: lib.RequestEntry | lib.EventEntry | undefined,
hasFallback: boolean,
) {
let dst = message.dst;
let nextHop: lib.Link | undefined;
let msg: string | undefined;
if (dst.type === lib.Address.broadcast) {
this.broadcastMessage(origin, message);
this.broadcastMessage(origin, message, entry as lib.EventEntry);
return true;
} else if (
dst.type === lib.Address.controller
@@ -153,27 +159,36 @@ class HostRouter {
return true;
}
broadcastMessage(origin: lib.Link, message: lib.MessageRoutable) {
broadcastMessage(
origin: lib.Link,
message: lib.MessageRoutable,
entry: lib.EventEntry,
) {
let dst = message.dst;
if (message.type !== "event") {
this.warnUnrouted(message, `Unexpected broadcast of ${message.type}`);
} else if (dst.id === lib.Address.host || dst.id === lib.Address.instance) {
return;
}
const plugin = entry.Event.plugin;
if (dst.id === lib.Address.instance) {
for (let instanceConnection of this.host.instanceConnections.values()) {
if (instanceConnection !== origin) {
instanceConnection.connector.send(message);
if (instanceConnection !== origin && (!plugin || instanceConnection.plugins.has(plugin))) {
instanceConnection.connector.forward(message);
}
}
if (this.host !== origin) {
this.host.connector.send(message);
if (this.host !== origin && (!plugin || this.host.serverPlugins.has(plugin))) {
this.host.connector.forward(message);
}
} else if (dst.id === lib.Address.control) {
if (this.host !== origin) {
this.host.connector.send(message);
if (!plugin || this.host.serverPlugins.has(plugin)) {
this.host.connector.forward(message);
}
} else {
logger.warn(`Received control broadcast of ${(message as lib.MessageEvent).name} from master.`);
logger.warn(`Received control broadcast of ${(message as lib.MessageEvent).name} from controller.`);
}
} else {
this.warnUnrouted(message, `Unexpected broacdast target ${dst.id}`);
this.warnUnrouted(message, `Unexpected broadcast target ${dst.id}`);
}
}
@@ -193,7 +208,7 @@ class HostRouter {
}
this.host._connectInstance(dst.id).then(instanceConnection => {
instanceConnection.connector.send(message);
instanceConnection.connector.forward(message);
}).catch(err => {
logger.error(`Error starting instance:\n${err.stack}`, this.host.instanceLogMeta(dst.id));
origin.connector.sendResponseError(
@@ -207,7 +222,7 @@ class HostRouter {
if (message.type === "request") {
nextHop.forwardRequest(message as lib.MessageRequest, origin);
} else {
nextHop.connector.send(message);
nextHop.connector.forward(message);
}
} catch (err: any) {
if (message.type === "request") {
@@ -377,7 +392,10 @@ export default class Host extends lib.Link {
async loadPlugins() {
for (let pluginInfo of this.pluginInfos) {
if (!this.config.get(`${pluginInfo.name}.load_plugin` as keyof lib.HostConfigFields)) {
if (
!pluginInfo.hostEntrypoint && !pluginInfo.instanceEntrypoint
|| !this.config.get(`${pluginInfo.name}.load_plugin` as keyof lib.HostConfigFields)
) {
continue;
}
@@ -582,8 +600,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)) {
@@ -1053,7 +1072,7 @@ export default class Host extends lib.Link {
if (instanceInfo.config.get("instance.auto_start")) {
try {
let instanceConnection = await this._connectInstance(instanceId);
await instanceConnection.send(new lib.InstanceStartRequest());
await instanceConnection.instance.handleInstanceStartRequest(new lib.InstanceStartRequest());
} catch (err: any) {
logger.error(
`Error during auto startup for ${instanceInfo.config.get("instance.name")}:\n${err.stack}`,
+6 -1
View File
@@ -800,7 +800,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)) {
@@ -955,6 +955,11 @@ rcon.print(game.table_to_json(players))`.replace(/\r?\n/g, " ");
await lib.invokeHook(this.plugins, "onStop");
await this.server.stop();
await this.sendSaveListUpdate();
} else if (
this.server._state === "stopping"
|| this.server._state === "create"
) {
await this.server.kill();
}
}
+19 -13
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 {
@@ -18,11 +18,10 @@ interface SimpleIconSpecification {
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,
tint?: lib.FactorioColor,
shift?: [number, number],
scale?: number,
icon_mipmaps?: number,
@@ -62,7 +61,7 @@ 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+$/, ""),
@@ -202,15 +201,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 +245,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 +289,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)
;
}
+15 -5
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;
}
@@ -395,8 +402,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 = [];
+11 -2
View File
@@ -562,6 +562,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 => {
this._logger.error(`Error handling ipc event:\n${err.stack}`);
}));
}
async _handleIpc(line: Buffer) {
let channelEnd = line.indexOf("?");
if (channelEnd === -1) {
@@ -865,7 +872,9 @@ export class FactorioServer extends events.EventEmitter {
try {
let [code, signal] = await events.once(this._server, "exit");
void signal;
if (signal) {
throw new Error(`Factorio exited with signal ${signal}`);
}
if (code !== 0) {
throw new Error(`Factorio exited with status ${code}`);
}
@@ -1092,7 +1101,7 @@ export class FactorioServer extends events.EventEmitter {
* process.
*/
async kill(unexpected = false) {
this._check(["running", "stopping"]);
this._check(["running", "stopping", "create"]);
this._killed = true;
if (!unexpected) {
this._state = "stopping";
+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",
},
}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.node.json",
"references": [
{ "path": "../lib/tsconfig.node.json" },
],
"include": ["index.ts", "host.ts", "package.json", "src/**/*.ts", "src/**/*.js"],
"compilerOptions": {
"outDir": "dist/node",
},
}
+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 -2
View File
@@ -1,14 +1,14 @@
{
"name": "@clusterio/lib",
"description": "Shared library for Clusterio",
"version": "2.0.0-alpha.14",
"version": "2.0.0-alpha.18",
"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": [
"factorio"
+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);
}
}
+1 -1
View File
@@ -459,7 +459,7 @@ export class Config<
setProp<Field extends keyof Fields & string>(
name: Field,
prop: string,
value: unknown,
value?: unknown,
location = this.location,
) {
let def = this.constructor.fieldDefinitions[name];
+9
View File
@@ -116,6 +116,11 @@ export default class ModInfo {
*/
size = 0;
/**
* Last modification time of the file.
*/
mtimeMs = 0;
/**
* SHA1 hash of this mod
*/
@@ -147,6 +152,7 @@ export default class ModInfo {
static jsonSchema = Type.Object({
...this.infoJsonSchema.properties,
"size": Type.Optional(Type.Integer()),
"mtime_ms": Type.Optional(Type.Number()),
"sha1": Type.Optional(Type.String()),
"updated_at_ms": Type.Optional(Type.Number()),
"is_deleted": Type.Optional(Type.Boolean()),
@@ -170,6 +176,7 @@ export default class ModInfo {
// Additional data
if (json.size) { modInfo.size = json.size; }
if (json.mtime_ms) { modInfo.mtimeMs = json.mtime_ms; }
if (json.sha1) { modInfo.sha1 = json.sha1; }
if (json.updated_at_ms) { modInfo.updatedAtMs = json.updated_at_ms; }
if (json.is_deleted) { modInfo.isDeleted = json.is_deleted; }
@@ -192,6 +199,7 @@ export default class ModInfo {
json.dependencies = this.dependencies;
}
if (this.size) { json.size = this.size; }
if (this.mtimeMs) { json.mtime_ms = this.mtimeMs; }
if (this.sha1) { json.sha1 = this.sha1; }
if (this.updatedAtMs) { json.updated_at_ms = this.updatedAtMs; }
if (this.isDeleted) { json.is_deleted = this.isDeleted; }
@@ -229,6 +237,7 @@ export default class ModInfo {
size: stat.size,
sha1: await libHash.hashFile(modPath),
mtime_ms: stat.mtimeMs,
updated_at_ms: stat.mtimeMs,
is_deleted: false,
});
+34 -10
View File
@@ -2,22 +2,31 @@ import zlib from "zlib";
import { Type, Static } from "@sinclair/typebox";
import * as libSchema from "../schema";
import * as libLuaTools from "../lua_tools";
import ExportManifest from "./ExportManifest";
import { integerFactorioVersion } from "./version";
import type { Logger } from "../logging";
export const ModSettingColor = Type.Object({
r: Type.Number(),
g: Type.Number(),
b: Type.Number(),
a: Type.Number(),
});
export type ModSettingColor = Static<typeof ModSettingColor>;
/**
* A setting for a mod.
*/
export interface ModSetting {
/** Value of the given mod setting. */
value: boolean | number | string;
value: boolean | number | string | ModSettingColor;
}
const ModSettingJsonSchema = Type.Object({
"value": Type.Union([Type.Boolean(), Type.Number(), Type.String()]),
"value": Type.Union([Type.Boolean(), Type.Number(), Type.String(), ModSettingColor]),
});
const ModSettingsJsonSchema = Type.Record(Type.String(), ModSettingJsonSchema);
@@ -325,17 +334,18 @@ export default class ModPack {
}
/**
* Fill in missing settings with their default values
* Fill in missing and incorrect settings with their default values
*
* Uses the provided setting prototypes to add any missing mod settings
* in the mod pack with the default value from the prototype.
* Uses the provided setting prototypes to correct any missing and or
* incorrect mod settings in the mod pack with the default value from
* the prototype.
*
* @param settingPrototypes -
* Setting prototypes exported from the game.
* @param logger - Logger used to report warnings on.
*/
fillDefaultSettings(settingPrototypes: Record<string, object>, logger: Logger) {
const knownTypes = ["bool-setting", "int-setting", "double-setting", "string-setting"];
const knownTypes = ["bool-setting", "int-setting", "double-setting", "string-setting", "color-setting"];
let prototypes = Object.entries(settingPrototypes)
.filter(([type, _]) => knownTypes.includes(type))
.flatMap(([_, settings]) => Object.values(settings))
@@ -351,11 +361,25 @@ export default class ModPack {
logger.warn(`Ignoring ${prototype.name} with missing default_value`);
continue;
}
if (this.settings[settingType].get(prototype.name)) {
continue;
let value = this.settings[settingType].get(prototype.name)?.value ?? prototype.default_value;
const type = prototype.type;
if (
type === "bool-setting" && typeof value !== "boolean"
|| type === "color-setting" && typeof value !== "object"
|| (type === "int-setting" || type === "double-setting") && typeof value !== "number"
) {
value = prototype.default_value;
}
this.settings[settingType].set(prototype.name, { value: prototype.default_value });
if (type === "string-setting" && typeof value !== "string") {
value = typeof value === "object" ? prototype.default_value : String(value ?? "");
}
if (type === "color-setting") {
if (typeof value !== "object") {
value = prototype.default_value;
}
value = libLuaTools.normalizeColor(value as object);
}
this.settings[settingType].set(prototype.name, { value });
}
}
}
+9 -1
View File
@@ -19,12 +19,20 @@ export default class Role {
permissions: Type.Array(Type.String()),
});
static remapPermission(permission: string) {
// migrate: core.instance.save.list.subscribe was renamed in alpha 17
if (permission === "core.instance.save.list.subscribe") {
return "core.instance.save.subscribe";
}
return permission;
}
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(
json.id,
json.name,
json.description,
new Set(json.permissions),
new Set(json.permissions.map(this.remapPermission)),
);
}
+1 -1
View File
@@ -5,7 +5,7 @@
*/
export { default as ExportManifest } from "./ExportManifest";
export { default as ModInfo } from "./ModInfo";
export { default as ModPack, ModSetting, ModRecord } from "./ModPack";
export { default as ModPack, ModSetting, ModSettingColor, ModRecord } from "./ModPack";
export { default as ModuleInfo } from "./ModuleInfo";
export { default as Permission } from "./Permission";
export { default as PlayerStats } from "./PlayerStats";
+1 -1
View File
@@ -87,7 +87,7 @@ export class Address {
}
toString() {
const typeMap = ["controller", "instance", "host", "control"];
const typeMap = ["controller", "host", "instance", "control"];
const type = typeMap[this.type] || this.type;
if (this.type === Address.broadcast) {
return `[Address ${type}:${typeMap[this.id] || this.id}]`;
+9 -9
View File
@@ -208,7 +208,7 @@ export class InstanceConfigSetPropRequest {
"instanceId": Type.Integer(),
"field": Type.String(),
"prop": Type.String(),
"value": Type.Unknown(),
"value": Type.Optional(Type.Unknown()),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
@@ -361,7 +361,7 @@ export class InstanceSaveDetailsUpdatesEvent {
static type = "event" as const;
static src = ["instance", "host", "controller"] as const;
static dst = ["controller", "control"] as const;
static permission = "core.instance.save.list.subscribe" as const;
static permission = "core.instance.save.subscribe" as const;
constructor(
public updates: SaveDetails[],
@@ -385,7 +385,7 @@ export class InstanceSaveDetailsUpdatesEvent {
export class InstanceCreateSaveRequest {
declare ["constructor"]: typeof InstanceCreateSaveRequest;
static type = "request" as const;
static src = "control" as const;
static src = ["control", "controller"] as const;
static dst = "instance" as const;
static permission = "core.instance.save.create" as const;
@@ -596,7 +596,7 @@ export class InstancePushSaveRequest {
export class InstanceLoadScenarioRequest {
declare ["constructor"]: typeof InstanceLoadScenarioRequest;
static type = "request" as const;
static src = "control" as const;
static src = ["control", "controller"] as const;
static dst = "instance" as const;
static permission = "core.instance.load_scenario" as const;
@@ -622,7 +622,7 @@ export class InstanceLoadScenarioRequest {
export class InstanceExportDataRequest {
declare ["constructor"]: typeof InstanceExportDataRequest;
static type = "request" as const;
static src = "control" as const;
static src = ["control", "controller"] as const;
static dst = "instance" as const;
static permission = "core.instance.export_data" as const;
}
@@ -630,7 +630,7 @@ export class InstanceExportDataRequest {
export class InstanceExtractPlayersRequest {
declare ["constructor"]: typeof InstanceExtractPlayersRequest;
static type = "request" as const;
static src = "control" as const;
static src = ["control", "controller"] as const;
static dst = "instance" as const;
static permission = "core.instance.extract_players" as const;
}
@@ -638,7 +638,7 @@ export class InstanceExtractPlayersRequest {
export class InstanceStopRequest {
declare ["constructor"]: typeof InstanceStopRequest;
static type = "request" as const;
static src = "control" as const;
static src = ["control", "controller"] as const;
static dst = "instance" as const;
static permission = "core.instance.stop" as const;
}
@@ -646,7 +646,7 @@ export class InstanceStopRequest {
export class InstanceKillRequest {
declare ["constructor"]: typeof InstanceKillRequest;
static type = "request" as const;
static src = "control" as const;
static src = ["control", "controller"] as const;
static dst = "instance" as const;
static permission = "core.instance.kill" as const;
}
@@ -693,7 +693,7 @@ export class InstanceDeleteInternalRequest {
export class InstanceSendRconRequest {
declare ["constructor"]: typeof InstanceSendRconRequest;
static type = "request" as const;
static src = "control" as const;
static src = ["control", "controller"] as const;
static dst = "instance" as const;
static permission = "core.instance.send_rcon" as const;
+32 -2
View File
@@ -33,7 +33,30 @@ export abstract class BaseConnector extends events.EventEmitter {
this._seq = 1;
}
abstract send(message: libData.Message): void;
protected abstract send(message: libData.MessageRoutable): void;
forward(message: libData.MessageRoutable) {
const seq = this._seq;
this._seq += 1;
// The message type currently can't be inferred like a tagged union
// TODO fix typing of message so it can be deduced from .type
if (message.type === "request") {
const request = message as libData.MessageRequest;
this.send(new libData.MessageRequest(seq, request.src, request.dst, request.name, request.data));
} else if (message.type === "response") {
const response = message as libData.MessageResponse;
this.send(new libData.MessageResponse(seq, response.src, response.dst, response.data));
} else if (message.type === "responseError") {
const error = message as libData.MessageResponseError;
this.send(new libData.MessageResponseError(seq, error.src, error.dst, error.data));
} else if (message.type === "event") {
const event = message as libData.MessageEvent;
this.send(new libData.MessageEvent(seq, event.src, event.dst, event.name, event.data));
} else {
throw new Error(`Cannot forward message type ${(message as any).type}`);
}
return seq;
}
sendRequest<Req, Res>(
request: Request<Req, Res>,
@@ -242,7 +265,7 @@ export abstract class WebSocketBaseConnector extends BaseConnector {
*
* @param message - Message to send.
*/
send(message: libData.MessageRoutable) {
protected send(message: libData.MessageRoutable) {
if (!["connected", "resuming"].includes(this._state)) {
throw new libErrors.SessionLost("No session");
}
@@ -295,6 +318,13 @@ export abstract class WebSocketBaseConnector extends BaseConnector {
await this.close(1000, "Disconnect");
}
/**
* Notify other end that the link is ready to disconnect
*/
sendDisconnectReady() {
this._sendInternal(new libData.MessageDisconnect("ready"));
}
/**
* True if the connection is established and active
*/
+39 -16
View File
@@ -72,7 +72,12 @@ export type RequestHandler<Req, Res> = (request: Req, src: libData.Address, dst:
export type EventHandler<T> = (event: T, src: libData.Address, dst: libData.Address) => Promise<void>;
interface Router {
forwardMessage(origin: Link, message: MessageRoutable, fallback: boolean): boolean,
forwardMessage(
origin: Link,
message: MessageRoutable,
entry: RequestEntry | EventEntry | undefined,
fallback: boolean,
): boolean,
}
// Some definitions for the terminology used here:
@@ -102,6 +107,9 @@ export class Link {
) {
this.handle(libData.PingRequest, async () => {});
// Prevent warnings about possible memory leak due to large number of event listeners
connector.setMaxListeners(20);
// Process messages received by the connector
connector.on("message", payload => {
try {
@@ -121,15 +129,15 @@ export class Link {
});
connector.on("disconnectPrepare", () => {
this.prepareDisconnect().catch(
err => {
logger.error(`Unexpected error preparing disconnect:\n${err.stack}`);
this.prepareDisconnect().finally(() => {
// Only WebSocket connectors issue disconnection events.
const webSocketConnector = this.connector as WebSocketBaseConnector;
if (webSocketConnector.hasSession) {
webSocketConnector.sendDisconnectReady();
}
).finally(
() => {
this.connector.send(new libData.MessageDisconnect("ready"));
},
);
}).catch(err => {
logger.error(`Unexpected error preparing disconnect:\n${err.stack}`);
});
});
connector.on("invalidate", () => {
@@ -141,17 +149,30 @@ export class Link {
});
}
/**
* Count of requests currently waiting for a response on this link
*/
get pendingRequestCount() {
return this._pendingRequests.size + this._forwardedRequests.size;
}
_clearPendingRequests(err: Error & { code?: string }) {
for (let pending of this._pendingRequests.values()) {
pending.reject(err);
}
for (let pending of this._forwardedRequests.values()) {
assert(pending.origin.connector instanceof WebSocketBaseConnector);
if (pending.origin.connector.hasSession) {
pending.origin.connector.sendResponseError(
new libData.ResponseError(err.message, err.code), pending.src, pending.dst
);
// Drop response if the origin is a WebSocket and the session
// has expired. The other end of the link will have sent an
// error response to the request in this case.
if (
pending.origin.connector instanceof WebSocketBaseConnector
&& !pending.origin.connector.hasSession
) {
continue;
}
pending.origin.connector.sendResponseError(
new libData.ResponseError(err.message, err.code), pending.src, pending.dst
);
}
this._pendingRequests.clear();
this._forwardedRequests.clear();
@@ -330,7 +351,7 @@ export class Link {
let fallback =
message.type === "request" ? this._requestFallbacks.get((entry as RequestEntry).Request) : undefined
;
if (this.router.forwardMessage(this, message, Boolean(fallback))) {
if (this.router.forwardMessage(this, message, entry, Boolean(fallback))) {
return;
}
if (!fallback) {
@@ -421,6 +442,7 @@ export class Link {
throw new libErrors.InvalidMessage(`Received reply from ${message.src} for message sent to ${pending.dst}`);
}
this._pendingRequests.delete(message.dst.requestId!);
try {
pending.resolve(pending.request.responseFromJSON(message.data));
} catch (err: any) {
@@ -437,6 +459,7 @@ export class Link {
);
}
this._pendingRequests.delete(message.dst.requestId!);
pending.reject(new libErrors.RequestError(message.data.message, message.data.code, message.data.stack));
}
@@ -541,7 +564,7 @@ export class Link {
dst: message.dst,
};
this._forwardedRequests.set(message.src.index(), pending);
this.connector.send(message);
this.connector.forward(message);
}
sendEvent<T>(event: Event<T>, dst: libData.Address) {
+7 -4
View File
@@ -8,8 +8,9 @@ export const levels = Object.freeze({
warn: 2,
audit: 3,
info: 4,
server: 5,
verbose: 6,
http: 5,
server: 6,
verbose: 7,
});
export const colors = {
@@ -18,6 +19,7 @@ export const colors = {
warn: "bold brightYellow",
audit: "bold brightGreen",
info: "bold brightBlue",
http: "bold",
server: "bold",
verbose: "bold grey",
} as const;
@@ -73,7 +75,7 @@ export class WebConsoleFormat {
transform(info: any, _options: object) {
let src = " ";
if (info.host_id !== undefined) {
src += `s:${info.host_name} - `;
src += `h:${info.host_name} - `;
}
if (info.instance_id !== undefined) {
src += `i:${info.instance_name}`;
@@ -163,7 +165,7 @@ export const logger = winston.createLogger({
// Who in their right mind thought that log levels should be hard coded
// into the type when you can define custom levels you want to support?
export type Logger = Omit<winston.Logger,
"error" | "warn" | "help" | "data" | "info" | "debug" | "prompt" | "http" | "verbose" | "input" | "silly" |
"error" | "warn" | "help" | "data" | "info" | "debug" | "prompt" | "verbose" | "input" | "silly" |
"emerg" | "alert" | "crit" | "warning" | "notice" |
"child"
> & {
@@ -172,6 +174,7 @@ export type Logger = Omit<winston.Logger,
warn: winston.LeveledLogMethod,
audit: winston.LeveledLogMethod,
info: winston.LeveledLogMethod,
http: winston.LeveledLogMethod,
server: winston.LeveledLogMethod,
verbose: winston.LeveledLogMethod,
child(options: object): Logger,
+1 -1
View File
@@ -163,7 +163,7 @@ export class TerminalFormat {
src += `i:${info.instance_name} `;
} else if (info.host_name) {
src += `s:${info.host_name} `;
src += `h:${info.host_name} `;
}
if (info.plugin) {
src += `${info.plugin}: `;
+48
View File
@@ -3,6 +3,54 @@
* @module lib/lua_tools
*/
/**
* A colour as it may originate from Factorio Lua code, see
* https://lua-api.factorio.com/latest/types/Color.html
*/
export type FactorioColor = { r?: number, g?: number, b?: number, a?: number } | [number, number, number, number?];
export type Color = { r: number, g: number, b: number, a: number };
/**
* Normalise a color as it may appear in Factorio Lua code to be an object
* with r, g, b, a properties in the range 0 to 1.
*
* @param color -
* A colour that may be specified with left out components or as an
* array and have the range 1 to 0 or 255 to 0.
* @returns
* Normalised colour where all 4 components are present as properties in
* the range 0 to 1.
*/
export function normalizeColor(color: FactorioColor) {
let r, g, b, a;
if (color instanceof Array) {
([r, g, b, a] = color);
} else {
({ r, g, b, a } = color);
}
r ??= NaN;
g ??= NaN;
b ??= NaN;
a ??= NaN;
// Note NaN > 1 is false and NaN / 255 is NaN
if (r > 1 || g > 1 || b > 1 || a > 1) {
r /= 255;
g /= 255;
b /= 255;
a /= 255;
}
return {
r: Number.isNaN(r) ? 0 : r,
g: Number.isNaN(g) ? 0 : g,
b: Number.isNaN(b) ? 0 : b,
a: Number.isNaN(a) ? 1 : a,
};
}
/**
* Escapes a string for inclusion into a lua string
*
+3 -3
View File
@@ -239,11 +239,11 @@ definePermission({
definePermission({
name: "core.instance.save.list",
title: "List saves",
description: "List the saves currently on the instance.",
description: "List the saves currently on instances.",
});
definePermission({
name: "core.instance.save.list.subscribe",
title: "Subscribe to save list updatess",
name: "core.instance.save.subscribe",
title: "Subscribe to save updates",
description: "Subscribe to be notifed on updates to the list of saves of instances.",
});
definePermission({
+9
View File
@@ -2,6 +2,7 @@ import { Type, Static } from "@sinclair/typebox";
import { Link, Event, EventClass, RequestHandler, WebSocketClientConnector, WebSocketBaseConnector } from "./link";
import { logger } from "./logging";
import { Address, MessageRequest, IControllerUser } from "./data";
import isDeepStrictEqual from "./is_deep_strict_equal";
export type SubscriptionRequestHandler<T> = RequestHandler<SubscriptionRequest, Event<T> | null>;
export type EventSubscriberCallback<T> = (updates: T[], synced: boolean) => void
@@ -204,6 +205,14 @@ export class EventSubscriber<
async _handle(event: EventSubscribable<T, V>) {
for (const value of event.updates) {
this.lastResponseTimeMs = Math.max(this.lastResponseTimeMs, value.updatedAtMs);
// Warn about updates which changes content but don't update updatedAtMs
const existing = this.values.get(value.id as K);
if (existing && existing.updatedAtMs === value.updatedAtMs && !isDeepStrictEqual(existing, value)) {
logger.warn(
`${this.Event.name} contains update for value with id ${value.id} that has an identical ` +
"updatedAtMs timestamp but differing content"
);
}
if (value.isDeleted) {
this.values.delete(value.id as K);
} else {
@@ -1,5 +1,5 @@
{
"extends": "../../tsconfig.web.json",
"extends": "../../tsconfig.browser.json",
"include": [
"browser.ts",
"package.json",
@@ -12,6 +12,7 @@
"./src/hash.ts",
"./src/ini.ts",
"./src/link/*.ts",
"./src/lua_tools.ts",
"./src/logging.ts",
"./src/permissions.ts",
"./src/prometheus.ts",
@@ -25,6 +26,6 @@
],
"compilerOptions": {
"rootDir": ".",
"outDir": "dist/web/build",
"outDir": "dist/browser",
}
}
+5 -6
View File
@@ -1,8 +1,7 @@
{
"extends": "../../tsconfig.node.json",
"include": ["index.ts", "browser.ts", "build_mod.js", "src/**/*.ts", "src/**/*.js"],
"compilerOptions": {
"outDir": "dist",
"allowJs": true,
},
"files": [],
"references": [
{ "path": "./tsconfig.node.json" },
{ "path": "./tsconfig.browser.json" },
],
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.node.json",
"include": ["index.ts", "browser.ts", "build_mod.js", "src/**/*.ts", "src/**/*.js"],
"compilerOptions": {
"outDir": "dist/node",
"allowJs": true,
},
}
+5 -7
View File
@@ -3,9 +3,9 @@
"description": "Clusterio web interface implementation",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.14",
"version": "2.0.0-alpha.18",
"browser": "src/index.ts",
"main": "dist/src/index.js",
"main": "dist/browser/src/index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
@@ -17,12 +17,10 @@
},
"dependencies": {
"@ant-design/icons": "^5.1.4",
"@babel/core": "^7.22.9",
"@babel/preset-react": "^7.22.5",
"@clusterio/lib": "^2.0.0-alpha.14",
"@clusterio/lib": "^2.0.0-alpha.18",
"@swc/core": "^1.4.0",
"antd": "^5.13.0",
"assert": "^2.0.0",
"babel-loader": "^9.1.3",
"browserify-zlib": "^0.2.0",
"buffer": "^6.0.3",
"clean-webpack-plugin": "^4.0.0",
@@ -39,8 +37,8 @@
"react-router-dom": "^6.14.2",
"stream-browserify": "^3.0.0",
"style-loader": "^3.3.3",
"swc-loader": "^0.2.6",
"terser-webpack-plugin": "^5.3.9",
"ts-loader": "^9.4.4",
"typescript": "^5.1.6",
"util": "^0.12.5",
"webpack": "^5.88.2",
+6 -6
View File
@@ -104,16 +104,16 @@ export default function App(props: AppProps) {
}
return (
<ErrorBoundary Component={ErrorCard}>
<ConfigProvider
theme={{ algorithm: theme.darkAlgorithm }}
>
<ConfigProvider
theme={{ algorithm: theme.darkAlgorithm }}
>
<ErrorBoundary Component={ErrorCard}>
<ControlContext.Provider value={props.control}>
<BrowserRouter basename={webRoot}>
{page}
</BrowserRouter>
</ControlContext.Provider>
</ConfigProvider>
</ErrorBoundary>
</ErrorBoundary>
</ConfigProvider>
);
}
@@ -27,6 +27,8 @@ export default function ControllerConfigTree() {
} catch (err) {
return;
}
} else {
value = undefined;
}
await control.send(new lib.ControllerConfigSetPropRequest(field, prop, value));
}
@@ -24,6 +24,8 @@ export default function HostConfigTree(props: { id: number, available: boolean }
} catch (err) {
return;
}
} else {
value = undefined;
}
await control.sendTo({ hostId: props.id }, new lib.HostConfigSetPropRequest(field, prop, value));
}
@@ -48,7 +48,7 @@ export default function HostViewPage() {
let hostButtons = <Space>
{
account.hasPermission("core.host.revoke_access")
account.hasPermission("core.host.revoke_token")
&& <Popconfirm
title={`Revoke tokens of ${host.name}?`}
placement="bottomRight"
@@ -28,6 +28,8 @@ export default function InstanceConfigTree(props: InstanceConfigTreeProps) {
} catch (err) {
return;
}
} else {
value = undefined;
}
await control.send(new lib.InstanceConfigSetPropRequest(props.id, field, prop, value));
}
@@ -1,6 +1,6 @@
import React from "react";
import { useNavigate } from "react-router-dom";
import { message, Button, Space, Table } from "antd";
import { message, Button, Space, Table, Typography } from "antd";
import CopyOutlined from "@ant-design/icons/CopyOutlined";
import type { SizeType } from "antd/es/config-provider/SizeContext";
import type { ColumnsType } from "antd/es/table";
@@ -10,6 +10,7 @@ import { useHosts } from "../model/host";
import InstanceStatusTag from "./InstanceStatusTag";
import StartStopInstanceButton from "./StartStopInstanceButton";
import { InstanceDetails } from "@clusterio/lib";
import Link from "./Link";
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
@@ -55,7 +56,12 @@ export default function InstanceList(props: InstanceListProps) {
{
title: "Assigned Host",
key: "assignedHost",
render: (_, instance) => hostName(instance.assignedHost),
render: (_, instance) => <Link
to={`/hosts/${instance.assignedHost}/view`}
onClick={e => e.stopPropagation()}
>
{hostName(instance.assignedHost)}
</Link>,
sorter: (a, b) => strcmp(hostName(a.assignedHost), hostName(b.assignedHost)),
responsive: ["sm"],
},
@@ -68,10 +74,10 @@ export default function InstanceList(props: InstanceListProps) {
{publicAddress}
<Button
type="text"
icon={<CopyOutlined/>}
icon={<CopyOutlined />}
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(publicAddress??"");
navigator.clipboard.writeText(publicAddress ?? "");
message.success("Copied public address!");
}}
/>
Loaded 100 of 192 files, more files were not shown because too many files have changed in this diff. Show more