diff --git a/clean.js b/clean.js
index f657c54f..51bc9f47 100644
--- a/clean.js
+++ b/clean.js
@@ -6,12 +6,11 @@
* clean-all: Removes all build, test, and execution artifacts. Including cluster config and logs!
*/
-"use strict";
-const path = require("path");
-const fs = require("node:fs/promises");
-const yargs = require("yargs");
+import path from "node:path";
+import fs from "node:fs/promises";
+import yargs from "yargs";
-const npmPackage = require("./package.json");
+import npmPackage from "./package.json" with { type: "json" };
const workspaces = npmPackage.workspaces
.map(p => p.slice(0, -2))
.filter(p => !p.includes("external_plugins"));
@@ -116,6 +115,6 @@ async function main() {
}
// Run main if started from command line
-if (module === require.main) {
+if (import.meta.main) {
main();
}
diff --git a/docs/config-system.md b/docs/config-system.md
index 2d080c4a..72997ef9 100644
--- a/docs/config-system.md
+++ b/docs/config-system.md
@@ -3,10 +3,10 @@
This document describes the implementation and inner working of the configuration system in Clusterio, and unless you're developing Clusterio it's probably not going to be very useful for you.
Clusterio uses a field based configuration system to manage settings for the controller, instances, hosts, and the clusterioctl utility.
-Plugins can add their own config fields for the controller and instances configs.
+Plugins can add their own config fields for the controller, host and instance configs.
The fields are defined early in the startup, and fields for disabled plugins will still be created.
-The built-in config fields are defined in [packages/lib/src/config/definitions.ts](/packages/lib/src/config/definitions.ts) while plugins can add their own config field definitions to the `controllerConfigFields` and `instanceConfigFields` properties of the `plugin` export.
+The built-in config fields are defined in [packages/lib/src/config/definitions.ts](/packages/lib/src/config/definitions.ts) while plugins can add their own config field definitions to the `controllerConfigFields`, `hostConfigFields` and `instanceConfigFields` properties of the `plugin` export.
A `.load_plugin` field is automatically created for each plugin and it's used to enable/disable the loading plugins on startup.
@@ -46,6 +46,45 @@ For plugins the group name is the name of the plugin, e.g., the `level` field of
Value this config field should take in a newly initialized config.
This can also be an async function returning the value to use.
+**validator** (optional):
+ Function called with the value being set and the config it belongs to.
+ It should throw an error if the value is not acceptable, and the message of the error is passed on to whoever tried to set it.
+ Common validators such as `greaterThanZero`, `greaterThanEqualZero`, `integer` and `filePath` are available on `Config.validators`, along with `optional` to skip null and `all` to combine several.
+ The config is read only while the validator runs.
+
+**dependsOn** (optional):
+ Names of other fields the validator reads the value of.
+ When one of those fields is changed the validator of this field is run again against the new value, and the change is rejected if it fails.
+
+**restartRequired** (optional):
+ True if a restart of the controller, host or instance the config belongs to is needed for changes to take effect.
+ Changing such a field sets the `restartRequired` flag on the config, which the web interface uses to show that a restart is pending.
+ Defaults to false.
+
+**restartRequiredProps** (optional):
+ For object fields, names of properties that have the opposite of `restartRequired`.
+ Only used by the web interface to show which properties need a restart, `factorio.settings` uses it to mark the settings that are applied live.
+
+**credential** (optional):
+ Locations allowed to read the value of this field.
+ Other locations can still change the value if allowed by `access`, which is what you want for tokens and passwords.
+
+**readonly** (optional):
+ Locations allowed to change the value of this field.
+ Other locations can still read the value if allowed by `access`.
+
+**hidden** (optional):
+ True if the web interface should not show this field.
+ Used for internal values that are managed by Clusterio.
+
+**autoComplete** (optional):
+ Value for the autocomplete attribute of the input in the web interface, e.g. `section-factorio new-password`.
+ Only used for string and number fields.
+
+**inputComponent** (optional):
+ Name of the input component the web interface should render for this field instead of the default for its type.
+ Plugins can add their own components through `inputComponents` on their web plugin class.
+
If the value for a field stored in on disk somehow ends up being invalid the field will take on the initial\_value instead when the config is loaded.
diff --git a/docs/configuration.md b/docs/configuration.md
index 39013cfc..8863454b 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -1,5 +1,15 @@
# Configuration
+Config values are changed with the `config set` commands of clusterioctl, clusteriocontroller and clusteriohost, or from the settings pages in the web interface.
+See [Managing a Cluster](/docs/managing-a-cluster.md) for the commands.
+
+All values are checked against the type of the field when set.
+Some fields have additional rules on top of that, such as a number having to be greater than 0, and these are listed under the field.
+Setting a value that fails a check is rejected with an error and the old value is kept.
+
+A few fields are internal to Clusterio.
+These are managed by Clusterio itself, hidden in the web interface and should not be modified by hand.
+
## Controller Configuration
@@ -20,11 +30,30 @@ This is also used by plugins to store its data.
Defaults to "database".
+### controller.mods_directory
+
+Directory where mods shared with the cluster are stored.
+Mods uploaded through the web interface or downloaded from the mod portal end up here, and hosts fetch the mods they need for their instances from here.
+Needs to be writeable by the controller.
+
+Defaults to "mods".
+
+
+### controller.autosave_interval
+
+Interval in seconds the controller saves the data it holds in memory, such as users, instances, mod packs and plugin data, to controller.database_directory.
+The data is also saved on shutdown.
+Must be greater than 0.
+
+Defaults to 60.
+
+
### controller.http_port
Port to host HTTP server on.
If set to null no HTTP server will be exposed.
At least one of this and controller.https_port needs to be set to a port.
+Must be a whole number greater than 0.
Defaults to 8080.
@@ -35,6 +64,7 @@ Port to host HTTPS server on.
Uses controller.tls_certificate and controller.tls_private_key as the certificate and private key for the HTTPS server.
If set to null no HTTPS server will be exposed.
At least one of this and controller.http_port needs to be set to a port.
+Must be a whole number greater than 0.
See the [Setting up TLS](/docs/setting-up-tls.md) document for a guide to setting up HTTPS with Clusterio.
@@ -51,10 +81,19 @@ interfaces.
Defaults to null.
+### controller.trusted_proxies
+
+Comma separated list of IP addresses and/or CIDR blocks of reverse proxies in front of the controller to trust the `X-Forwarded-For` header from.
+When a connection comes from one of these addresses the last address in the `X-Forwarded-For` header is used as the remote address of the connection instead of the address of the proxy.
+Entries that are not valid IP addresses are logged as errors and ignored.
+
+Defaults to null meaning the header is never trusted.
+
+
### controller.public_url
Public address the controller is accessible on.
-Currently only used for `clusteriocontroller bootstrap create-ctl-config` in order to give the right url to connect to.
+Used as the URL in configs generated with `clusteriocontroller bootstrap create-ctl-config` and `clusterioctl host create-config`, and by plugins like Player Auth that need to show the address of the web interface in-game.
This should be a full URL ending with a /.
Defaults to null meaning assume localhost.
@@ -64,25 +103,34 @@ Defaults to null meaning assume localhost.
Used to override where static assets such as the web bundle is loaded from.
This can be used if you want to put the static assets on a separate cdn url like `https://cdn.example.com/static/` while keeping the API hosted directly by clusterio.
-Due to a limitation on how the webpack assets are built the pathname component of the url have to be `/static/`.
+Due to a limitation on how the webpack assets are built the pathname component of the url have to be `/static/`, values with any other path are rejected.
See also the `clusteriocontroller copy-static` command for how to obtain the static asset files that need to be hosted.
Defaults to null meaning use the internally hosted assets.
+### controller.grafana_url
+
+URL of a Grafana dashboard to link to from the overview page of the web interface, including the protocol.
+If null no link is shown.
+
+Defaults to null.
+
+
### controller.tls_certificate
Path to TLS certificate to use for the HTTPS server when controller.https_port is configured.
+Both this and controller.tls_private_key have to be set for the controller to start with controller.https_port configured.
-Defaults to "database/certificates/cert.crt".
+Defaults to null.
### controller.tls_private_key
Path to TLS private key to use for the HTTPS server when controller.https_port is configured.
-Defaults to "database/certificates/cert.key".
+Defaults to null.
### controller.auth_secret
@@ -90,6 +138,7 @@ Defaults to "database/certificates/cert.key".
Base64 encoded authentication secret used to verify tokens issued to hosts, users, and sessions.
This should contain 256 bytes of random data, and if changed will cause all tokens become invalid.
Keep this secret if leaked an attacker could easily compromise the cluster.
+The value can only be read and changed from the controller itself, it is not available through clusterioctl or the web interface.
Defaults to null which means generate a secure secret on startup.
@@ -99,6 +148,7 @@ Defaults to null which means generate a secure secret on startup.
Interval in seconds heartbeats are sent out at for WebSocket connections.
If a WebSocket connection hasn't received a heartbeat in 2 times the heartbeat interval it will be considered stale and closed.
A lower value means less time between connections going stale and them being closed.
+Must be greater than 0.
Defaults to 15.
@@ -110,6 +160,8 @@ A higher timeout gives more time for a client to recconect and resume an active
In the case of the controller or client crashing the data loss is unavoidable.
Value should not be less than the configured `max_reconnect_delay` of the clients and it should be greater than 2 times `controller.heartbeat_interval`.
+Must be greater than 0.
+
Defaults to 60.
@@ -117,18 +169,64 @@ Defaults to 60.
Timeout in seconds before a call to gather metrics from a host times out.
This should be less than both the timeout and collection interval configured for the collection job in Prometheus.
+Must be greater than 0.
Defaults to 8.
-### controller.stream_timeout
+### controller.system_metrics_interval
+
+Interval in seconds the controller gathers CPU, memory and disk usage from itself and every connected host for display in the web interface.
+Whether the controller needs a restart to apply an update is also checked on this interval.
+If 0 system metrics are not gathered.
+Must be 0 or greater.
+
+Defaults to 10.
+
+
+### controller.proxy_stream_timeout
Timeout in seconds for proxy streams through the controller that has been created but is not yet sending data to be automatically closed.
Streams that have started sending data before this timeout is not affected.
+Must be greater than 0.
Defaults to 15.
+### controller.factorio_username
+
+Username of the Factorio.com account to authenticate with the Factorio API with.
+Together with controller.factorio_token this is used by the controller to download mods from the mod portal, and if controller.share_factorio_credentials_with_hosts is enabled, as the credentials in the server settings of instances.
+
+Defaults to null.
+
+
+### controller.factorio_token
+
+Token of the Factorio.com account to authenticate with the Factorio API with, see controller.factorio_username.
+This is the token shown on your profile page on factorio.com, not your password.
+The value is treated as a credential, it can be set from anywhere but only read back from the controller itself.
+
+Defaults to null.
+
+
+### controller.share_factorio_credentials_with_hosts
+
+If enabled hosts get controller.factorio_username and controller.factorio_token from the controller when they start an instance and put them in the server settings of that instance.
+Credentials set on the host with host.factorio_username and host.factorio_token, or on the instance through `username` and `token` in factorio.settings, take precedence over the ones from the controller.
+Disable this if you don't want the controller's credentials to be sent to the hosts.
+
+Defaults to true.
+
+
+### controller.default_mod_pack_id
+
+ID of the mod pack to use for instances that don't have factorio.mod_pack_id set.
+Instances without a mod pack of their own fail to start if this is null.
+
+Defaults to null.
+
+
### controller.default_role_id
Role to automatically grant to new users.
@@ -137,6 +235,55 @@ If null no role is granted.
Defaults to 1 which correspond to the default Player role.
+### controller.mod_portal_cache_duration_minutes
+
+Time in minutes the controller caches the list of mods fetched from the Factorio mod portal when browsing it from the web interface.
+Set to 0 to disable the cache and fetch the list on every request.
+Must be 0 or greater.
+
+Defaults to 30.
+
+
+### controller.mod_portal_page_size
+
+Number of results to request per page when fetching the list of mods from the Factorio mod portal.
+Must be greater than 0.
+
+Defaults to 1000.
+
+
+### controller.allow_remote_updates
+
+Whether the `controller update` command in clusterioctl and the update button in the web interface are allowed to update the `@clusterio/controller` package with npm.
+For security reasons this can only be changed locally with `clusteriocontroller config set` while the controller is stopped, and it is hidden in the web interface.
+See [Managing a Cluster](/docs/managing-a-cluster.md) for details on remote updates.
+
+Defaults to true.
+
+
+### controller.allow_plugin_updates
+
+Whether remote requests to update plugins installed on the controller with npm are allowed.
+Like controller.allow_remote_updates this can only be changed locally.
+
+Defaults to true.
+
+
+### controller.allow_plugin_install
+
+Whether remote requests to install new plugins on the controller with npm are allowed.
+Like controller.allow_remote_updates this can only be changed locally.
+
+Defaults to false.
+
+
+### controller.version
+
+Version of the controller package that last loaded this config.
+Set automatically on startup and used to detect when the controller has been updated or downgraded.
+Internal, hidden in the web interface and should not be changed.
+
+
### .load_plugin
Whether to load the plugin on the controller.
@@ -158,6 +305,7 @@ Defaults to "New Host".
### host.id
Immutable numeric id of the host which uniquely identifies this host in the cluster.
+Hidden in the web interface.
Defaults to a random 31 bit number.
@@ -170,6 +318,14 @@ This can either point to a Factorio server directory, or to a directory containi
Defaults to "factorio".
+### host.mods_directory
+
+Directory the host stores mods downloaded from the controller in.
+When an instance starts the mods in its mod pack are downloaded here if missing and then linked into the mods directory of the instance.
+
+Defaults to "mods".
+
+
### host.instances_directory
Directory to store instances in.
@@ -189,6 +345,7 @@ Defaults to "http://localhost:8080/".
Access token used for authenticating with the controller.
You can generate an access token with `clusterioctl host generate-token --id `, or use the `clusterioctl host create-config` to create a new host config with the correct url and token in it.
+The value can only be read and changed from the host itself.
Defaults to "enter token here".
@@ -201,14 +358,82 @@ This is used by plugins like Server Select to give the correct address to switch
Defaults to "localhost".
+### host.factorio_port_range
+
+UDP ports the host hands out to instances that don't have factorio.game_port set.
+Written as a comma separated list of ports and/or ranges of ports separated with a dash, for example "34100-34199" or "34100, 34200-34210".
+A port is picked when the instance is started and remembered in factorio.host_assigned_game_port, so an instance keeps the same port as long as it is still free.
+Starting an instance fails if all ports in the range are taken.
+
+Defaults to "34100-34199".
+
+
+### host.factorio_username
+
+Username of the Factorio.com account to put in the server settings of instances on this host.
+If set this takes precedence over the credentials shared by the controller, but not over `username` set in factorio.settings of an instance.
+
+Defaults to null.
+
+
+### host.factorio_token
+
+Token of the Factorio.com account to go with host.factorio_username.
+The value is treated as a credential, it can be set from anywhere but only read back from the host itself.
+
+Defaults to null.
+
+
### host.max_reconnect_delay
Maximum delay in seconds to wait after connection to the controller is dropped before attempting to reconnect to it.
The actual delay on each reconnect will be a random number between 0 and this configured value to avoid all clients trying to reconnect at the same time.
+Must be greater than 0.
Defaults to 60.
+### host.allow_remote_updates
+
+Whether the `host update` command in clusterioctl and the update button in the web interface are allowed to update the `@clusterio/host` package with npm.
+For security reasons this can only be changed locally with `clusteriohost config set` while the host is stopped, and it is hidden in the web interface.
+See [Managing a Cluster](/docs/managing-a-cluster.md) for details on remote updates.
+
+Defaults to true.
+
+
+### host.allow_plugin_updates
+
+Whether remote requests to update plugins installed on the host with npm are allowed.
+Like host.allow_remote_updates this can only be changed locally.
+
+Defaults to true.
+
+
+### host.allow_plugin_install
+
+Whether remote requests to install new plugins on the host with npm are allowed.
+Like host.allow_remote_updates this can only be changed locally.
+
+Defaults to false.
+
+
+### host.version
+
+Version of the host package that last loaded this config.
+Set automatically on startup and used to detect when the host has been updated or downgraded.
+Internal, hidden in the web interface and should not be changed.
+
+
+### .load_plugin
+
+Whether to load the plugin on the host.
+Only plugins with a host or instance part have this field in the host config.
+Plugins not loaded on the host will not be loaded on the instances it hosts.
+
+Defaults to true.
+
+
## Instance Configuration
### instance.name
@@ -221,6 +446,7 @@ Defaults to "New Instance".
### instance.id
Imutable numeric id of the instance which uniquely identifies this instance in the cluster.
+Hidden in the web interface.
Defaluts to a random 31 bit integer.
@@ -230,6 +456,7 @@ Defaluts to a random 31 bit integer.
Host this instance is assigned to.
To change this you need to use the separate assign instance interface.
After being assigned to a host the directory for the instance is created on the host.
+Internal and hidden in the web interface.
Defaults to null meaning not assigned.
@@ -253,23 +480,52 @@ Defaults to false.
### factorio.version
Version of Factorio to use for this instance.
-Can be a version like "1.0.0" or special string "latest" meaning the latest version of Factorio found on the host.
+Can be a specific version like "2.0" or "2.0.15", a release channel like "stable" or "experimental", or the special string "latest" meaning the latest version of Factorio found on the host.
+A release channel is resolved to a version through the latest releases data the controller keeps, and the instance fails to start if that is unavailable.
+Values that don't match one of these forms are rejected.
Defaults to "latest".
+### factorio.executable_path
+
+Path relative to the Factorio installation directory of the executable to run.
+If null the path is auto detected, which is `bin/x64/factorio` on Linux and Windows and `MacOS/factorio` on macOS.
+Only needed in special setups.
+
+Defaults to null.
+
+
+### factorio.shutdown_timeout
+
+Time in seconds to wait for the Factorio server to exit after asking it to stop before the process is killed.
+Set to 0 to wait forever.
+Changing this while the instance is stopping restarts the timer with the new value.
+Must be 0 or greater.
+
+Defaults to 300.
+
+
### factorio.game_port
UDP port to run the Factorio server on.
-If null a random port in the dynamyc range assigned each time the instance starts.
+If null a port from host.factorio_port_range on the host is assigned to the instance.
+Negative values are rejected.
Defaults to null.
+### factorio.host_assigned_game_port
+
+Port from host.factorio_port_range the host has assigned to this instance while factorio.game_port is null.
+Internal, set by the host and only accessible from the host.
+
+
### factorio.rcon_port
TCP port to start the RCON interface on the Factorio server on.
If null a random port in the dynamic range is assigned each time the instance starts.
+Negative values are rejected.
Defaults to null.
@@ -278,16 +534,18 @@ Defaults to null.
Password to use for the RCON interafec.
If null a random secure password is generated and used each time the instance starts.
+The value is treated as a credential and can only be read back by the controller and the host.
Defaults to null.
-### player_online_autosave_slots
+### factorio.player_online_autosave_slots
Keeps a separate pool for autosaves where at least on player has been online since the previous autosave was done.
This requires regular autosaving to be enabled and will rename autosaves to `_autosave_poN.zip` (where N is a slot number) whenever a player has been online for that save.
The slot numbers start at 1, are incremented by 1 after each autosave that is renamed and reset back to 1 whenever this value is exceeded.
-Note that this value should be set to a higher value than the value of `autosave_slots` in `factorio.settings`.
+Setting this to a value lower than `autosave_slots` in factorio.settings is rejected, unless it is 0.
+The same check is done when factorio.settings is changed.
Useful for preventing the loss of all progress when nobody is around on always online servers with auto pause disabled.
If 0 no autosave renaming takes place.
@@ -295,12 +553,34 @@ If 0 no autosave renaming takes place.
Defaults to 5.
+### factorio.mod_pack_id
+
+ID of the mod pack to use on this instance.
+The mods in the pack are linked into the mods directory of the instance every time it starts, and any other files in that directory are removed.
+If null the mod pack set in controller.default_mod_pack_id is used.
+
+Defaults to null.
+
+
### factorio.enable_save_patching
Whether to use save patching or not.
When enabled lua code is patched into the game save before starting it.
Most plugins require the use of save patching to function.
Turning it off makes it possible to use Clusterio to run and manage regular vanilla games and scenarios not compatible with Clusterio.
+Plugins that need save patching can't be loaded on the instance while this is disabled.
+Disabling it while such a plugin is loaded is rejected, and so is loading such a plugin while it is disabled.
+
+Defaults to true.
+
+
+### factorio.enable_script_commands
+
+Allow Clusterio and plugins to send commands that disable achievements to the server over RCON, that is `/c`, `/sc`, `/mc`, `/cheat`, `/editor` and their long forms.
+Clusterio integrations and most plugins need this, and when enabled achievements are disabled on the server at startup.
+When disabled attempts to send such a command fail with an error.
+This does not stop players from using these commands themselves.
+Like factorio.enable_save_patching, plugins that need script commands can't be loaded on the instance while this is disabled.
Defaults to true.
@@ -312,6 +592,13 @@ Turn on the whitelist on the server.
Defaults to false.
+### factorio.enable_authserver_bans
+
+Pass the `--use-authserver-bans` flag to Factorio, which makes the server check connecting players against the multiplayer bans on Factorio.com and report bans and unbans done on the server back to it.
+
+Defaults to false.
+
+
### factorio.settings
Object with the settings to put into `server-settings.json` for the Factorio server.
@@ -319,7 +606,7 @@ The settings in `data/server-settings.example.json` of the Factorio installation
Changes to the following properties will be applied live if the instance is running while it is changed: `afk_autokick_interval`, `allow_commands`, `autosave_interval`, `autosave_only_on_server`, `description`, `ignore_player_limit_for_returning_players`, `max_players`, `max_upload_slots`, `max_upload_in_kilobytes_per_second`, `name`, `only_admins_can_pause_the_game`, `game_password`, `require_user_verification`, `tags`, `visibility`.
-Defaults to {"tags":["clusterio"],"auto_pause":false}.
+Defaults to a set of common settings filled in when the instance is created, such as `"tags": ["clusterio"]`, `"auto_pause": false` and a `name` and `description` based on the cluster and instance names.
### factorio.verbose_logging
@@ -330,6 +617,14 @@ This prints more messages to the log.
Defaults to false.
+### factorio.console_logging
+
+Pass the `--console-log` flag to Factorio to make it write the console output, such as chat, commands and player joins, to `console.log` in the instance directory.
+Useful for 3rd party tools that read the console log of the server.
+
+Defaults to false.
+
+
### factorio.strip_paths
Strip down absolute paths in the server log going to files inside the instance directory such that they are relative to the instance directory.
@@ -352,25 +647,35 @@ Defaults to true.
### factorio.sync_adminlist
Synchronize in-game admin list with admin status of the users in cluster.
+One of "disabled", "enabled" or "bidirectional".
If enabled Clusterio will generate a new `server-adminlist.json` on instance startup based on the users in the cluster with admin status set to true, and keep the admins in sync with the users that have admin status while the instance is running.
+With "bidirectional" players promoted or demoted in-game with `/promote` and `/demote` are also updated in the cluster, and from there on the other instances.
+Configs from before this became a choice are migrated from true and false to "enabled" and "disabled".
-Defaults to true.
+Defaults to "bidirectional".
### factorio.sync_whitelist
Synchronize in-game whitelist with the whitelisted status of the users in the cluster.
+One of "disabled", "enabled" or "bidirectional".
If enabled Clusterio will generate a new `server-whitelist.json` on instance startup based on the users in the cluster with whitelisted status set to true, and keep the whitelisted users in sync with the users that have whitelisted status while the instance in running.
+With "bidirectional" changes made in-game with `/whitelist` are picked up by watching `server-whitelist.json` for changes and updated in the cluster as well.
+This requires factorio.enable_whitelist to be on, as Factorio doesn't write the file otherwise.
+Configs from before this became a choice are migrated from true and false to "enabled" and "disabled".
-Defaults to true.
+Defaults to "bidirectional".
### factorio.sync_banlist
Synchronize in-game banlist with the banned status of users in the cluster.
+One of "disabled", "enabled" or "bidirectional".
If enabled Clusterio will generate a new `server-banlist.json` on instance startup based on users in the cluster with banned status set to true, and keep the banned users in sync with the users that have the banned status while the instance is running.
+With "bidirectional" players banned or unbanned in-game with `/ban` and `/unban` are also updated in the cluster, and from there on the other instances.
+Configs from before this became a choice are migrated from true and false to "enabled" and "disabled".
-Defaults to true.
+Defaults to "bidirectional".
### factorio.max_concurrent_commands
@@ -387,6 +692,7 @@ This means a high number of concurrent commands can amplify resends due to packe
Prior to 0.18.7 the maximum practical size of game update sent where arround 8 kB due to the UDP receive buffer on windows defaulting to this value, larger updates would not get through the receive buffer and cause the connection to drop.
To stay below the 8 kB limit the maximum safe value for this option would be around 7000 / (3 * game_speed * maximum_segment_size), which is around 20 in normal circumstances.
+Must be greater than 0.
Defaults to 5.
@@ -394,7 +700,8 @@ Defaults to 5.
### .load_plugin
Whether to load the given plugin on the instance.
-Note that plugins need to be loaded on the controller in order for them to be loaded on instances.
+Note that plugins need to be loaded on the controller and the host in order for them to be loaded on instances.
+Loading a plugin that requires save patching or script commands is rejected while factorio.enable_save_patching or factorio.enable_script_commands respectively is disabled on the instance.
Defaults to true.
@@ -420,5 +727,6 @@ Defaults to null meaning complain about it not being set and exit.
Max duration in seconds to wait before attempting to reconnect with the controller after the connection is dropped.
The actual delay on each reconnect will be a random number between 0 and this configured value to avoid all clients trying to reconnect at the same time.
+Must be 0 or greater.
Defaults to 60.
diff --git a/docs/contributing.md b/docs/contributing.md
index 1ae0d1d2..62dc3c71 100644
--- a/docs/contributing.md
+++ b/docs/contributing.md
@@ -229,8 +229,8 @@ Files containing and exporting a single class should be named the same as the cl
Relative imports in lib package are prefixed with lib to make them easier to distinguish, as the names tend to be very generic.
E.g.:
- const libLink = require("./link");
- const libLuaTools = require("./lua_tools");
+ import libLink from "./link";
+ import libLuaTools from "./lua_tools";
Note that no sub-modules of the lib package is imported directly from the outside of it with the exception of `build_mod`.
diff --git a/docs/devs/json-serialisable-classes.md b/docs/devs/json-serialisable-classes.md
index ca152c2a..24715bd4 100644
--- a/docs/devs/json-serialisable-classes.md
+++ b/docs/devs/json-serialisable-classes.md
@@ -14,7 +14,7 @@ See the following example code for a hypothetical `Data` class with a single pro
* A Data class example
* @alias module:Data
*/
-class Data {
+export class Data {
/** @type {string} */
member = "foo";
@@ -41,8 +41,6 @@ class Data {
return json;
}
}
-
-module.exports = Data;
```
Serialising an instance of `Data` for JSON is as simple as doing `serialised = JSON.stringify(data)` as the `toJSON` method is automatically called by `JSON.stringify` to create a suitable object.
@@ -53,14 +51,13 @@ For example a class holding a data property with an instance of the Data class f
```js
// Composed.js
-"use strict";
-const Data = require("./Data");
+import Data from "./Data";
/**
* Composed data class example
* @alias module:Data
*/
-class Composed {
+export class Composed {
/** @type {module:Data|undefined} */
data;
@@ -83,8 +80,6 @@ class Composed {
// toJSON is optional and not needed for this particular class
}
-
-module.exports = Composed;
```
It's easy to create a new instance of Composed by calling `new Composed(new Data("spam"))` and serialising to and from JSON is the same as for Data.
diff --git a/docs/how-it-works.md b/docs/how-it-works.md
index 0bc0021b..b1d86f90 100644
--- a/docs/how-it-works.md
+++ b/docs/how-it-works.md
@@ -6,6 +6,7 @@ Mods on the other hand work without modifications.
This can be disabled with the `factorio.enable_save_patching` config on a per instance basis, however disabling it means that modules are not loaded into the game and most plugins will not function.
To use the modules outside of Clusterio the `npx clusteriohost create-scenario` command creates a scenario with the modules patched into it.
+Note that the IPC between the game and the host is not available outside of Clusterio, so modules that depend on it will not work as expected in such a scenario.
1: Before version 2.0 of Clusterio this was done at runtime with a patcher called Hotpatch, but due to the difficulty in supporting run time patching and the lack of documentation it was replace it with save patching instead.
diff --git a/docs/managing-a-cluster.md b/docs/managing-a-cluster.md
index 476d1314..aa3e8587 100644
--- a/docs/managing-a-cluster.md
+++ b/docs/managing-a-cluster.md
@@ -247,6 +247,13 @@ This command lists all plugins currently known to the remote. Newly installed pl
If remote plugin updates are enabled on the remote, this will execute `npm update --save `. Changes will not be applied until the remote is restarted. If the `--restart` flag is provided, the remote will automatically restart once the update is complete.
+### Update All
+
+ ctl> controller update --all [--restart]
+ ctl> host update --all [--restart]
+
+Updates the Clusterio package and all plugins installed from npm in a single `npm update --save` command. Updating everything at once avoids ending up with multiple versions of `@clusterio/lib` in the installation. Requires both remote updates and remote plugin updates to be enabled on the remote.
+
### Install
ctl> controller plugin install [--restart]
diff --git a/docs/writing-plugins.md b/docs/writing-plugins.md
index fb80fef8..b6205037 100644
--- a/docs/writing-plugins.md
+++ b/docs/writing-plugins.md
@@ -1,7 +1,7 @@
# Writing Plugins
-Plugins for Clusterio are classes written in JavaScript that run under Node.js.
-The plugin classes have pre-defined hooks that are called during various stages and operations of Clusterio.
+Plugins for Clusterio are JavaScript modules that run under Node.js.
+Each entrypoint of a plugin exports a function that attaches handlers to hooks called during various stages and operations of Clusterio.
## Contents
@@ -10,6 +10,7 @@ The plugin classes have pre-defined hooks that are called during various stages
- [Defining the plugin class](#defining-the-plugin-class)
- [Logging Messages](#logging-messages)
- [Plugin Configuration](#plugin-configuration)
+- [Plugin Permissions](#plugin-permissions)
- [Communicating with Factorio](#communicating-with-factorio)
- [Defining Link Messages](#defining-link-messages)
- [Sending Link Messages](#sending-link-messages)
@@ -48,12 +49,12 @@ By default the main entrypoint is the `index.js` file, but this may be changed b
Here's an example of `index.js`:
```js
-module.exports.plugin = {
+export const plugin = {
name: "foo_frobber",
title: "Foo Frobber",
description: "Does advanced frobnication",
- instanceEntrypoint: "instance",
- controllerEntrypoint: "controller",
+ instanceEntrypoint: "instance.js",
+ controllerEntrypoint: "controller.js",
messages: {
/* See below */
},
@@ -75,7 +76,7 @@ The following properties are recognized:
Currently not used.
**instanceEntrypoint**:
- Path to a Node.js module relative to the plugin directory which contains the InstancePlugin class definition for this plugin.
+ Path to a Node.js module relative to the plugin directory which exports the instance entrypoint function for this plugin.
This is an optional paramater.
A plugin may have code only for instances but it must still be loaded on the controller in order for it to be possible to load it on an instance.
@@ -84,7 +85,7 @@ The following properties are recognized:
See [Plugin Configuration](#plugin-configuration)
**controllerEntrypoint**:
- Path to a Node.js module relative to the plugin directory which contains the ControllerPlugin class definiton for this plugin.
+ Path to a Node.js module relative to the plugin directory which exports the controller entrypoint function for this plugin.
This is an optional parameter.
A plugin can be made that only runs on the controller.
@@ -93,73 +94,85 @@ The following properties are recognized:
See [Plugin Configuration](#plugin-configuration)
**ctlEntrypoint**:
- Path to a Node.js module relative to the plugin directory which contains the CtlPlugin class definition for this plugin.
+ Path to a Node.js module relative to the plugin directory which exports the clusterioctl entrypoint function for this plugin.
This is an optional paramater.
A plugin can be made that only runs on the clusterioctl side.
+**features**:
+ Array of features the plugin needs from the instances it is loaded on, `"SavePatching"` and/or `"ScriptCommands"`.
+ See [Handling Invalid Configuration](#handling-invalid-configuration)
+
**messages**:
Object with link messages definitions for this plugin.
See guide for [defining link messages](#defining-link-messages) below.
+**permissions**:
+ Array of permission definitions for this plugin.
+ See [Plugin Permissions](#plugin-permissions)
+
The optional module folder contains a Clusterio module that will be patched into the save when the plugin is loaded.
See the section on [Clusterio Modules](developing-for-clusterio.md) in the Developing for Clusterio document.
The only restriction imposed on modules embedded into plugins is that they must be named the same as the plugin.
-While there is no standard for how to organize a plugin it's recommended to put the ControllerPlugin class definition into controller.js and the InstancePlugin class definition into instance.js.
+While there is no standard for how to organize a plugin it's recommended to put the controller entrypoint into controller.js and the instance entrypoint into instance.js.
You can put them into whatever file you want (even the same one for both).
-For both instanceEntrypoint and controllerEntrypoint the path should not end with .js and it should use forward slashes for directory separators if any.
+The entrypoint paths must include the .js extension and use forward slashes for directory separators if any.
-## Defining the plugin class
+## Defining the plugin entrypoint
-The plugin class should derive from its respective base class defined in `lib/plugin`.
-For example, to define a ControllerPlugin class the following code can be used:
+Each entrypoint module exports a default async function which is called once when the plugin is loaded.
+It is passed a context object with what the plugin needs to interact with Clusterio, and attaches handlers to the hooks it is interested in.
+For example, a controller entrypoint can look like this:
```js
-const { BaseControllerPlugin } = require("@clusterio/controller");
+export default async function(context) {
+ const { controller, logger, plugin } = context;
-class ControllerPlugin extends BaseControllerPlugin {
- async init() {
- this.foo = 42;
- await this.startFrobnication();
- }
+ controller.hooks.save.attach(plugin.name, async () => {
+ await saveFrobnicationData();
+ });
- // ...
-}
-
-module.exports = {
- ControllerPlugin,
+ controller.hooks.shutdown.attach(plugin.name, async () => {
+ logger.info("Stopping frobnication");
+ });
}
```
-For the instance plugin it's exactly the same except "Controller" is replaced with "Instance", for host plugins "Host" is used and for the clusterioctl plugin "Ctl" is used.
-The available hooks that you can override are documented in the base classes.
+The context has the following properties:
-It's best to avoid defining a constructor, but if you insist on defining one, forward all arguments to the base class.
-E.g.:
+**plugin**:
+ The plugin's own info, as exported from the main entrypoint.
-```js
-constructor(...args) {
- super(...args);
+**logger**:
+ Logger for this plugin, see [Logging Messages](#logging-messages).
- // Code here
-}
-```
+**controller**, **host**, **instance**, **hooks** or **control**:
+ The object the entrypoint is loaded for.
+ Controller entrypoints get `controller`, host entrypoints get `host`, instance entrypoints get both `instance` and `host`, clusterioctl entrypoints get `hooks` and web entrypoints get `control`.
-The arguments passed may change, and attempting to modify them will result in unpredicatable behaviour.
-The async init method is always called immediatly after the constructor, so there's little reason to do this.
+The available hooks are documented on the `ControllerHooks`, `HostHooks`, `InstanceHooks`, `CtlHooks` and `WebHooks` classes.
+Handlers are attached under a name, which should be the name of the plugin, and a hook only accepts one handler per name.
+An error thrown by a handler is logged and does not affect the other handlers, the same goes for handlers that take more than 15 seconds to complete.
+
+For TypeScript the context types are exported as `ControllerPluginContext`, `HostPluginContext`, `InstancePluginContext`, `CtlPluginContext` and `WebPluginContext` from their respective packages.
+
+### Class based plugins
+
+Plugins written as classes deriving from `BaseControllerPlugin`, `BaseHostPlugin`, `BaseInstancePlugin`, `BaseCtlPlugin` or `BaseWebPlugin` still load, but this way of defining plugins is deprecated and a warning is logged when such a plugin is loaded.
+The base class constructor attaches the `on*` methods overridden by the plugin to the corresponding hooks, and the async `init` method is called immediately after.
## Logging Messages
-The base plugin classes provide a winston logger for logging messages to the shared cluster log.
+The load context provides a winston logger for logging messages to the shared cluster log.
For instances a copy of the log is also stored on the host the instance is on.
To use it, pass a string to one of the log levels functions, for example:
```js
-async init() {
- this.logger.info("Initializing frobbing");
+export default async function(context) {
+ context.logger.info("Initializing frobbing");
}
```
@@ -189,7 +202,7 @@ For guarding unexpected errors the best option is to log a short description alo
try {
// Operation that should not throw but may end up throwing
} catch (err) {
- this.logger.error(`Operation failed:\n${err.stack}`);
+ logger.error(`Operation failed:\n${err.stack}`);
)
```
@@ -225,12 +238,12 @@ export default {
} satisfies lib.PluginDeclaration;
```
-Code inside the `ControllerPlugin` class will then be able to access the level config field through the `Config` object at `this.controller.config`, for example in the ControllerPluginClass:
+The controller entrypoint will then be able to access the level config field through the `Config` object at `controller.config`:
```ts
-async init() {
- let level = this.controller.config.get("foo_frobber.level");
- this.logger.info(`I got a frobnication level of ${level}`);
+export default async function(context: ControllerPluginContext) {
+ const level = context.controller.config.get("foo_frobber.level");
+ context.logger.info(`I got a frobnication level of ${level}`);
}
```
@@ -240,33 +253,76 @@ See [Configuration System](config-system.md) for more details on how this system
### Handling Invalid Configuration
-If the plugin requires a certain feature to be enabled to function it should throw an error during init if this is not the case.
-The most common such feature is the save patching, which can be disabled to run vanilla or scenarios not compatible with Clusterio.
-For example:
+If the plugin requires save patching or script commands to function, which can be disabled to run vanilla or scenarios not compatible with Clusterio, declare it under `features` in the `plugin` export:
```js
-async init() {
- if (!this.instance.config.get("factorio.enable_save_patching")) {
- throw new Error("foo_frobber plugin requires save patching.");
+export const plugin = {
+ name: "foo_frobber",
+ // ...
+ features: ["SavePatching"],
+};
+```
+
+Enabling the plugin on an instance that has the feature turned off then fails config validation, so the mistake is caught when the config is edited instead of when the instance starts.
+
+Other constraints on config fields are expressed with a `validator` on the field definition, which throws when the value is not acceptable.
+Use `dependsOn` when the check involves other fields so that the validator runs when those change too.
+See [Configuration System](config-system.md) for details.
+
+
+## Plugin Permissions
+
+Permissions are granted to roles and checked on users, both on the controller and in the web UI.
+A plugin declares the permissions it uses under `permissions` in the `plugin` export.
+Names must start with the plugin name followed by a dot.
+For example in index.ts:
+
+```ts
+import type * as lib from "@clusterio/lib";
+
+declare module "@clusterio/lib" {
+ // Extend the interface of known permission names so that
+ // user.checkPermission(...) accepts our permission.
+ export interface Permissions {
+ "foo_frobber.frobnicate": never;
}
}
+
+export default {
+ ...
+ permissions: [
+ {
+ name: "foo_frobber.frobnicate",
+ title: "Frobnicate",
+ description: "Run frobnication on instances.",
+ grantByDefault: false, // Whether the generated Player role is granted this permission
+ },
+ ],
+} satisfies lib.PluginDeclaration;
```
+The `Permissions` interface only exists at the type level, its values are always `never` and only the keys are used.
+Adding your permission names to it gives you autocomplete and a compile error on typos when calling `user.checkPermission`, `account.hasPermission` or setting the `permission` of a web UI page.
+In JavaScript the `declare module` block is not needed.
+
+Permissions declared this way are registered when the plugin is loaded.
+The older `lib.definePermission()` function still works, but a plugin should not use both for the same permission name.
+
## Communicating with Factorio
For pushing data into Factorio there's RCON, which lets you send arbitrary Lua commands to invoke whatever code you want in the game.
-This is done by calling the `sendRcon` method on the plugin object.
+This is done by calling the `sendRcon` method on the instance.
For example:
```js
-async onStart() {
- let response = await this.sendRcon(
+instance.hooks.start.attach(plugin.name, async () => {
+ const response = await instance.sendRcon(
"/sc rcon.print('data')"
);
// Do stuff with response.
-}
+});
```
Because data into Factorio is streamed at a rate of 3-6 kB/s by default, it is recommended to avoid sending large commands as much as possible, and to strip down the data on the ones you send to only what's strictly necessary.
@@ -279,15 +335,16 @@ From a plugin you listen for an event named `ipc-channel_name` in order to get d
For example in the plugin code:
```js
-async init() {
- this.instance.server.on("ipc-my_plugin_foo", content =>
- this.handleFoo(content).catch(err => this.logger.error(
+export default async function(context) {
+ const { instance, logger } = context;
+ instance.server.on("ipc-my_plugin_foo", content =>
+ handleFoo(content).catch(err => logger.error(
`Error handling foo:\n${err.stack}`
))
);
}
-async handleFoo(content) {
+async function handleFoo(content) {
// Do stuff with content
}
```
@@ -304,11 +361,11 @@ clusterio_api.send_json("my_plugin_foo", { data = 123 })
For convience a helper function is exposed on server specifically for handling IPC events.
```js
-async init() {
- this.instance.server.handle("my_plugin_foo", this.handleFoo.bind(this));
+export default async function(context) {
+ context.instance.server.handle("my_plugin_foo", handleFoo);
}
-async handleFoo(content) {
+async function handleFoo(content) {
// Do stuff with content
}
```
@@ -445,9 +502,9 @@ Link messages are sent by calling the `.sendTo()` method on a connection object
For example:
```js
-// In an InstancePlugin class
-async frobnicate() {
- const response = await this.instance.sendTo("controller", new messages.Frobnicate({
+// In an instance entrypoint
+async function frobnicate(instance) {
+ const response = await instance.sendTo("controller", new messages.Frobnicate({
verbosity: 2,
special: false,
}));
@@ -464,14 +521,14 @@ Here is what that looks like in practice:
```js
// ❌ BAD: The extracted method loses its `this` binding and crashes when called.
-const send = this.instance.sendTo;
+const send = instance.sendTo;
send("controller", new messages.Frobnicate({ foo: "bar" }));
// ✅ GOOD: Call it directly on the connection object. (Cast args/results here if needed).
-this.instance.sendTo("controller", new messages.Frobnicate({ foo: "bar" }));
+instance.sendTo("controller", new messages.Frobnicate({ foo: "bar" }));
// ✅ GOOD: Explicitly bind the receiver if you need to pass a method as a value (like a handler).
-this.instance.handle(messages.Frobnicate, this.handleFrobnicate.bind(this));
+instance.handle(messages.Frobnicate, plugin.handleFrobnicate.bind(plugin));
```
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.
@@ -493,23 +550,23 @@ sendTo({ controlId: 123 }, message);
There are a few connection related events that plugins neeed to repsond to in order to avoid data loss and connection problems.
The most important is the prepare disconnect for the link between controller and host.
-This is signaled to `ControllerPlugin` classes via the `onPrepareHostDisconnect` hook and to `InstancePlugin` classes via the `onPrepareControllerDisconnect` hook.
+This is signaled to controller plugins via the `prepareHostDisconnect` hook and to instance plugins via the `prepareControllerDisconnect` hook.
After the prepare disconnect the connection will be closed, which will result in pending requests and events being dropped.
Plugins must respond to the prepare disconnect by stopping any processess it does that send events or requests over the link in question.
This can be accomplished either through listening for the prepare disconnect hook, or by checking the `connected` property of the `HostConnection` class and `Host` class on the controller and host respectively.
-For example the sending of an event from an `InstancePlugin` class can be stopped while the connection is not connected, not in the dropped state, and not in the process of discunnecting by using the following code:
+For example the sending of an event from an instance plugin can be stopped while the connection is not connected, not in the dropped state, and not in the process of discunnecting by using the following code:
```js
-if (this.host.connected) {
- this.instance.sendTo("controller", new messages.Frobnicate({ foo: "bar" }));
+if (host.connected) {
+ instance.sendTo("controller", new messages.Frobnicate({ foo: "bar" }));
}
```
-If the event or request needs to be sent to the controller it can be put into a queue stored on the plugin instance and sent out when the connection is established again.
-The re-establishement of the connection is notified to plugins via the `connect` event to the `onControllerConnectionEvent` and `onHostConnectionEvent` hooks.
+If the event or request needs to be sent to the controller it can be put into a queue and sent out when the connection is established again.
+The re-establishement of the connection is notified to plugins via the `connect` event to the `controllerConnectionEvent` and `hostConnectionEvent` hooks.
-The second connection event which is of lesser importance to respond to is the `drop` connection event served through `onControllerConnectionEvent` for `InstancePlugin` classes and through `onHostConnectionEvent` for `ControllerPlugin` classes.
+The second connection event which is of lesser importance to respond to is the `drop` connection event served through `controllerConnectionEvent` for instance plugins and through `hostConnectionEvent` for controller plugins.
This is raised when the connection between the controller and host in question is lost, most likely due to networking issues.
When in the dropped state the host will keep trying to reconnect to the controller in order to re-establish it, and if successful no events or requests will be lost.
However while in the dropped state any requests and events sent gets queued up in memory until the connection is either re-established or the session times out.
@@ -525,7 +582,7 @@ In its simplest form collecting data from plugins consists of defining the metri
For example:
```js
-const { Counter } = require("@clusterio/lib");
+import { Counter } from "@clusterio/lib";
const fooMetric = new Counter(
"clusterio_foo_frobber_foo_metric", "Measures the level of foo",
@@ -541,7 +598,7 @@ It's recommended that plugin metrics follow `clusterio__ {
+ frobnicationsMetric.set(frobnications.size);
+});
+```
+
+A handler may also return the results of collecting collectors that are not in the default registry, and they are added to the response.
+
+```js
+import { CollectorRegistry, Counter } from "@clusterio/lib";
+
+const fooRegistry = new CollectorRegistry();
+const fooCounter = new Counter("clusterio_foo_frobber_foos", "Foos frobbed", { register: false });
+fooRegistry.register(fooCounter);
+
+controller.hooks.metrics.attach(plugin.name, async () => fooRegistry.collect());
+```
## Adding Custom Commands to clusterioctl
@@ -565,7 +647,7 @@ The control entrypoint for plugins allows you to extend clustectl with your own
The creation of custom commands typically starts with defining a command tree for the plugin:
```js
-const { Command, CommandTree } = require("@clusterio/lib");
+import { Command, CommandTree } from "@clusterio/lib";
const fooFrobberCommands = new CommandTree({
name: "foo-frobber", description: "Foo Frobber Plugin commands"
});
@@ -574,7 +656,7 @@ const fooFrobberCommands = new CommandTree({
Then commands are added to the the plugin's command tree:
```js
-const info = require("./info");
+import { messages } from "./index.js";
fooFrobberCommands.add(new Command({
definition: ["frobnicate ", "Do frobnications", (yargs) => {
@@ -593,22 +675,14 @@ fooFrobberCommands.add(new Command({
For a command the `definition` is the arguments to pass to [yargs.command](http://yargs.js.org/docs/#api-reference-commandcmd-desc-builder-handler) (see also [yargs.positional](http://yargs.js.org/docs/#api-reference-positionalkey-opt) and [yargs.options](http://yargs.js.org/docs/#api-reference-optionskey-opt) for setting up positional and optional arguments to commands).
The `handler` is an async function that's invoked when the command is executed and it's passed the parsed command line arguments and a reference to the `Control` class of clusterioctl.
-It's possible to optain a reference to the plugin class with `control.plugins.get(info.name)`.
-
Note that messages sent from clusterioctl needs to have `"control-controller"` as a part of the links array for it to be accepted by the controller, see [Defining Link Messages](#defining-link-messages) for how to define the messages that can be sent to the controller.
-To have the command tree become part of clusterioctl it needs to be added to the rootCommand tree in `addCommands` callback of the Ctl plugin:
+To have the command tree become part of clusterioctl it needs to be added to the rootCommand tree in the `addCommands` hook of the ctl entrypoint:
```js
-const { BaseCtlPlugin } = require("@clusterio/ctl");
-
-class CtlPlugin extends BaseCtlPlugin {
- async addCommands(rootCommand) {
+export default async function(context) {
+ context.hooks.addCommands.attach(context.plugin.name, async (rootCommand) => {
rootCommand.add(fooFrobberCommands);
- }
-}
-
-module.exports = {
- CtlPlugin,
+ });
}
```
diff --git a/eslint.config.mjs b/eslint.config.mjs
index d7b7e8e2..8fc661f1 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -418,7 +418,7 @@ export default defineConfig([{
...globals.mocha,
},
- sourceType: "commonjs",
+ sourceType: "module",
parserOptions: {},
},
@@ -489,7 +489,7 @@ export default defineConfig([{
"node/shebang": "off",
},
}, {
- files: ["packages/create/templates/**/*.{t,j}s?(x)"],
+ files: ["packages/create/templates/**/*.?(c){t,j}s?(x)"],
"rules": {
"node/no-missing-require": "off",
diff --git a/package.json b/package.json
index dc551a8c..972d08c9 100644
--- a/package.json
+++ b/package.json
@@ -3,6 +3,7 @@
"private": true,
"files": [],
"packageManager": "pnpm@10.28.1+sha512.7d7dbbca9e99447b7c3bf7a73286afaaf6be99251eb9498baefa7d406892f67b879adb3a1d7e687fc4ccc1a388c7175fbaae567a26ab44d1067b54fcb0d6a316",
+ "type": "module",
"scripts": {
"build": "tsc --build",
"watch": "tsc --build --watch",
diff --git a/packages/controller/controller.ts b/packages/controller/controller.ts
index 44d92502..a61a0080 100644
--- a/packages/controller/controller.ts
+++ b/packages/controller/controller.ts
@@ -27,12 +27,12 @@ import jwt from "jsonwebtoken";
// homebrew modules
import * as lib from "@clusterio/lib";
-const { ConsoleTransport, levels, logger } = lib;
+import { ConsoleTransport, levels, logger } from "@clusterio/lib";
-import Controller from "./src/Controller";
-import UserManager from "./src/UserManager";
-import UserRecord from "./src/UserRecord";
-import { version } from "./package.json";
+import Controller from "./src/Controller.js";
+import UserManager from "./src/UserManager.js";
+import UserRecord from "./src/UserRecord.js";
+import packageConfig from "./package.json" with { type: "json" };
// globals
let controller: Controller;
@@ -206,7 +206,7 @@ async function handleBootstrapCommand(
console.log(content);
} else {
logger.info(`Writing ${args.output}`);
- await lib.safeOutputFile(args.output, content);
+ await lib.safeOutputFile(args.output, content, { mode: 0o600 });
}
}
}
@@ -216,7 +216,7 @@ async function handleCopyStaticCommand(
pluginInfos: lib.PluginNodeEnvInfo[],
) {
await fs.cp(
- path.join(__dirname, "..", "web", "static"),
+ path.join(import.meta.dirname, "..", "web", "static"),
args.target,
{
recursive: true,
@@ -268,7 +268,7 @@ interface InitializeParameters {
async function initialize(): Promise {
// argument parsing
- const args = await yargs
+ const args = await yargs(process.argv.slice(2))
.scriptName("controller")
.usage("$0 [options]")
.option("log-level", {
@@ -340,7 +340,7 @@ async function initialize(): Promise {
})
.demandCommand(1, "You need to specify a command to run")
.strict()
- .argv
+ .parse()
;
// Combined log stream of the whole cluster.
@@ -377,7 +377,7 @@ async function initialize(): Promise {
let command = args._[0];
let shouldRun = false;
if (command === "run") {
- logger.info(`Starting Clusterio controller ${version}`);
+ logger.info(`Starting Clusterio controller ${packageConfig.version}`);
if (args.recovery) {
logger.warn("Controller recovery mode enabled. Some features will be disabled.");
}
@@ -402,6 +402,7 @@ async function initialize(): Promise {
logger.info("Loading Plugin info");
const pluginInfos = await lib.loadPluginInfos(pluginList);
lib.registerPluginMessages(pluginInfos);
+ lib.registerPluginPermissions(pluginInfos);
lib.addPluginConfigFields(pluginInfos);
const controllerConfigPath = args.config;
@@ -426,7 +427,7 @@ async function initialize(): Promise {
}
}
- controllerConfig.set("controller.version", version); // Allows tracking last loaded version
+ controllerConfig.set("controller.version", packageConfig.version); // Allows tracking last loaded version
if (!controllerConfig.get("controller.auth_secret")) {
logger.info("Generating new controller authentication secret");
@@ -537,6 +538,6 @@ ${err.stack}`
});
}
-if (module === require.main) {
+if (import.meta.main) {
bootstrap();
}
diff --git a/packages/controller/index.ts b/packages/controller/index.ts
index 4e7b2b63..6a8937b6 100644
--- a/packages/controller/index.ts
+++ b/packages/controller/index.ts
@@ -1,17 +1,17 @@
-import { bootstrap } from "./controller";
-export { default as Controller } from "./src/Controller";
-export { default as ControllerRouter } from "./src/ControllerRouter";
-export { default as ControlConnection } from "./src/ControlConnection";
-export { default as HostConnection } from "./src/HostConnection";
-export { default as InstanceRecord } from "./src/InstanceRecord";
-export { default as InstanceManager } from "./src/InstanceManager";
-export { default as HostRecord } from "./src/HostRecord";
-export { default as BaseControllerPlugin } from "./src/BaseControllerPlugin";
-export { default as UserManager } from "./src/UserManager";
-export { default as UserRecord } from "./src/UserRecord";
-export { default as User } from "./src/User";
-export { default as WsServer } from "./src/WsServer";
+import { bootstrap } from "./controller.js";
+export { default as Controller } from "./src/Controller.js";
+export { default as ControllerRouter } from "./src/ControllerRouter.js";
+export { default as ControlConnection } from "./src/ControlConnection.js";
+export { default as HostConnection } from "./src/HostConnection.js";
+export { default as InstanceRecord } from "./src/InstanceRecord.js";
+export { default as InstanceManager } from "./src/InstanceManager.js";
+export { default as HostRecord } from "./src/HostRecord.js";
+export { BaseControllerPlugin, ControllerHooks, type ControllerPluginContext } from "./src/BaseControllerPlugin.js";
+export { default as UserManager } from "./src/UserManager.js";
+export { default as UserRecord } from "./src/UserRecord.js";
+export { default as User } from "./src/User.js";
+export { default as WsServer } from "./src/WsServer.js";
-if (module === require.main) {
+if (import.meta.main) {
bootstrap();
}
diff --git a/packages/controller/package.json b/packages/controller/package.json
index cc070c83..0ff9fda0 100644
--- a/packages/controller/package.json
+++ b/packages/controller/package.json
@@ -22,6 +22,7 @@
"access": "public"
},
+ "type": "module",
"main": "dist/node/index.js",
"scripts": {
"prepare": "tsc --build && webpack-cli --mode production",
diff --git a/packages/controller/src/BaseConnection.ts b/packages/controller/src/BaseConnection.ts
index 76949eb8..b04c1946 100644
--- a/packages/controller/src/BaseConnection.ts
+++ b/packages/controller/src/BaseConnection.ts
@@ -1,12 +1,12 @@
import path from "path";
import fs from "node:fs/promises";
-import type Controller from "./Controller";
-import type WsServerConnector from "./WsServerConnector";
+import type Controller from "./Controller.js";
+import type WsServerConnector from "./WsServerConnector.js";
import * as lib from "@clusterio/lib";
-const { logger } = lib;
-import * as routes from "./routes";
+import { logger } from "@clusterio/lib";
+import * as routes from "./routes.js";
/**
diff --git a/packages/controller/src/BaseControllerPlugin.ts b/packages/controller/src/BaseControllerPlugin.ts
index 7b052b42..e515e82d 100644
--- a/packages/controller/src/BaseControllerPlugin.ts
+++ b/packages/controller/src/BaseControllerPlugin.ts
@@ -1,45 +1,35 @@
-import type {
- CollectorResult, Event, InstanceStatus, Logger,
- ModPack, ModInfo, PlayerEvent, PluginNodeEnvInfo,
-} from "@clusterio/lib";
-import type Controller from "./Controller";
-import type InstanceRecord from "./InstanceRecord";
-import type ControlConnection from "./ControlConnection";
-import type HostConnection from "./HostConnection";
+import type Controller from "./Controller.js";
+import type InstanceRecord from "./InstanceRecord.js";
+import type ControlConnection from "./ControlConnection.js";
+import type HostConnection from "./HostConnection.js";
+import * as lib from "@clusterio/lib";
+
+export type ControllerPluginContext = lib.PluginLoadContext<{
+ controller: Controller;
+ metrics: any;
+}>;
/**
- * Base class for controller plugins
- *
- * Controller plugins are subclasses of this class which get instantiated by
- * the controller on startup when the plugin is enabled in the config.
- * To be discovered the class must be exported under the name `ControllerPlugin`
- * in the module specified by the `controllerEntrypoint` in the plugin's
- * `plugin` export.
+ * Collection of controller plugin hooks
*/
-export default class BaseControllerPlugin {
- /**
- * Logger for this plugin
- *
- * Instance of winston Logger for sending log messages from this
- * plugin. Supported methods and their corresponding log levels are
- * `error`, `warn`, `audit`, `info` and `verbose`.
- */
- logger: Logger;
-
- constructor(
- public info: PluginNodeEnvInfo,
- public controller: Controller,
- public metrics: any,
- logger: Logger
- ) {
- this.logger = logger.child({ plugin: this.info.name }) as unknown as Logger;
+export class ControllerHooks extends lib.AsyncHookCollection {
+ constructor(logger: lib.Logger) {
+ super(logger);
+ this.save = this.newHook();
+ this.metrics = this.newHook();
+ this.shutdown = this.newHook();
+ this.instanceStatusChanged = this.newHook();
+ this.instanceConfigFieldChanged = this.newHook();
+ this.controllerConfigFieldChanged = this.newHook();
+ this.controlConnectionEvent = this.newHook();
+ this.hostConnectionEvent = this.newHook();
+ this.prepareHostDisconnect = this.newHook();
+ this.modPacksUpdated = this.newHook();
+ this.modsUpdated = this.newHook();
+ this.rolesUpdated = this.newHook();
+ this.playerEvent = this.newHook();
}
- /**
- * Called immediately after the class is instantiated
- */
- async init() { }
-
/**
* Called when the controller saves data in memory to disk
*
@@ -51,7 +41,7 @@ export default class BaseControllerPlugin {
* BaseControllerPlugin.onShutdown} have been invoked and all links have
* been disconnected.
*/
- async onSaveData() { }
+ readonly save: lib.AsyncHook<[]>;
/**
* Called when the status of an instance changes
@@ -91,7 +81,7 @@ export default class BaseControllerPlugin {
* The instance that changed.
* @param prev - the previous status of the instance.
*/
- async onInstanceStatusChanged(instance: InstanceRecord, prev?: InstanceStatus) { }
+ readonly instanceStatusChanged: lib.AsyncHook<[instance: InstanceRecord, prev: lib.InstanceStatus | undefined]>;
/**
* Called when the value of a controller config field changed.
@@ -103,7 +93,7 @@ export default class BaseControllerPlugin {
* @param curr - The current value of the field.
* @param prev - The previous value of the field.
*/
- async onControllerConfigFieldChanged(field: string, curr: unknown, prev: unknown) { }
+ readonly controllerConfigFieldChanged: lib.AsyncHook<[field: string, curr: unknown, prev: unknown]>;
/**
* Called when the value of an instance config field changed.
@@ -117,7 +107,8 @@ export default class BaseControllerPlugin {
* @param curr - The current value of the field.
* @param prev - The previous value of the field.
*/
- async onInstanceConfigFieldChanged(instance: InstanceRecord, field: string, curr: unknown, prev: unknown) { }
+ // eslint-disable-next-line max-len
+ readonly instanceConfigFieldChanged: lib.AsyncHook<[instance: InstanceRecord, field: string, curr: unknown, prev: unknown]>;
/**
* Called before collecting Prometheus metrics
@@ -134,12 +125,12 @@ export default class BaseControllerPlugin {
*
* @returns an async iterator of prometheus metric results or undefined.
*/
- async onMetrics(): Promise> { }
+ readonly metrics: lib.AsyncHook<[], AsyncIterable>;
/**
* Called when the controller is shutting down
*/
- async onShutdown() { }
+ readonly shutdown: lib.AsyncHook<[]>;
/**
* Called when an event on a host connection happens
@@ -174,7 +165,8 @@ export default class BaseControllerPlugin {
* The connection the event occured on.
* @param event - one of connect, drop, resume and close
*/
- onHostConnectionEvent(connection: HostConnection, event: "connect" | "drop" | "resume" | "close") { }
+ // eslint-disable-next-line max-len
+ readonly hostConnectionEvent: lib.AsyncHook<[connection: HostConnection, event: "connect" | "drop" | "resume" | "close"]>;
/**
* Called when an avent on a control connection happens
@@ -210,7 +202,8 @@ export default class BaseControllerPlugin {
* The connection the event occured on.
* @param event - one of connect, drop, resume, and close.
*/
- onControlConnectionEvent(connection: ControlConnection, event: "connect" | "drop" | "resume" | "close") { }
+ // eslint-disable-next-line max-len
+ readonly controlConnectionEvent: lib.AsyncHook<[connection: ControlConnection, event: "connect" | "drop" | "resume" | "close"]>;
/**
* Called when a host is preparing to disconnect from the controller
@@ -224,7 +217,7 @@ export default class BaseControllerPlugin {
* @param connection -
* The connection to the host preparing to disconnect.
*/
- async onPrepareHostDisconnect(connection: HostConnection) { }
+ readonly prepareHostDisconnect: lib.AsyncHook<[connection: HostConnection]>;
/**
* Called when one or more mod packs are updated
@@ -237,7 +230,7 @@ export default class BaseControllerPlugin {
*
* @param modPacks - Mod packs that updated.
*/
- async onModPacksUpdated(modPacks: ModPack[]) { }
+ readonly modPacksUpdated: lib.AsyncHook<[modPacks: lib.ModPack[]]>;
/**
* Called when one or more mod stored on the controller are updated
@@ -249,7 +242,18 @@ export default class BaseControllerPlugin {
*
* @param mods - Mods that updated.
*/
- async onModsUpdated(mods: ModInfo[]) { }
+ readonly modsUpdated: lib.AsyncHook<[mods: lib.ModInfo[]]>;
+
+ /**
+ * Called when one or more roles stored on the controller are updated
+ *
+ * Invoked when one or more roles have been added, updated or deleted.
+ *
+ * If a role has been deleted its `.isDeleted` property will be true.
+ *
+ * @param roles - Roles that updated.
+ */
+ readonly rolesUpdated: lib.AsyncHook<[roles: lib.Role[]]>;
/**
* Called when a player joins or leaves an instance
@@ -261,7 +265,79 @@ export default class BaseControllerPlugin {
* The instance it occured on.
* @param event - Information about the event.
*/
- async onPlayerEvent(instance: InstanceRecord, event: PlayerEvent) { }
+ readonly playerEvent: lib.AsyncHook<[instance: InstanceRecord, event: lib.PlayerEvent]>;
+}
+
+// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
+export interface BaseControllerPlugin {
+ onSaveData?(): Promise;
+ onMetrics?(): Promise>;
+ onShutdown?(): Promise;
+ onInstanceStatusChanged?(instance: InstanceRecord, prev?: lib.InstanceStatus): Promise;
+ onControllerConfigFieldChanged?(field: string, curr: unknown, prev: unknown): Promise;
+ onInstanceConfigFieldChanged?(instance: InstanceRecord, field: string, curr: unknown, prev: unknown): Promise;
+ onHostConnectionEvent?(connection: HostConnection, event: "connect" | "drop" | "resume" | "close"): void;
+ onControlConnectionEvent?(connection: ControlConnection, event: "connect" | "drop" | "resume" | "close"): void;
+ onPrepareHostDisconnect?(connection: HostConnection): Promise;
+ onModPacksUpdated?(modPacks: lib.ModPack[]): Promise;
+ onModsUpdated?(mods: lib.ModInfo[]): Promise;
+ onRolesUpdated?(roles: lib.Role[]): Promise;
+ onPlayerEvent?(instance: InstanceRecord, event: lib.PlayerEvent): Promise;
+}
+
+/**
+ * Base class for controller plugins
+ *
+ * Controller plugins are subclasses of this class which get instantiated by
+ * the controller on startup when the plugin is enabled in the config.
+ * To be discovered the class must be exported under the name `ControllerPlugin`
+ * in the module specified by the `controllerEntrypoint` in the plugin's
+ * `plugin` export.
+ */
+// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
+export class BaseControllerPlugin {
+ constructor(
+ public info: lib.PluginNodeEnvInfo,
+ public controller: Controller,
+ public metrics: any,
+ public logger: lib.Logger,
+ ) {
+ const attach = (
+ hook: lib.AsyncHook,
+ fn?: lib.HookHandler,
+ ) => {
+ if (fn) {
+ hook.attach(info.name, fn.bind(this));
+ }
+ };
+
+ attach(controller.hooks.save, this.onSaveData);
+ attach(controller.hooks.instanceStatusChanged, this.onInstanceStatusChanged);
+ attach(controller.hooks.controllerConfigFieldChanged, this.onControllerConfigFieldChanged);
+ attach(controller.hooks.instanceConfigFieldChanged, this.onInstanceConfigFieldChanged);
+ attach(controller.hooks.metrics, this.onMetrics);
+ attach(controller.hooks.shutdown, this.onShutdown);
+ attach(controller.hooks.hostConnectionEvent, this.onHostConnectionEvent);
+ attach(controller.hooks.controlConnectionEvent, this.onControlConnectionEvent);
+ attach(controller.hooks.prepareHostDisconnect, this.onPrepareHostDisconnect);
+ attach(controller.hooks.modPacksUpdated, this.onModPacksUpdated);
+ attach(controller.hooks.modsUpdated, this.onModsUpdated);
+ attach(controller.hooks.rolesUpdated, this.onRolesUpdated);
+ attach(controller.hooks.playerEvent, this.onPlayerEvent);
+ }
+
+ static fromContext(context: ControllerPluginContext) {
+ return new this(context.plugin, context.controller, context.metrics, context.logger);
+ }
+
+ detachHooks() {
+ this.controller.hooks.detachAll(this.info.name);
+ }
+
+ /**
+ * Called immediately after the class is instantiated
+ */
+ async init() {}
/**
* Broadcast event to all connected hosts
@@ -272,7 +348,7 @@ export default class BaseControllerPlugin {
*
* @param event - Event to send
*/
- broadcastEventToHosts(event: Event) {
+ broadcastEventToHosts(event: lib.Event) {
for (let hostConnection of this.controller.wsServer.hostConnections.values()) {
if (
!hostConnection.connector.closing
diff --git a/packages/controller/src/ControlConnection.ts b/packages/controller/src/ControlConnection.ts
index c9c9f8b0..b502429b 100644
--- a/packages/controller/src/ControlConnection.ts
+++ b/packages/controller/src/ControlConnection.ts
@@ -1,15 +1,15 @@
-import type WsServerConnector from "./WsServerConnector";
+import type WsServerConnector from "./WsServerConnector.js";
import events from "events";
import * as lib from "@clusterio/lib";
-const { logFilter, logger } = lib;
+import { logFilter, logger } from "@clusterio/lib";
-import BaseConnection from "./BaseConnection";
-import User from "./User";
-import UserRecord from "./UserRecord";
-import * as routes from "./routes";
-import Controller from "./Controller";
+import BaseConnection from "./BaseConnection.js";
+import User from "./User.js";
+import UserRecord from "./UserRecord.js";
+import * as routes from "./routes.js";
+import Controller from "./Controller.js";
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
@@ -63,9 +63,7 @@ export default class ControlConnection extends BaseConnection {
for (let event of ["connect", "drop", "resume", "close"] as const) {
this.connector.on(event, () => {
- for (let controllerPlugin of this._controller.plugins.values()) {
- controllerPlugin.onControlConnectionEvent(this, event);
- }
+ this._controller.hooks.controlConnectionEvent.invoke(this, event);
});
}
@@ -128,6 +126,7 @@ export default class ControlConnection extends BaseConnection {
this.handle(lib.PluginListRequest, this.handlePluginListRequest.bind(this));
this.handle(lib.PluginUpdateRequest, this.handlePluginUpdateRequest.bind(this));
this.handle(lib.PluginInstallRequest, this.handlePluginInstallRequest.bind(this));
+ this.handle(lib.UpdateAllRequest, this.handleUpdateAllRequest.bind(this));
this.handle(lib.DebugDumpWsRequest, this.handleDebugDumpWsRequest.bind(this));
}
@@ -169,7 +168,7 @@ export default class ControlConnection extends BaseConnection {
}
if (typeof permission === "string") {
- this.user.checkPermission(permission);
+ this.user.checkPermission(permission as lib.PermissionName);
return;
}
@@ -1307,10 +1306,20 @@ export default class ControlConnection extends BaseConnection {
return await lib.handlePluginInstall(request.pluginPackage);
}
+ async handleUpdateAllRequest(request: lib.UpdateAllRequest) {
+ if (!this._controller.config.get("controller.allow_remote_updates")) {
+ throw new lib.RequestError("Remote updates are disabled on this machine");
+ }
+ if (!this._controller.config.get("controller.allow_plugin_updates")) {
+ throw new lib.RequestError("Plugin updates are disabled on this machine");
+ }
+ return await lib.handleUpdateAll("@clusterio/controller", this._controller.pluginInfos);
+ }
+
async handlePluginListRequest(request: lib.PluginListRequest) {
return this._controller.pluginInfos.map(pluginInfo => lib.PluginDetails.fromNodeEnvInfo(
pluginInfo,
- this._controller.plugins.has(pluginInfo.name),
+ this._controller.loadedPlugins.has(pluginInfo),
this._controller.config.get(`${pluginInfo.name}.load_plugin`),
));
}
@@ -1325,5 +1334,3 @@ export default class ControlConnection extends BaseConnection {
this._controller.debugEvents.on("message", this.ws_dumper);
}
}
-
-module.exports = ControlConnection;
diff --git a/packages/controller/src/Controller.ts b/packages/controller/src/Controller.ts
index bcbdd007..d3b09b3a 100644
--- a/packages/controller/src/Controller.ts
+++ b/packages/controller/src/Controller.ts
@@ -1,6 +1,6 @@
import type winston from "winston";
import { BlockList, type AddressInfo, isIPv4, isIPv6 } from "net";
-import type { ControllerArgs } from "../controller";
+import type { ControllerArgs } from "../controller.js";
import express, { type Request, type Response, type NextFunction, type Application } from "express";
import type { Static } from "@sinclair/typebox";
import finalhandler from "finalhandler";
@@ -14,22 +14,23 @@ import jwt from "jsonwebtoken";
import path from "path";
import semver from "semver";
import stream from "stream";
+import { fileURLToPath, pathToFileURL } from "node:url";
import * as lib from "@clusterio/lib";
-const { logger, Summary, Gauge } = lib;
+import { logger, Summary, Gauge } from "@clusterio/lib";
-import HttpCloser from "./HttpCloser";
-import InstanceRecord from "./InstanceRecord";
-import * as metrics from "./metrics";
-import * as routes from "./routes";
-import User from "./User";
-import UserRecord from "./UserRecord";
-import UserManager from "./UserManager";
-import WsServer from "./WsServer";
-import HostRecord from "./HostRecord";
-import BaseControllerPlugin from "./BaseControllerPlugin";
-import ControllerRouter from "./ControllerRouter";
-import InstanceManager from "./InstanceManager";
+import HttpCloser from "./HttpCloser.js";
+import InstanceRecord from "./InstanceRecord.js";
+import * as metrics from "./metrics.js";
+import * as routes from "./routes.js";
+import User from "./User.js";
+import UserRecord from "./UserRecord.js";
+import UserManager from "./UserManager.js";
+import WsServer from "./WsServer.js";
+import HostRecord from "./HostRecord.js";
+import { BaseControllerPlugin, ControllerHooks } from "./BaseControllerPlugin.js";
+import ControllerRouter from "./ControllerRouter.js";
+import InstanceManager from "./InstanceManager.js";
const endpointDurationSummary = new Summary(
"clusterio_controller_http_endpoint_duration_seconds",
@@ -69,8 +70,10 @@ export default class Controller {
httpsServer: https.Server | null = null;
httpsServerCloser: HttpCloser | null = null;
- /** Mapping of plugin name to loaded plugin */
- plugins: Map = new Map();
+ /** Hooks which plugins can attach to */
+ hooks = new ControllerHooks(logger);
+ /** Plugins which are currently loaded */
+ loadedPlugins: Set = new Set();
/** WebSocket server */
wsServer: WsServer;
@@ -345,7 +348,7 @@ export default class Controller {
// Start webpack development server if enabled
if (args.dev || args.devPlugin) {
- this._startDevServer(args);
+ await this._startDevServer(args);
}
this.config.on("fieldChanged", (field, curr, prev) => {
@@ -358,7 +361,7 @@ export default class Controller {
} else if (field === "controller.static_url") {
this.app.locals.staticRoot = this.config.get("controller.static_url");
}
- lib.invokeHook(this.plugins, "onControllerConfigFieldChanged", field, curr, prev);
+ this.hooks.controllerConfigFieldChanged.invoke(field, curr, prev);
});
// Make sure we're actually going to listen on a port
@@ -383,7 +386,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(import.meta.dirname, "..", "..", "web", "manifest.json");
let manifest = await Controller.loadJsonObject(manifestPath);
if (!manifest["main.js"]) {
@@ -458,12 +461,13 @@ export default class Controller {
async _startDevServer(args: ControllerArgs) {
logger.warn("Webpack development mode enabled");
- const webpack = require("webpack");
- const webpackDevMiddleware = require("webpack-dev-middleware");
+ const webpack = (await import("webpack")).default;
+ const webpackDevMiddleware = (await import("webpack-dev-middleware")).default;
const webpackConfigs = [];
if (args.dev) {
- webpackConfigs.push(require("../../../webpack.config")({})); // Path outside of build
+ // @ts-expect-error this file is run from a different folder
+ webpackConfigs.push((await import("../../../webpack.config.cjs")).default({})); // Path outside of build
}
if (args.devPlugin) {
let devPlugins = new Map();
@@ -472,7 +476,10 @@ export default class Controller {
if (!info) {
throw new lib.StartupError(`No plugin named ${name}`);
}
- let config = require(path.posix.join(info.requirePath, "webpack.config"))({});
+ const webpackConfig = path.posix.join(info.requirePath, "webpack.config.cjs");
+ let config = (await import(
+ path.isAbsolute(webpackConfig) ? pathToFileURL(webpackConfig).href : webpackConfig
+ )).default({});
devPlugins.set(name, webpackConfigs.length);
webpackConfigs.push(config);
}
@@ -537,7 +544,7 @@ export default class Controller {
await new Promise((resolve, reject) => { this.devMiddleware.close(resolve); });
}
- await lib.invokeHook(this.plugins, "onShutdown");
+ await this.hooks.shutdown.invoke();
await this.wsServer.stop();
@@ -627,7 +634,9 @@ export default class Controller {
try {
// First check the clusterio version
const runningVersion = this.config.get("controller.version");
- const packageJsonPath = require.resolve("@clusterio/controller/package.json");
+ const packageJsonPath = fileURLToPath(
+ import.meta.resolve("@clusterio/controller/package.json"),
+ );
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, "utf8"));
if (runningVersion !== packageJson.version) {
this.config.restartRequired = true;
@@ -644,15 +653,14 @@ export default class Controller {
}
packageName = pluginInfo.npmPackage ?? pluginInfo.name;
- const pluginPackageJsonPath = require.resolve(path.posix.join(pluginInfo.requirePath, "package.json"));
- const pluginPackageJson = JSON.parse(await fs.readFile(pluginPackageJsonPath, "utf8"));
+ const pluginPackageJson = JSON.parse(await fs.readFile(pluginInfo.packagePath, "utf8"));
if (pluginInfo.version !== pluginPackageJson.version) {
this.config.restartRequired = true;
return true;
}
}
} catch (err: any) {
- logger.warn(`Failed to read package json for ${packageName}:\n${err.stack ?? err.message}`);
+ logger.warn(`Failed to read package.json for ${packageName}:\n${err.stack ?? err.message}`);
}
return false;
@@ -661,7 +669,7 @@ export default class Controller {
async checkRestartDowngrade() {
try {
const runningVersion = this.config.get("controller.version");
- const packageJsonPath = require.resolve("@clusterio/controller/package.json");
+ const packageJsonPath = fileURLToPath(import.meta.resolve("@clusterio/controller/package.json"));
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, "utf8"));
const installedVersion = packageJson.version;
@@ -726,9 +734,8 @@ export default class Controller {
this.modPacks.save(),
this.roles.save(),
this.users.records.save(),
+ this.hooks.save.invoke(),
]);
-
- await lib.invokeHook(this.plugins, "onSaveData");
}
static migrateSystems(rawJson: unknown[]): Static[] {
@@ -902,7 +909,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, "..", "..", "web", "static"), staticOptions)
+ express.static(path.join(import.meta.dirname, "..", "..", "web", "static"), staticOptions)
);
app.use("/static", express.static("static", staticOptions)); // Used for data export files
@@ -1028,7 +1035,7 @@ export default class Controller {
modPacksUpdated(modPacks: lib.ModPack[]) {
this.subscriptions.broadcast(new lib.ModPackUpdatesEvent(modPacks));
- lib.invokeHook(this.plugins, "onModPacksUpdated", modPacks);
+ this.hooks.modPacksUpdated.invoke(modPacks);
}
async handleModPackSubscription(request: lib.SubscriptionRequest) {
@@ -1044,7 +1051,7 @@ export default class Controller {
modsUpdated(mods: lib.ModInfo[]) {
// ModStore sets updatedAtMs for mods
this.subscriptions.broadcast(new lib.ModUpdatesEvent(mods));
- lib.invokeHook(this.plugins, "onModsUpdated", mods);
+ this.hooks.modsUpdated.invoke(mods);
}
async handleModSubscription(request: lib.SubscriptionRequest) {
@@ -1092,7 +1099,7 @@ export default class Controller {
rolesUpdated(roles: lib.Role[]) {
this.subscriptions.broadcast(new lib.RoleUpdatesEvent(roles));
- // lib.invokeHook(this.plugins, "onRolesUpdated", roles); // This doesn't exist at the moment
+ this.hooks.rolesUpdated.invoke(roles);
// Notify connected control clients with the given role that the permissions may have changed.
for (const role of roles) {
for (let controlConnection of this.wsServer.controlConnections.values()) {
@@ -1122,12 +1129,15 @@ export default class Controller {
}
async loadPlugins() {
+ const context = { metrics, logger, controller: this };
for (let pluginInfo of this.pluginInfos) {
try {
let manifestPath = path.posix.join(pluginInfo.requirePath, "dist", "web", "manifest.json");
- pluginInfo.manifest = await Controller.loadJsonObject(require.resolve(manifestPath), true);
+ pluginInfo.manifest = await Controller.loadJsonObject(import.meta.resolve(manifestPath), true);
} catch (err) {
- logger.warn(`Unable to load dist/web/manifest.json for plugin ${pluginInfo.name}`);
+ if (lib.pluginNeedsWebBuild(pluginInfo)) {
+ logger.warn(`Unable to load dist/web/manifest.json for plugin ${pluginInfo.name}`);
+ }
}
if (!this.config.get(`${pluginInfo.name}.load_plugin`)) {
@@ -1139,20 +1149,16 @@ export default class Controller {
continue;
}
- let ControllerPluginClass = BaseControllerPlugin;
try {
- if (pluginInfo.controllerEntrypoint) {
- ControllerPluginClass = await lib.loadPluginClass(
- pluginInfo.name,
- path.posix.join(pluginInfo.requirePath, pluginInfo.controllerEntrypoint),
- "ControllerPlugin",
- BaseControllerPlugin,
- );
- }
+ await lib.loadPlugin(
+ pluginInfo,
+ "controller",
+ context,
+ "ControllerPlugin",
+ BaseControllerPlugin,
+ );
- let controllerPlugin = new ControllerPluginClass(pluginInfo, this, metrics as any, logger);
- await controllerPlugin.init();
- this.plugins.set(pluginInfo.name, controllerPlugin);
+ this.loadedPlugins.add(pluginInfo);
} catch (err: any) {
if (err.code === "InstallationError") {
@@ -1238,11 +1244,14 @@ export default class Controller {
mainBundle = res.app.locals.mainBundle;
} else {
let stats = res.locals.webpack.devMiddleware.stats.stats[0];
- mainBundle = stats.toJson().assetsByChunkName["main"];
+ mainBundle = stats.toJson().assetsByChunkName["main"][0];
}
mainBundle = routes.stripStaticPrefix(mainBundle);
- fs.readFile(path.join(__dirname, "..", "..", "..", "web", "index.html"), "utf8").then((content) => {
+ fs.readFile(
+ path.join(import.meta.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"))
diff --git a/packages/controller/src/ControllerRouter.ts b/packages/controller/src/ControllerRouter.ts
index 4072c941..4b90abb8 100644
--- a/packages/controller/src/ControllerRouter.ts
+++ b/packages/controller/src/ControllerRouter.ts
@@ -1,10 +1,10 @@
-import type Controller from "./Controller";
-import type BaseConnection from "./BaseConnection";
+import type Controller from "./Controller.js";
+import type BaseConnection from "./BaseConnection.js";
import * as lib from "@clusterio/lib";
-import HostConnection from "./HostConnection";
-import ControlConnection from "./ControlConnection";
-const { logger } = lib;
+import { logger } from "@clusterio/lib";
+import HostConnection from "./HostConnection.js";
+import ControlConnection from "./ControlConnection.js";
export default class ControllerRouter {
constructor(
diff --git a/packages/controller/src/HostConnection.ts b/packages/controller/src/HostConnection.ts
index e7132925..3080c1be 100644
--- a/packages/controller/src/HostConnection.ts
+++ b/packages/controller/src/HostConnection.ts
@@ -1,12 +1,12 @@
-import type Controller from "./Controller";
-import type WsServerConnector from "./WsServerConnector";
+import type Controller from "./Controller.js";
+import type WsServerConnector from "./WsServerConnector.js";
import * as lib from "@clusterio/lib";
-const { logger } = lib;
+import { logger } from "@clusterio/lib";
-import BaseConnection from "./BaseConnection";
-import InstanceRecord from "./InstanceRecord";
-import HostRecord from "./HostRecord";
+import BaseConnection from "./BaseConnection.js";
+import InstanceRecord from "./InstanceRecord.js";
+import HostRecord from "./HostRecord.js";
/**
@@ -47,12 +47,7 @@ export default class HostConnection extends BaseConnection {
this._checkPluginVersions();
for (let event of ["connect", "drop", "resume", "close"] as const) {
-
- this.connector.on(event, () => {
- for (let plugin of this._controller.plugins.values()) {
- plugin.onHostConnectionEvent(this, event);
- }
- });
+ this.connector.on(event, () => this._controller.hooks.hostConnectionEvent.invoke(this, event));
}
this.connector.on("close", () => {
@@ -66,7 +61,7 @@ export default class HostConnection extends BaseConnection {
instances.push(instance);
let prev = instance.status;
instance.status = "unknown";
- lib.invokeHook(this._controller.plugins, "onInstanceStatusChanged", instance, prev);
+ this._controller.hooks.instanceStatusChanged.invoke(instance, prev);
}
this._controller.instances.records.setMany(instances);
@@ -143,7 +138,7 @@ export default class HostConnection extends BaseConnection {
}
async prepareDisconnect() {
- await lib.invokeHook(this._controller.plugins, "onPrepareHostDisconnect", this);
+ await this._controller.hooks.prepareHostDisconnect.invoke(this);
return await super.prepareDisconnect();
}
@@ -216,7 +211,7 @@ export default class HostConnection extends BaseConnection {
instance.factorioVersion = request.factorioVersion;
this._controller.instances.records.set(instance);
logger.verbose(`Instance ${instance.config.get("instance.name")} State: ${instance.status}`);
- await lib.invokeHook(this._controller.plugins, "onInstanceStatusChanged", instance, prev);
+ await this._controller.hooks.instanceStatusChanged.invoke(instance, prev);
}
async handleInstancesUpdateRequest(request: lib.InstancesUpdateRequest) {
@@ -256,9 +251,7 @@ export default class HostConnection extends BaseConnection {
}
instanceUpdates.push(controllerInstance);
logger.verbose(`Instance ${instanceConfig.get("instance.name")} State: ${instanceData.status}`);
- await lib.invokeHook(
- this._controller.plugins, "onInstanceStatusChanged", controllerInstance, prev
- );
+ await this._controller.hooks.instanceStatusChanged.invoke(controllerInstance, prev);
}
continue;
}
@@ -274,7 +267,7 @@ export default class HostConnection extends BaseConnection {
// We suppress the changes made by createInstance so we must manually make them here
this._controller.instances.records.setMany(instanceUpdates);
await Promise.all(instanceUpdates.flatMap(newInstance => [
- lib.invokeHook(this._controller.plugins, "onInstanceStatusChanged", newInstance),
+ this._controller.hooks.instanceStatusChanged.invoke(newInstance, undefined),
this.send(
new lib.InstanceAssignInternalRequest(
newInstance.config.get("instance.id"), newInstance.config.toRemote("host")
@@ -350,7 +343,7 @@ export default class HostConnection extends BaseConnection {
user.saveRecord();
let instance = this._controller.instances.get(instanceId)!;
- await lib.invokeHook(this._controller.plugins, "onPlayerEvent", instance, {
+ await this._controller.hooks.playerEvent.invoke(instance, {
type: event.type,
name: event.name,
reason: event.reason,
@@ -367,7 +360,7 @@ export default class HostConnection extends BaseConnection {
user.set("isAdmin", event.admin);
const instance = this._controller.instances.get(src.id)!;
- await lib.invokeHook(this._controller.plugins, "onPlayerEvent", instance, {
+ await this._controller.hooks.playerEvent.invoke(instance, {
type: event.admin ? "promote" : "demote" as lib.PlayerEvent["type"],
name: event.name,
stats: user.instanceStats.get(src.id)!,
@@ -385,11 +378,11 @@ export default class HostConnection extends BaseConnection {
user.saveRecord();
const instance = this._controller.instances.get(src.id)!;
- await lib.invokeHook(this._controller.plugins, "onPlayerEvent", instance, {
+ await this._controller.hooks.playerEvent.invoke(instance, {
type: event.banned ? "ban" : "unban" as lib.PlayerEvent["type"],
name: event.name,
stats: user.instanceStats.get(src.id)!,
- resaon: event.reason,
+ reason: event.reason,
});
}
@@ -402,7 +395,7 @@ export default class HostConnection extends BaseConnection {
user.set("isWhitelisted", event.whitelisted);
const instance = this._controller.instances.get(src.id)!;
- await lib.invokeHook(this._controller.plugins, "onPlayerEvent", instance, {
+ await this._controller.hooks.playerEvent.invoke(instance, {
type: event.whitelisted ? "whitelisted" : "unwhitelisted" as lib.PlayerEvent["type"],
name: event.name,
stats: user.instanceStats.get(src.id)!,
diff --git a/packages/controller/src/InstanceManager.ts b/packages/controller/src/InstanceManager.ts
index e1cf1941..0736c493 100644
--- a/packages/controller/src/InstanceManager.ts
+++ b/packages/controller/src/InstanceManager.ts
@@ -1,8 +1,8 @@
import * as lib from "@clusterio/lib";
-import type Controller from "./Controller";
-import type HostConnection from "./HostConnection";
-import InstanceRecord from "./InstanceRecord";
+import type Controller from "./Controller.js";
+import type HostConnection from "./HostConnection.js";
+import InstanceRecord from "./InstanceRecord.js";
export default class InstanceManager {
private static readonly DefaultFactorioSettings = {
@@ -83,12 +83,7 @@ export default class InstanceManager {
instance: InstanceRecord,
prev?: lib.InstanceStatus,
): Promise {
- await lib.invokeHook(
- this._controller.plugins,
- "onInstanceStatusChanged",
- instance,
- prev,
- );
+ await this._controller.hooks.instanceStatusChanged.invoke(instance, prev);
}
private async _notifyConfigFieldChanged(
@@ -97,14 +92,7 @@ export default class InstanceManager {
curr: unknown,
prev: unknown,
): Promise {
- await lib.invokeHook(
- this._controller.plugins,
- "onInstanceConfigFieldChanged",
- instance,
- field,
- curr,
- prev,
- );
+ await this._controller.hooks.instanceConfigFieldChanged.invoke(instance, field, curr, prev);
}
/**
diff --git a/packages/controller/src/User.ts b/packages/controller/src/User.ts
index d82202a1..c7d50147 100644
--- a/packages/controller/src/User.ts
+++ b/packages/controller/src/User.ts
@@ -1,10 +1,10 @@
import {
IUser, Role, SubscribableDatastore,
- PermissionError, permissions,
+ PermissionError, PermissionName, permissions,
} from "@clusterio/lib";
-import UserRecord from "./UserRecord";
+import UserRecord from "./UserRecord.js";
import { Static } from "@sinclair/typebox";
/**
@@ -126,7 +126,7 @@ export default class User extends UserRecord implements IUser {
this.saveRecord();
}
- checkPermission(permission: string) {
+ checkPermission(permission: PermissionName) {
if (!permissions.has(permission)) {
throw new Error(`permission ${permission} does not exist`);
}
diff --git a/packages/controller/src/UserManager.ts b/packages/controller/src/UserManager.ts
index 0b4861b8..60649e5f 100644
--- a/packages/controller/src/UserManager.ts
+++ b/packages/controller/src/UserManager.ts
@@ -3,8 +3,8 @@ import jwt from "jsonwebtoken";
import * as lib from "@clusterio/lib";
-import User from "./User";
-import UserRecord from "./UserRecord";
+import User from "./User.js";
+import UserRecord from "./UserRecord.js";
/**
* Access layer for users on the controller.
diff --git a/packages/controller/src/WsServer.ts b/packages/controller/src/WsServer.ts
index c2b33c81..efaf1061 100644
--- a/packages/controller/src/WsServer.ts
+++ b/packages/controller/src/WsServer.ts
@@ -1,18 +1,18 @@
import type { IncomingMessage } from "http";
import type { Duplex } from "stream";
-import type Controller from "./Controller";
+import type Controller from "./Controller.js";
import jwt from "jsonwebtoken";
import WebSocket, { WebSocketServer } from "ws";
import * as lib from "@clusterio/lib";
-const { logger } = lib;
+import { logger } from "@clusterio/lib";
-import ControlConnection from "./ControlConnection";
-import HostConnection from "./HostConnection";
-import WsServerConnector from "./WsServerConnector";
+import ControlConnection from "./ControlConnection.js";
+import HostConnection from "./HostConnection.js";
+import WsServerConnector from "./WsServerConnector.js";
-import { version as packageVersion } from "../package.json";
+import packageConfig from "../package.json" with { type: "json" };
const wsMessageCounter = new lib.Counter(
"clusterio_controller_websocket_message_total",
@@ -45,12 +45,12 @@ export default class WsServer {
sessionAud: string = `session-${Date.now()}`;
nextSessionId: number = 1;
nextControlId: number = 1;
- wss: WebSocket.Server;
+ wss: WebSocketServer;
constructor(controller: Controller) {
this.controller = controller;
- this.wss = new WebSocket.Server({
+ this.wss = new WebSocketServer({
noServer: true,
path: "/api/socket",
});
@@ -135,11 +135,11 @@ export default class WsServer {
// Start connection handshake.
let loadedPlugins: {[key:string]: string} = {};
- for (let [name, plugin] of this.controller.plugins) {
- loadedPlugins[name] = plugin.info.version;
+ for (const plugin of this.controller.loadedPlugins) {
+ loadedPlugins[plugin.name] = plugin.version;
}
- socket.send(JSON.stringify(new lib.MessageHello(new lib.HelloData(packageVersion, loadedPlugins))));
+ socket.send(JSON.stringify(new lib.MessageHello(new lib.HelloData(packageConfig.version, loadedPlugins))));
this.attachHandler(socket, req);
}
@@ -287,10 +287,11 @@ ${err.stack}`
logger.info(
`WsServer | registered host ${data.id} using version ${data.version}`
);
- if (data.version !== packageVersion) {
+ if (data.version !== packageConfig.version) {
logger.warn(
`Host ${data.id} connected using version ${data.version} which does not match the ` +
- `version of the controller is currently running (${packageVersion}). It may not work as expected.`
+ `version of the controller is currently running (${packageConfig.version}). ` +
+ "It may not work as expected."
);
}
diff --git a/packages/controller/src/WsServerConnector.ts b/packages/controller/src/WsServerConnector.ts
index 403feeb6..439a2056 100644
--- a/packages/controller/src/WsServerConnector.ts
+++ b/packages/controller/src/WsServerConnector.ts
@@ -1,10 +1,10 @@
-import type WebSocket from "ws";
+import { type WebSocket } from "ws";
import events from "events";
import assert from "assert/strict";
import * as lib from "@clusterio/lib";
-const { logger } = lib;
+import { logger } from "@clusterio/lib";
/**
@@ -49,7 +49,7 @@ export default class WsServerConnector extends lib.WebSocketBaseConnector {
* @param account - account data to provide to control connection
*/
ready(
- socket: WebSocket.WebSocket,
+ socket: WebSocket,
src: lib.Address,
sessionToken: string,
account: lib.AccountDetails | undefined
@@ -80,7 +80,7 @@ export default class WsServerConnector extends lib.WebSocketBaseConnector {
* @param lastSeq - The last message the client received.
*/
continue(
- socket: WebSocket.WebSocket,
+ socket: WebSocket,
lastSeq: number
) {
@@ -106,6 +106,9 @@ export default class WsServerConnector extends lib.WebSocketBaseConnector {
this._state = "connected";
this._attachSocketHandlers();
this._dropSendBufferSeq(lastSeq);
+ logger.verbose(
+ `Connector | resuming session with ${this.dst}, resending ${this._sendBuffer.length} buffered messages`
+ );
for (let message of this._sendBuffer) {
this._socket.send(JSON.stringify(message));
}
@@ -113,7 +116,7 @@ export default class WsServerConnector extends lib.WebSocketBaseConnector {
}
_timedOut() {
- logger.verbose("Connector | Connection timed out");
+ logger.verbose(`Connector | Connection to ${this.dst} timed out`);
this._close();
}
diff --git a/packages/controller/src/routes.ts b/packages/controller/src/routes.ts
index a4e1da70..993f16b9 100644
--- a/packages/controller/src/routes.ts
+++ b/packages/controller/src/routes.ts
@@ -1,5 +1,5 @@
import type { Application, Request, Response } from "express";
-import Controller from "./Controller";
+import Controller from "./Controller.js";
import busboy from "busboy";
import crypto from "crypto";
@@ -12,7 +12,7 @@ import nodeStream from "stream";
import util from "util";
import * as lib from "@clusterio/lib";
-const { logger } = lib;
+import { logger } from "@clusterio/lib";
const finished = util.promisify(nodeStream.finished);
@@ -49,7 +49,7 @@ async function getMetrics(req: Request, res: Response, next: any) {
const controller: Controller = req.app.locals.controller;
let results: lib.CollectorResult[] = [];
- let pluginResults = await lib.invokeHook(controller.plugins, "onMetrics");
+ let pluginResults = await controller.hooks.metrics.collect();
for (let metricIterator of pluginResults) {
for await (let metric of metricIterator) {
results.push(metric);
@@ -117,8 +117,8 @@ function getPlugins(req: Request, res: Response) {
let plugins: lib.PluginWebApi[] = [];
for (let pluginInfo of req.app.locals.controller.pluginInfos) {
let name = pluginInfo.name;
- let loaded = req.app.locals.controller.plugins.has(name);
- let enabled = loaded && req.app.locals.controller.config.get(`${pluginInfo.name}.load_plugin`);
+ const loaded = req.app.locals.controller.loadedPlugins.has(pluginInfo);
+ const enabled = loaded && req.app.locals.controller.config.get(`${pluginInfo.name}.load_plugin`);
// Note: Cast through unknown is needed because load_plugin is
// defined at runtime and no other fields in the controller config
// currently have the boolean type.
@@ -126,13 +126,13 @@ function getPlugins(req: Request, res: Response) {
let devPlugins = req.app.locals.devPlugins;
if (devPlugins && devPlugins.has(name)) {
let stats = res.locals.webpack.devMiddleware.stats.stats[devPlugins.get(name)!];
- web.main = stats.toJson().assetsByChunkName[name];
+ web.main = stats.toJson().assetsByChunkName[name][0];
} else if (pluginInfo.manifest) {
web.main = pluginInfo.manifest[`${pluginInfo.name}.js`];
if (!web.main) {
web.error = `Missing ${pluginInfo.name}.js entry in manifest.json`;
}
- } else {
+ } else if (lib.pluginNeedsWebBuild(pluginInfo)) {
web.error = "Missing dist/web/manifest.json";
}
if (web.main === "remoteEntry.js") {
diff --git a/packages/controller/webpack.config.js b/packages/controller/webpack.config.cjs
similarity index 100%
rename from packages/controller/webpack.config.js
rename to packages/controller/webpack.config.cjs
diff --git a/packages/create/create.js b/packages/create/create.js
index 3d0557af..5980f9d2 100755
--- a/packages/create/create.js
+++ b/packages/create/create.js
@@ -1,20 +1,19 @@
#!/usr/bin/env node
-"use strict";
-const child_process = require("child_process");
-const fs = require("node:fs/promises");
-const inquirer = require("inquirer");
-const os = require("os");
-const path = require("path");
-const stream = require("stream");
-const util = require("util");
-const yargs = require("yargs");
+import child_process from "node:child_process";
+import fs from "node:fs/promises";
+import inquirer from "inquirer";
+import os from "node:os";
+import path from "node:path";
+import stream from "node:stream";
+import util from "node:util";
+import yargs from "yargs";
-const { levels, logger, setLogLevel } = require("./logging");
-const { copyPluginTemplates } = require("./template");
+import { levels, logger, setLogLevel } from "./logging.js";
+import { copyPluginTemplates } from "./template.js";
let dev = false;
const scriptExt = process.platform === "win32" ? ".cmd" : "";
-const { escapeArg } = require("./escape_arg");
+import { escapeArg } from "./escape_arg.js";
// 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
@@ -147,21 +146,21 @@ async function execFile(cmd, args) {
async function execController(args) {
if (dev) {
- return await execFile("node", [path.join(__dirname, "..", "controller"), ...args]);
+ return await execFile("node", [path.join(import.meta.dirname, "..", "controller"), ...args]);
}
return await execFile(path.join("node_modules", ".bin", `clusteriocontroller${scriptExt}`), args);
}
async function execHost(args) {
if (dev) {
- return await execFile("node", [path.join(__dirname, "..", "host"), ...args]);
+ return await execFile("node", [path.join(import.meta.dirname, "..", "host"), ...args]);
}
return await execFile(path.join("node_modules", ".bin", `clusteriohost${scriptExt}`), args);
}
async function execCtl(args) {
if (dev) {
- return await execFile("node", [path.join(__dirname, "..", "ctl"), ...args]);
+ return await execFile("node", [path.join(import.meta.dirname, "..", "ctl"), ...args]);
}
return await execFile(path.join("node_modules", ".bin", `clusterioctl${scriptExt}`), args);
}
@@ -235,7 +234,7 @@ async function migrateRename(args) {
if (await pathExists(source) && !await pathExists(destination)) {
await safeOutputFile(destination, JSON.stringify(
renameConfig(JSON.parse(await fs.readFile(source))), null, "\t"
- ));
+ ), { mode: 0o600 });
logger.info(`Migrated ${source} to ${destination}`);
}
}
@@ -408,7 +407,7 @@ async function installClusterio(mode, plugins) {
for (let plugin of plugins) {
if (!pluginList.has(plugin)) {
- let pluginInfo = require(require.resolve(plugin, { paths: [process.cwd()] })).plugin;
+ let pluginInfo = (await import(plugin)).plugin;
pluginList.set(pluginInfo.name, plugin);
}
}
@@ -750,7 +749,7 @@ async function inquirerMissingArgs(args) {
}
async function main() {
- let args = yargs
+ let yarg = yargs(process.argv.slice(2))
.option("log-level", {
nargs: 1, describe: "Log level to print to stdout", default: "info",
choices: ["none"].concat(Object.keys(levels)), type: "string",
@@ -805,14 +804,14 @@ async function main() {
;
if (process.platform === "linux") {
- args = args
+ yarg = yarg
.option("allow-install-as-root", {
nargs: 0, describe: "(Linux only) Allow installing as root (not recommended)", type: "boolean",
})
;
}
- args = args.argv;
+ const args = yarg.parse();
setLogLevel(args.logLevel === "none" ? -1 : levels[args.logLevel]);
dev = args.dev;
@@ -900,7 +899,7 @@ async function main() {
/* eslint-enable no-console */
}
-if (module === require.main) {
+if (import.meta.main) {
main().catch(err => {
if (err instanceof InstallError) {
logger.error(err.message);
diff --git a/packages/create/escape_arg.js b/packages/create/escape_arg.js
index 3767ae82..b194c75f 100644
--- a/packages/create/escape_arg.js
+++ b/packages/create/escape_arg.js
@@ -1,6 +1,4 @@
-"use strict";
-
-function escapeArg(arg) {
+export function escapeArg(arg) {
// When shell: true is used execFile does not escaping of arguments, they are
// simply joined by space and then passed to the shell.
@@ -56,7 +54,3 @@ function escapeArg(arg) {
}
return `"${arg.replace(/[$`"\\]/g, "\\$&")}"`;
}
-
-module.exports = {
- escapeArg,
-};
diff --git a/packages/create/logging.js b/packages/create/logging.js
index 265dd8e1..9954b666 100644
--- a/packages/create/logging.js
+++ b/packages/create/logging.js
@@ -1,8 +1,7 @@
/* eslint-disable no-console */
-"use strict";
-const chalk = require("chalk");
+import chalk from "chalk";
-const levels = Object.freeze({
+export const levels = Object.freeze({
fatal: 0,
error: 1,
warn: 2,
@@ -11,7 +10,7 @@ const levels = Object.freeze({
verbose: 6,
});
-const colors = {
+export const colors = {
fatal: "inverse red",
error: "bold redBright",
warn: "bold yellowBright",
@@ -21,7 +20,7 @@ const colors = {
};
let maxLogLevel = levels.info;
-const logger = {};
+export const logger = {};
for (let [name, level] of Object.entries(levels)) {
// eslint-disable-next-line no-loop-func
logger[name] = message => {
@@ -42,10 +41,6 @@ for (let [name, level] of Object.entries(levels)) {
};
}
-module.exports = {
- colors,
- levels,
- setLogLevel: (level) => { maxLogLevel = level; },
- logger,
-};
-
+export function setLogLevel(level) {
+ maxLogLevel = level;
+}
diff --git a/packages/create/package.json b/packages/create/package.json
index aa0bfd24..601defa0 100644
--- a/packages/create/package.json
+++ b/packages/create/package.json
@@ -22,6 +22,7 @@
"access": "public"
},
+ "type": "module",
"main": "create.js",
"bin": {
"clusteriocreate": "./create.js"
diff --git a/packages/create/template.js b/packages/create/template.js
index e878a42a..99505d70 100644
--- a/packages/create/template.js
+++ b/packages/create/template.js
@@ -1,9 +1,8 @@
-"use strict";
-const { logger } = require("./logging");
-const fs = require("node:fs/promises");
-const path = require("path");
+import { logger } from "./logging.js";
+import fs from "node:fs/promises";
+import path from "node:path";
-const _package = require("./package.json");
+import _package from "./package.json" with { type: "json" };
async function copyTemplateFile(src, dst, properties) {
logger.verbose(`Writing ${dst} from template ${src}`);
@@ -79,7 +78,7 @@ async function copyTemplateFile(src, dst, properties) {
}
}
-async function copyPluginTemplates(pluginName, templates) {
+export async function copyPluginTemplates(pluginName, templates) {
logger.info(`Please wait, coping templates for ${templates.join(", ")}`);
const files = new Map();
const prepare = [];
@@ -96,8 +95,11 @@ async function copyPluginTemplates(pluginName, templates) {
// 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");
+ const templatePath = path.resolve(
+ import.meta.dirname,
+ javascriptOnly ? "./templates/plugin-js" : "./templates/plugin-ts"
+ );
+ const commonPath = path.resolve(import.meta.dirname, "./templates/common");
// Files included in all templates
files.set(".gitignore", path.join(commonPath, "template.gitignore"));
@@ -119,7 +121,7 @@ async function copyPluginTemplates(pluginName, templates) {
// Files and dependences to support webpack
if (webpack) {
prepare.push("webpack-cli --mode production");
- files.set("webpack.config.js", path.join(commonPath, "webpack.config.js"));
+ files.set("webpack.config.cjs", path.join(commonPath, "webpack.config.cjs"));
if (templates.includes("web")) {
files.set(`web/index.${ext}x`, path.join(templatePath, `web/plugin.${ext}x`));
pluginContexts += 1;
@@ -202,7 +204,3 @@ async function copyPluginTemplates(pluginName, templates) {
await Promise.all(writes);
logger.info("Successfully wrote all template files");
}
-
-module.exports = {
- copyPluginTemplates,
-};
diff --git a/packages/create/templates/common/package.json b/packages/create/templates/common/package.json
index 0e01b9d7..dd7b657d 100644
--- a/packages/create/templates/common/package.json
+++ b/packages/create/templates/common/package.json
@@ -2,6 +2,7 @@
"name": "__plugin_name__",
"version": "0.1.0",
"description": "Example Description. Package. Change me in package.json",
+ "type": "module",
//%if typescript
"main": "dist/node/index.js",
//%endif
@@ -18,9 +19,6 @@
//%if controller
"@clusterio/controller": "^__clusterio_version__",
//%endif
-//%if webpack
- "@clusterio/web_ui": "^__clusterio_version__",
-//%endif
//%if host
"@clusterio/host": "^__clusterio_version__",
//%endif
@@ -29,6 +27,31 @@
//%endif
"@clusterio/lib": "^__clusterio_version__"
},
+//%if controller | host | ctl
+ "peerDependenciesMeta": {
+//%endif
+//%if controller
+ "@clusterio/controller": {
+ "optional": true
+//%endif
+//%if controller & host | controller & ctl
+ },
+//%endif
+//%if host
+ "@clusterio/host": {
+ "optional": true
+//%endif
+//%if host & ctl
+ },
+//%endif
+//%if ctl
+ "@clusterio/ctl": {
+ "optional": true
+//%endif
+//%if controller | host | ctl
+ }
+ },
+//%endif
"devDependencies": {
//%// Typescript and type declarations
//%if typescript
diff --git a/packages/create/templates/common/webpack.config.js b/packages/create/templates/common/webpack.config.cjs
similarity index 100%
rename from packages/create/templates/common/webpack.config.js
rename to packages/create/templates/common/webpack.config.cjs
diff --git a/packages/create/templates/plugin-js/controller.js b/packages/create/templates/plugin-js/controller.js
index fae78fdc..f5df87e1 100644
--- a/packages/create/templates/plugin-js/controller.js
+++ b/packages/create/templates/plugin-js/controller.js
@@ -1,85 +1,66 @@
-"use strict";
-const lib = require("@clusterio/lib");
-const { BaseControllerPlugin } = require("@clusterio/controller");
+import * as lib from "@clusterio/lib";
//%if multi_context // Messages requires multi context
-const {
+import {
PluginExampleEvent, PluginExampleRequest,
//%endif
//%if controller & web // Subscribing requires web content and the controller
ExampleSubscribableUpdate, ExampleSubscribableValue,
//%endif
//%if multi_context // Messages requires multi context
-} = require("./messages");
+} from "./messages.js";
//%endif
-class ControllerPlugin extends BaseControllerPlugin {
+export default async function loadControllerPlugin(context) {
+ const { controller, logger, plugin } = context;
//%if controller & web // Subscribing requires web content and the controller
- exampleDatabase;
- storageDirty = false;
-
-//%endif
-//%if multi_context // Subscribing requires multi context
- async init() {
- this.controller.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
- this.controller.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
-//%endif
-//%if controller & web // Subscribing requires web content and the controller
- this.controller.subscriptions.handle(ExampleSubscribableUpdate, this.handleExampleSubscription.bind(this));
- // If needed, replace with loading from database file such as lib.Datastore
- this.exampleDatabase = new Map([["foo", new ExampleSubscribableValue("foo", 0, false)]]);
-//%endif
-//%if multi_context // Subscribing requires multi context
- }
+ // If needed, replace with loading from database file such as lib.Datastore
+ const exampleDatabase = new Map([["foo", new ExampleSubscribableValue("foo", 0, false)]]);
//%endif
+//%if multi_context // Messages requires multi context
- async onControllerConfigFieldChanged(field, curr, prev) {
- this.logger.info(`controller::onControllerConfigFieldChanged ${field}`);
- }
-//%if instance
+ controller.handle(PluginExampleEvent, async (event) => {
+ logger.info(JSON.stringify(event));
+ });
- async onInstanceConfigFieldChanged(instance, field, curr, prev) {
- this.logger.info(`controller::onInstanceConfigFieldChanged ${instance.id} ${field}`);
- }
-//%endif
-
- async onSaveData() {
- this.logger.info("controller::onSaveData");
- }
-
- async onShutdown() {
- this.logger.info("controller::onShutdown");
- }
-
- async onPlayerEvent(instance, event) {
- this.logger.info(`controller::onPlayerEvent ${instance.id} ${JSON.stringify(event)}`);
- }
-//%if multi_context
-
- async handlePluginExampleEvent(event) {
- this.logger.info(JSON.stringify(event));
- }
-
- async handlePluginExampleRequest(request) {
- this.logger.info(JSON.stringify(request));
+ controller.handle(PluginExampleRequest, async (request) => {
+ logger.info(JSON.stringify(request));
return {
myResponseString: request.myString,
myResponseNumbers: request.myNumberArray,
};
- }
+ });
//%endif
//%if controller & web // Subscribing requires web content and the controller
- async handleExampleSubscription(request) {
- this.logger.info(JSON.stringify(request));
- const values = [...this.exampleDatabase.values()].filter(
+ controller.subscriptions.handle(ExampleSubscribableUpdate, async (request) => {
+ logger.info(JSON.stringify(request));
+ const values = [...exampleDatabase.values()].filter(
value => value.updatedAtMs > request.lastRequestTimeMs,
);
return values.length ? new ExampleSubscribableUpdate(values) : null;
- }
+ });
//%endif
-}
-module.exports = {
- ControllerPlugin,
-};
+ controller.hooks.controllerConfigFieldChanged.attach(plugin.name, async (field, curr, prev) => {
+ logger.info(`controller::controllerConfigFieldChanged ${field}`);
+ });
+//%if instance
+
+ controller.hooks.instanceConfigFieldChanged.attach(plugin.name, async (instance, field, curr, prev) => {
+ logger.info(`controller::instanceConfigFieldChanged ${instance.id} ${field}`);
+ });
+//%endif
+
+ controller.hooks.save.attach(plugin.name, async () => {
+ logger.info("controller::save");
+ });
+
+ controller.hooks.shutdown.attach(plugin.name, async () => {
+ logger.info("controller::shutdown");
+ });
+
+ controller.hooks.playerEvent.attach(plugin.name, async (instance, event) => {
+ logger.info(`controller::playerEvent ${instance.id} ${JSON.stringify(event)}`);
+ });
+}
diff --git a/packages/create/templates/plugin-js/ctl.js b/packages/create/templates/plugin-js/ctl.js
index 95e886b9..b3f16594 100644
--- a/packages/create/templates/plugin-js/ctl.js
+++ b/packages/create/templates/plugin-js/ctl.js
@@ -1,8 +1,6 @@
-"use strict";
-const { BaseCtlPlugin } = require("@clusterio/ctl");
-const { CommandTree, Command } = require("@clusterio/lib");
+import { CommandTree, Command } from "@clusterio/lib";
//%// We do not check for multi context here because it doesn't make sense to have a ctl without messages
-const { PluginExampleEvent, PluginExampleRequest } = require("./messages");
+import { PluginExampleEvent, PluginExampleRequest } from "./messages.js";
/* eslint-disable no-console */
const pluginCommands = new CommandTree({
@@ -21,12 +19,8 @@ pluginCommands.add(new Command({
}));
/* eslint-enable no-console */
-class CtlPlugin extends BaseCtlPlugin {
- async addCommands(rootCommand) {
+export default async function loadCtlPlugin(context) {
+ context.hooks.addCommands.attach(context.plugin.name, async (rootCommand) => {
rootCommand.add(pluginCommands);
- }
+ });
}
-
-module.exports = {
- CtlPlugin,
-};
diff --git a/packages/create/templates/plugin-js/host.js b/packages/create/templates/plugin-js/host.js
index c1e1468d..d40601ec 100644
--- a/packages/create/templates/plugin-js/host.js
+++ b/packages/create/templates/plugin-js/host.js
@@ -1,41 +1,30 @@
-"use strict";
-const lib = require("@clusterio/lib");
-const { BaseHostPlugin } = require("@clusterio/host");
+import * as lib from "@clusterio/lib";
//%if multi_context
-const { PluginExampleEvent, PluginExampleRequest } = require("./messages");
+import { PluginExampleEvent, PluginExampleRequest } from "./messages.js";
//%endif
-class HostPlugin extends BaseHostPlugin {
-//%if multi_context
- async init() {
- this.host.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
- this.host.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
- }
-//%endif
-
- async onHostConfigFieldChanged(field, curr, prev) {
- this.logger.info(`host::onInstanceConfigFieldChanged ${field}`);
- }
-
- async onShutdown() {
- this.logger.info("host::onShutdown");
- }
+export default async function loadHostPlugin(context) {
+ const { host, logger, plugin } = context;
//%if multi_context
- async handlePluginExampleEvent(event) {
- this.logger.info(JSON.stringify(event));
- }
+ host.handle(PluginExampleEvent, async (event) => {
+ logger.info(JSON.stringify(event));
+ });
- async handlePluginExampleRequest(request) {
- this.logger.info(JSON.stringify(request));
+ host.handle(PluginExampleRequest, async (request) => {
+ logger.info(JSON.stringify(request));
return {
myResponseString: request.myString,
myResponseNumbers: request.myNumberArray,
};
- }
+ });
//%endif
-}
-module.exports = {
- HostPlugin,
-};
+ host.hooks.hostConfigFieldChanged.attach(plugin.name, async (field, curr, prev) => {
+ logger.info(`host::hostConfigFieldChanged ${field}`);
+ });
+
+ host.hooks.shutdown.attach(plugin.name, async () => {
+ logger.info("host::shutdown");
+ });
+}
diff --git a/packages/create/templates/plugin-js/index.js b/packages/create/templates/plugin-js/index.js
index d773d4c4..171341cb 100644
--- a/packages/create/templates/plugin-js/index.js
+++ b/packages/create/templates/plugin-js/index.js
@@ -1,42 +1,9 @@
-"use strict";
-const lib = require("@clusterio/lib");
+import * as lib from "@clusterio/lib";
//%if multi_context
-const Messages = require("./messages");
-
-lib.definePermission({
- name: "__plugin_name__.example.permission.event",
- title: "Example permission event",
- description: "Example Description. Event. Change me in index.js",
- grantByDefault: true,
-});
-
-lib.definePermission({
- name: "__plugin_name__.example.permission.request",
- title: "Example permission request",
- description: "Example Description. Request. Change me in index.js",
- grantByDefault: true,
-});
-//%endif
-//%if controller & web // Subscribing requires web content and the controller
-
-lib.definePermission({
- name: "__plugin_name__.example.permission.subscribe",
- title: "Example permission subscribe",
- description: "Example Description. Subscribe. Change me in index.js",
- grantByDefault: true,
-});
-//%endif
-//%if web
-
-lib.definePermission({
- name: "__plugin_name__.page.view",
- title: "Example page view permission",
- description: "Example Description. View. Change me in index.js",
- grantByDefault: true,
-});
+import * as Messages from "./messages.js";
//%endif
-const plugin = {
+export const plugin = {
name: "__plugin_name__",
title: "__plugin_name__",
description: "Example Description. Plugin. Change me in index.js",
@@ -44,7 +11,7 @@ const plugin = {
//%endif
//%if controller
- controllerEntrypoint: "./dist/node/controller",
+ controllerEntrypoint: "./dist/node/controller.js",
//%endif
//%if controller & config
controllerConfigFields: {
@@ -60,7 +27,7 @@ const plugin = {
//%endif
//%if host
- hostEntrypoint: "./dist/node/host",
+ hostEntrypoint: "./dist/node/host.js",
//%endif
//%if host & config
hostConfigFields: {
@@ -76,7 +43,7 @@ const plugin = {
//%endif
//%if instance | module // Modules load an empty instance plugin
- instanceEntrypoint: "./dist/node/instance",
+ instanceEntrypoint: "./dist/node/instance.js",
//%endif
//%if instance & config
instanceConfigFields: {
@@ -92,7 +59,7 @@ const plugin = {
//%endif
//%if ctl
- ctlEntrypoint: "./dist/node/ctl",
+ ctlEntrypoint: "./dist/node/ctl.js",
//%endif
//%if ctl & config
controlConfigFields: {
@@ -116,6 +83,43 @@ const plugin = {
//%if multi_context // Subscribing requires multi context
],
//%endif
+//%if multi_context | web
+
+ permissions: [
+//%endif
+//%if multi_context
+ {
+ name: "__plugin_name__.example.permission.event",
+ title: "Example permission event",
+ description: "Example Description. Event. Change me in index.js",
+ grantByDefault: true,
+ },
+ {
+ name: "__plugin_name__.example.permission.request",
+ title: "Example permission request",
+ description: "Example Description. Request. Change me in index.js",
+ grantByDefault: true,
+ },
+//%endif
+//%if controller & web // Subscribing requires web content and the controller
+ {
+ name: "__plugin_name__.example.permission.subscribe",
+ title: "Example permission subscribe",
+ description: "Example Description. Subscribe. Change me in index.js",
+ grantByDefault: true,
+ },
+//%endif
+//%if web
+ {
+ name: "__plugin_name__.page.view",
+ title: "Example page view permission",
+ description: "Example Description. View. Change me in index.js",
+ grantByDefault: true,
+ },
+//%endif
+//%if multi_context | web
+ ],
+//%endif
//%if web // Web content template has an example route which is the plugin name
webEntrypoint: "./web",
@@ -124,7 +128,3 @@ const plugin = {
],
//%endif
};
-
-module.exports = {
- plugin,
-};
diff --git a/packages/create/templates/plugin-js/instance.js b/packages/create/templates/plugin-js/instance.js
index 41175938..6b2569ef 100644
--- a/packages/create/templates/plugin-js/instance.js
+++ b/packages/create/templates/plugin-js/instance.js
@@ -1,74 +1,56 @@
-"use strict";
-const lib = require("@clusterio/lib");
-const { BaseInstancePlugin } = require("@clusterio/host");
+import * as lib from "@clusterio/lib";
//%if multi_context
-const { PluginExampleEvent, PluginExampleRequest } = require("./messages");
+import { PluginExampleEvent, PluginExampleRequest } from "./messages.js";
//%endif
-class InstancePlugin extends BaseInstancePlugin {
-//%if multi_context | module
- async init() {
-//%if multi_context
- this.instance.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
- this.instance.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
-//%endif
-//%if module
- this.instance.server.handle("__plugin_name__-plugin_example_ipc", this.handlePluginExampleIPC.bind(this));
-//%endif
-//%if multi_context | module
- }
-//%endif
-
- async onInstanceConfigFieldChanged(field, curr, prev) {
- this.logger.info(`instance::onInstanceConfigFieldChanged ${field}`);
- }
-
- async onStart() {
- // Called once rcon becomes available
- this.logger.info("instance::onStart");
- }
-
- async onStop() {
- // Called during normal exits before rcon becomes unavailable
- this.logger.info("instance::onStop");
- }
-
- onExit() {
- // Called during all exits, including crashes and init failures, rcon is not available
- this.logger.info("instance::onExit");
- }
-
-
- async onPlayerEvent(event) {
- this.logger.info(`onPlayerEvent::onPlayerEvent ${JSON.stringify(event)}`);
-//%if module
- if (this.instance.status === "running") {
- await this.sendRcon("/sc __plugin_name__.foo()");
- }
-//%endif
- }
+export default async function loadInstancePlugin(context) {
+ const { instance, logger, plugin } = context;
//%if multi_context
- async handlePluginExampleEvent(event) {
- this.logger.info(JSON.stringify(event));
- }
+ instance.handle(PluginExampleEvent, async (event) => {
+ logger.info(JSON.stringify(event));
+ });
- async handlePluginExampleRequest(request) {
- this.logger.info(JSON.stringify(request));
+ instance.handle(PluginExampleRequest, async (request) => {
+ logger.info(JSON.stringify(request));
return {
myResponseString: request.myString,
myResponseNumbers: request.myNumberArray,
};
- }
+ });
//%endif
//%if module
- async handlePluginExampleIPC(event) {
- this.logger.info(JSON.stringify(event));
- }
+ instance.server.handle("__plugin_name__-plugin_example_ipc", async (event) => {
+ logger.info(JSON.stringify(event));
+ });
//%endif
-}
-module.exports = {
- InstancePlugin,
-};
+ instance.hooks.instanceConfigFieldChanged.attach(plugin.name, async (field, curr, prev) => {
+ logger.info(`instance::instanceConfigFieldChanged ${field}`);
+ });
+
+ instance.hooks.start.attach(plugin.name, async () => {
+ // Called once rcon becomes available
+ logger.info("instance::start");
+ });
+
+ instance.hooks.stop.attach(plugin.name, async () => {
+ // Called during normal exits before rcon becomes unavailable
+ logger.info("instance::stop");
+ });
+
+ instance.hooks.exit.attach(plugin.name, () => {
+ // Called during all exits, including crashes and init failures, rcon is not available
+ logger.info("instance::exit");
+ });
+
+ instance.hooks.playerEvent.attach(plugin.name, async (event) => {
+ logger.info(`instance::playerEvent ${JSON.stringify(event)}`);
+//%if module
+ if (instance.status === "running") {
+ await instance.sendRcon("/sc __plugin_name__.foo()", false, plugin.name);
+ }
+//%endif
+ });
+}
diff --git a/packages/create/templates/plugin-js/instance_empty.js b/packages/create/templates/plugin-js/instance_empty.js
index 177f9952..73a295ed 100644
--- a/packages/create/templates/plugin-js/instance_empty.js
+++ b/packages/create/templates/plugin-js/instance_empty.js
@@ -1,11 +1,3 @@
-"use strict";
-const { BaseInstancePlugin } = require("@clusterio/host");
-
-class InstancePlugin extends BaseInstancePlugin {
- // This class is empty because an instance plugin must be defined for a module to be injected
- // This requirement may change in the future to allow for standalone modules
-}
-
-module.exports = {
- InstancePlugin,
-};
+// This entrypoint is empty because an instance entrypoint must be defined for a module to be injected
+// This requirement may change in the future to allow for standalone modules
+export default async function loadInstancePlugin(context) { }
diff --git a/packages/create/templates/plugin-js/messages.js b/packages/create/templates/plugin-js/messages.js
index aaffd9f6..296856ea 100644
--- a/packages/create/templates/plugin-js/messages.js
+++ b/packages/create/templates/plugin-js/messages.js
@@ -1,8 +1,7 @@
-"use strict";
-const { plainJson, jsonArray, JsonBoolean, JsonNumber, JsonString, StringEnum } = require("@clusterio/lib");
-const { Type } = require("@sinclair/typebox");
+import { plainJson, jsonArray, JsonBoolean, JsonNumber, JsonString, StringEnum } from "@clusterio/lib";
+import { Type } from "@sinclair/typebox";
-class PluginExampleEvent {
+export class PluginExampleEvent {
static type = "event";
static src = ["host", "control"];
static dst = ["controller", "host", "instance"];
@@ -24,7 +23,7 @@ class PluginExampleEvent {
}
}
-class PluginExampleRequest {
+export class PluginExampleRequest {
static type = "request";
static src = ["host", "control"];
static dst = ["controller", "host", "instance"];
@@ -52,7 +51,7 @@ class PluginExampleRequest {
}
//%if controller & web // Subscribing requires web content and the controller
-class ExampleSubscribableValue {
+export class ExampleSubscribableValue {
constructor(id, updatedAtMs, isDeleted) {
this.id = id;
this.updatedAtMs = updatedAtMs;
@@ -70,7 +69,7 @@ class ExampleSubscribableValue {
}
}
-class ExampleSubscribableUpdate {
+export class ExampleSubscribableUpdate {
static type = "event";
static src = "controller";
static dst = "control";
@@ -90,12 +89,3 @@ class ExampleSubscribableUpdate {
}
}
//%endif
-
-module.exports = {
- PluginExampleEvent,
- PluginExampleRequest,
-//%if controller & web // Subscribing requires web content and the controller
- ExampleSubscribableValue,
- ExampleSubscribableUpdate,
-//%endif
-};
diff --git a/packages/create/templates/plugin-js/web/plugin.jsx b/packages/create/templates/plugin-js/web/plugin.jsx
index 7a67c8f6..3cac165e 100644
--- a/packages/create/templates/plugin-js/web/plugin.jsx
+++ b/packages/create/templates/plugin-js/web/plugin.jsx
@@ -10,7 +10,7 @@ import React, {
// } from "antd";
import {
- BaseWebPlugin, PageLayout, PageHeader, Control, ControlContext, notifyErrorHandler,
+ PageLayout, PageHeader, Control, ControlContext, notifyErrorHandler,
} from "@clusterio/web_ui";
//%if multi_context // Messages requires multi context
@@ -21,16 +21,26 @@ import {
ExampleSubscribableUpdate, ExampleSubscribableValue,
//%endif
//%if multi_context // Messages requires multi context
-} from "../messages";
+} from "../messages.js";
//%endif
import * as lib from "@clusterio/lib";
+//%if controller // Subscribing requires web content and the controller
+
+// Created when the plugin is loaded, before any page is rendered
+let exampleSubscriber;
+
+function useSubscribableData() {
+ const control = useContext(ControlContext);
+ const subscribe = useCallback((callback) => exampleSubscriber.subscribe(callback), [control]);
+ return useSyncExternalStore(subscribe, () => exampleSubscriber.getSnapshot());
+}
+//%endif
function MyTemplatePage() {
let control = useContext(ControlContext);
//%if controller // Subscribing requires web content and the controller
- const plugin = control.plugins.get("__plugin_name__");
- const [subscribableData, synced] = plugin.useSubscribableData();
+ const [subscribableData, synced] = useSubscribableData();
//%endif
return
@@ -41,53 +51,39 @@ function MyTemplatePage() {
;
}
-export class WebPlugin extends BaseWebPlugin {
+export default async function loadWebPlugin(context) {
+ const { control, logger, plugin } = context;
//%if controller // Subscribing requires web content and the controller
- subscribableData = new lib.EventSubscriber(ExampleSubscribableUpdate, this.control);
-
+ exampleSubscriber = new lib.MapSubscriber(ExampleSubscribableUpdate, 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
+
+ control.hooks.pages.attach(plugin.name, () => [
+ {
+ path: "/__plugin_name__",
+ sidebarName: "__plugin_name__",
+ // This permission is client side only, so it must match the permission string of a resource request to be secure
+ // An undefined value means that the page will always be visible
//%if controller // Subscribing requires web content and the controller
- permission: "__plugin_name__.example.permission.subscribe",
+ permission: "__plugin_name__.example.permission.subscribe",
//%endif
//%if !controller
- permission: "__plugin_name__.example.permission.view",
+ permission: "__plugin_name__.page.view",
//%endif
- content: ,
- },
- ];
+ content: ,
+ },
+ ]);
//%if multi_context
- this.control.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
- this.control.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
-//%endif
- }
-//%if controller// Subscribing requires web content and the controller
+ control.handle(PluginExampleEvent, async (event) => {
+ logger.info(JSON.stringify(event));
+ });
- useSubscribableData() {
- const control = useContext(ControlContext);
- const subscribe = useCallback((callback) => this.subscribableData.subscribe(callback), [control]);
- return useSyncExternalStore(subscribe, () => this.subscribableData.getSnapshot());
- }
-//%endif
-//%if multi_context
-
- async handlePluginExampleEvent(event) {
- this.logger.info(JSON.stringify(event));
- }
-
- async handlePluginExampleRequest(request) {
- this.logger.info(JSON.stringify(request));
+ control.handle(PluginExampleRequest, async (request) => {
+ logger.info(JSON.stringify(request));
return {
myResponseString: request.myString,
myResponseNumbers: request.myNumberArray,
};
- }
+ });
//%endif
}
diff --git a/packages/create/templates/plugin-ts/controller.ts b/packages/create/templates/plugin-ts/controller.ts
index 59fb296d..8f1368d4 100644
--- a/packages/create/templates/plugin-ts/controller.ts
+++ b/packages/create/templates/plugin-ts/controller.ts
@@ -1,5 +1,5 @@
import * as lib from "@clusterio/lib";
-import { BaseControllerPlugin, InstanceRecord } from "@clusterio/controller";
+import type { ControllerPluginContext, InstanceRecord } from "@clusterio/controller";
//%if multi_context // Messages requires multi context
import {
@@ -9,72 +9,61 @@ import {
ExampleSubscribableUpdate, ExampleSubscribableValue,
//%endif
//%if multi_context // Messages requires multi context
-} from "./messages";
+} from "./messages.js";
//%endif
-export class ControllerPlugin extends BaseControllerPlugin {
+export default async function loadControllerPlugin(context: ControllerPluginContext) {
+ const { controller, logger, plugin } = context;
//%if controller & web // Subscribing requires web content and the controller
- exampleDatabase!: Map;
- storageDirty = false;
-
-//%endif
-//%if multi_context // Subscribing requires multi context
- async init() {
- this.controller.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
- this.controller.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
-//%endif
-//%if controller & web // Subscribing requires web content and the controller
- this.controller.subscriptions.handle(ExampleSubscribableUpdate, this.handleExampleSubscription.bind(this));
- // If needed, replace with loading from database file such as lib.Datastore
- this.exampleDatabase = new Map([["foo", new ExampleSubscribableValue("foo", 0, false)]]);
-//%endif
-//%if multi_context // Subscribing requires multi context
- }
+ // If needed, replace with loading from database file such as lib.Datastore
+ const exampleDatabase = new Map([["foo", new ExampleSubscribableValue("foo", 0, false)]]);
//%endif
+//%if multi_context // Messages requires multi context
- async onControllerConfigFieldChanged(field: string, curr: unknown, prev: unknown) {
- this.logger.info(`controller::onControllerConfigFieldChanged ${field}`);
- }
-//%if instance
+ controller.handle(PluginExampleEvent, async (event: PluginExampleEvent) => {
+ logger.info(JSON.stringify(event));
+ });
- async onInstanceConfigFieldChanged(instance: InstanceRecord, 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: InstanceRecord, event: lib.PlayerEvent) {
- this.logger.info(`controller::onPlayerEvent ${instance.id} ${JSON.stringify(event)}`);
- }
-//%if multi_context
-
- async handlePluginExampleEvent(event: PluginExampleEvent) {
- this.logger.info(JSON.stringify(event));
- }
-
- async handlePluginExampleRequest(request: PluginExampleRequest) {
- this.logger.info(JSON.stringify(request));
+ controller.handle(PluginExampleRequest, async (request: PluginExampleRequest) => {
+ logger.info(JSON.stringify(request));
return {
myResponseString: request.myString,
myResponseNumbers: request.myNumberArray,
};
- }
+ });
//%endif
//%if controller & web // Subscribing requires web content and the controller
- async handleExampleSubscription(request: lib.SubscriptionRequest) {
- this.logger.info(JSON.stringify(request));
- const values = [...this.exampleDatabase.values()].filter(
+ controller.subscriptions.handle(ExampleSubscribableUpdate, async (request: lib.SubscriptionRequest) => {
+ logger.info(JSON.stringify(request));
+ const values = [...exampleDatabase.values()].filter(
value => value.updatedAtMs > request.lastRequestTimeMs,
);
return values.length ? new ExampleSubscribableUpdate(values) : null;
- }
+ });
//%endif
+
+ controller.hooks.controllerConfigFieldChanged.attach(plugin.name, async (field, curr, prev) => {
+ logger.info(`controller::controllerConfigFieldChanged ${field}`);
+ });
+//%if instance
+
+ controller.hooks.instanceConfigFieldChanged.attach(
+ plugin.name, async (instance: InstanceRecord, field, curr, prev) => {
+ logger.info(`controller::instanceConfigFieldChanged ${instance.id} ${field}`);
+ }
+ );
+//%endif
+
+ controller.hooks.save.attach(plugin.name, async () => {
+ logger.info("controller::save");
+ });
+
+ controller.hooks.shutdown.attach(plugin.name, async () => {
+ logger.info("controller::shutdown");
+ });
+
+ controller.hooks.playerEvent.attach(plugin.name, async (instance: InstanceRecord, event: lib.PlayerEvent) => {
+ logger.info(`controller::playerEvent ${instance.id} ${JSON.stringify(event)}`);
+ });
}
diff --git a/packages/create/templates/plugin-ts/ctl.ts b/packages/create/templates/plugin-ts/ctl.ts
index dc0fa01b..6211d37c 100644
--- a/packages/create/templates/plugin-ts/ctl.ts
+++ b/packages/create/templates/plugin-ts/ctl.ts
@@ -1,7 +1,7 @@
-import { BaseCtlPlugin, type Control } from "@clusterio/ctl";
+import type { Control, CtlPluginContext } from "@clusterio/ctl";
import { CommandTree, Command } from "@clusterio/lib";
//%// We do not check for multi context here because it doesn't make sense to have a ctl without messages
-import { PluginExampleEvent, PluginExampleRequest } from "./messages";
+import { PluginExampleEvent, PluginExampleRequest } from "./messages.js";
/* eslint-disable no-console */
const pluginCommands = new CommandTree({
@@ -20,8 +20,8 @@ pluginCommands.add(new Command({
}));
/* eslint-enable no-console */
-export class CtlPlugin extends BaseCtlPlugin {
- async addCommands(rootCommand: CommandTree) {
+export default async function loadCtlPlugin(context: CtlPluginContext) {
+ context.hooks.addCommands.attach(context.plugin.name, async (rootCommand) => {
rootCommand.add(pluginCommands);
- }
+ });
}
diff --git a/packages/create/templates/plugin-ts/host.ts b/packages/create/templates/plugin-ts/host.ts
index 9a395988..63df2b5e 100644
--- a/packages/create/templates/plugin-ts/host.ts
+++ b/packages/create/templates/plugin-ts/host.ts
@@ -1,36 +1,30 @@
-import * as lib from "@clusterio/lib";
-import { BaseHostPlugin } from "@clusterio/host";
+import type { HostPluginContext } from "@clusterio/host";
//%if multi_context
-import { PluginExampleEvent, PluginExampleRequest } from "./messages";
+import { PluginExampleEvent, PluginExampleRequest } from "./messages.js";
//%endif
-export class HostPlugin extends BaseHostPlugin {
-//%if multi_context
- async init() {
- this.host.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
- this.host.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
- }
-//%endif
-
- async onHostConfigFieldChanged(field: string, curr: unknown, prev: unknown) {
- this.logger.info(`host::onInstanceConfigFieldChanged ${field}`);
- }
-
- async onShutdown() {
- this.logger.info("host::onShutdown");
- }
+export default async function loadHostPlugin(context: HostPluginContext) {
+ const { host, logger, plugin } = context;
//%if multi_context
- async handlePluginExampleEvent(event: PluginExampleEvent) {
- this.logger.info(JSON.stringify(event));
- }
+ host.handle(PluginExampleEvent, async (event: PluginExampleEvent) => {
+ logger.info(JSON.stringify(event));
+ });
- async handlePluginExampleRequest(request: PluginExampleRequest) {
- this.logger.info(JSON.stringify(request));
+ host.handle(PluginExampleRequest, async (request: PluginExampleRequest) => {
+ logger.info(JSON.stringify(request));
return {
myResponseString: request.myString,
myResponseNumbers: request.myNumberArray,
};
- }
+ });
//%endif
+
+ host.hooks.hostConfigFieldChanged.attach(plugin.name, async (field, curr, prev) => {
+ logger.info(`host::hostConfigFieldChanged ${field}`);
+ });
+
+ host.hooks.shutdown.attach(plugin.name, async () => {
+ logger.info("host::shutdown");
+ });
}
diff --git a/packages/create/templates/plugin-ts/index.ts b/packages/create/templates/plugin-ts/index.ts
index 539a66bc..f35b29e4 100644
--- a/packages/create/templates/plugin-ts/index.ts
+++ b/packages/create/templates/plugin-ts/index.ts
@@ -1,40 +1,8 @@
import * as lib from "@clusterio/lib";
//%if multi_context
-import * as Messages from "./messages";
-
-lib.definePermission({
- name: "__plugin_name__.example.permission.event",
- title: "Example permission event",
- description: "Example Description. Event. Change me in index.ts",
- grantByDefault: true,
-});
-
-lib.definePermission({
- name: "__plugin_name__.example.permission.request",
- title: "Example permission request",
- description: "Example Description. Request. Change me in index.ts",
- grantByDefault: true,
-});
+import * as Messages from "./messages.js";
//%endif
-//%if controller & web // Subscribing requires web content and the controller
-
-lib.definePermission({
- name: "__plugin_name__.example.permission.subscribe",
- title: "Example permission subscribe",
- description: "Example Description. Subscribe. Change me in index.ts",
- grantByDefault: true,
-});
-//%endif
-//%if web
-
-lib.definePermission({
- name: "__plugin_name__.page.view",
- title: "Example page view permission",
- description: "Example Description. View. Change me in index.ts",
- grantByDefault: true,
-});
-//%endif
-//%if config
+//%if config | multi_context | web
declare module "@clusterio/lib" {
//%endif
@@ -58,7 +26,23 @@ declare module "@clusterio/lib" {
"__plugin_name__.myControlField": string;
}
//%endif
-//%if config
+//%if multi_context | web
+ export interface Permissions {
+//%endif
+//%if multi_context
+ "__plugin_name__.example.permission.event": never;
+ "__plugin_name__.example.permission.request": never;
+//%endif
+//%if controller & web // Subscribing requires web content and the controller
+ "__plugin_name__.example.permission.subscribe": never;
+//%endif
+//%if web
+ "__plugin_name__.page.view": never;
+//%endif
+//%if multi_context | web
+ }
+//%endif
+//%if config | multi_context | web
}
//%endif
@@ -71,7 +55,7 @@ export const plugin: lib.PluginDeclaration = {
//%endif
//%if controller
- controllerEntrypoint: "./dist/node/controller",
+ controllerEntrypoint: "./dist/node/controller.js",
//%endif
//%if controller & config
controllerConfigFields: {
@@ -87,7 +71,7 @@ export const plugin: lib.PluginDeclaration = {
//%endif
//%if host
- hostEntrypoint: "./dist/node/host",
+ hostEntrypoint: "./dist/node/host.js",
//%endif
//%if host & config
hostConfigFields: {
@@ -103,7 +87,7 @@ export const plugin: lib.PluginDeclaration = {
//%endif
//%if instance | module // Modules load an empty instance plugin
- instanceEntrypoint: "./dist/node/instance",
+ instanceEntrypoint: "./dist/node/instance.js",
//%endif
//%if instance & config
instanceConfigFields: {
@@ -119,7 +103,7 @@ export const plugin: lib.PluginDeclaration = {
//%endif
//%if ctl
- ctlEntrypoint: "./dist/node/ctl",
+ ctlEntrypoint: "./dist/node/ctl.js",
//%endif
//%if ctl & config
controlConfigFields: {
@@ -143,6 +127,43 @@ export const plugin: lib.PluginDeclaration = {
//%if multi_context // Subscribing requires multi context
],
//%endif
+//%if multi_context | web
+
+ permissions: [
+//%endif
+//%if multi_context
+ {
+ name: "__plugin_name__.example.permission.event",
+ title: "Example permission event",
+ description: "Example Description. Event. Change me in index.ts",
+ grantByDefault: true,
+ },
+ {
+ name: "__plugin_name__.example.permission.request",
+ title: "Example permission request",
+ description: "Example Description. Request. Change me in index.ts",
+ grantByDefault: true,
+ },
+//%endif
+//%if controller & web // Subscribing requires web content and the controller
+ {
+ name: "__plugin_name__.example.permission.subscribe",
+ title: "Example permission subscribe",
+ description: "Example Description. Subscribe. Change me in index.ts",
+ grantByDefault: true,
+ },
+//%endif
+//%if web
+ {
+ name: "__plugin_name__.page.view",
+ title: "Example page view permission",
+ description: "Example Description. View. Change me in index.ts",
+ grantByDefault: true,
+ },
+//%endif
+//%if multi_context | web
+ ],
+//%endif
//%if web // Web content template has an example route which is the plugin name
webEntrypoint: "./web",
diff --git a/packages/create/templates/plugin-ts/instance.ts b/packages/create/templates/plugin-ts/instance.ts
index 133efa6f..72caab87 100644
--- a/packages/create/templates/plugin-ts/instance.ts
+++ b/packages/create/templates/plugin-ts/instance.ts
@@ -1,76 +1,64 @@
import * as lib from "@clusterio/lib";
-import { BaseInstancePlugin } from "@clusterio/host";
+import type { InstancePluginContext } from "@clusterio/host";
//%if multi_context
-import { PluginExampleEvent, PluginExampleRequest } from "./messages";
+import { PluginExampleEvent, PluginExampleRequest } from "./messages.js";
//%endif
//%if module
-type PuginExampleIPC = {
+type PluginExampleIPC = {
tick: number,
player_name: string,
};
//%endif
-export class InstancePlugin extends BaseInstancePlugin {
-//%if multi_context | module
- async init() {
-//%endif
-//%if multi_context
- this.instance.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
- this.instance.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
-//%endif
-//%if module
- this.instance.server.handle("__plugin_name__-plugin_example_ipc", this.handlePluginExampleIPC.bind(this));
-//%endif
-//%if multi_context | module
- }
-//%endif
-
- async onInstanceConfigFieldChanged(field: string, curr: unknown, prev: unknown) {
- this.logger.info(`instance::onInstanceConfigFieldChanged ${field}`);
- }
-
- async onStart() {
- // Called once rcon becomes available
- this.logger.info("instance::onStart");
- }
-
- async onStop() {
- // Called during normal exits before rcon becomes unavailable
- this.logger.info("instance::onStop");
- }
-
- onExit() {
- // Called during all exits, including crashes and init failures, rcon is not available
- this.logger.info("instance::onExit");
- }
-
- async onPlayerEvent(event: lib.PlayerEvent) {
- this.logger.info(`onPlayerEvent::onPlayerEvent ${JSON.stringify(event)}`);
-//%if module
- if (this.instance.status === "running") {
- await this.sendRcon("/sc __plugin_name__.foo()");
- }
-//%endif
- }
+export default async function loadInstancePlugin(context: InstancePluginContext) {
+ const { instance, logger, plugin } = context;
//%if multi_context
- async handlePluginExampleEvent(event: PluginExampleEvent) {
- this.logger.info(JSON.stringify(event));
- }
+ instance.handle(PluginExampleEvent, async (event: PluginExampleEvent) => {
+ logger.info(JSON.stringify(event));
+ });
- async handlePluginExampleRequest(request: PluginExampleRequest) {
- this.logger.info(JSON.stringify(request));
+ instance.handle(PluginExampleRequest, async (request: PluginExampleRequest) => {
+ logger.info(JSON.stringify(request));
return {
myResponseString: request.myString,
myResponseNumbers: request.myNumberArray,
};
- }
+ });
//%endif
//%if module
- async handlePluginExampleIPC(event: PuginExampleIPC) {
- this.logger.info(JSON.stringify(event));
- }
+ instance.server.handle("__plugin_name__-plugin_example_ipc", async (event: PluginExampleIPC) => {
+ logger.info(JSON.stringify(event));
+ });
//%endif
+
+ instance.hooks.instanceConfigFieldChanged.attach(plugin.name, async (field, curr, prev) => {
+ logger.info(`instance::instanceConfigFieldChanged ${field}`);
+ });
+
+ instance.hooks.start.attach(plugin.name, async () => {
+ // Called once rcon becomes available
+ logger.info("instance::start");
+ });
+
+ instance.hooks.stop.attach(plugin.name, async () => {
+ // Called during normal exits before rcon becomes unavailable
+ logger.info("instance::stop");
+ });
+
+ instance.hooks.exit.attach(plugin.name, () => {
+ // Called during all exits, including crashes and init failures, rcon is not available
+ logger.info("instance::exit");
+ });
+
+ instance.hooks.playerEvent.attach(plugin.name, async (event: lib.PlayerEvent) => {
+ logger.info(`instance::playerEvent ${JSON.stringify(event)}`);
+//%if module
+ if (instance.status === "running") {
+ await instance.sendRcon("/sc __plugin_name__.foo()", false, plugin.name);
+ }
+//%endif
+ });
}
diff --git a/packages/create/templates/plugin-ts/instance_empty.ts b/packages/create/templates/plugin-ts/instance_empty.ts
index e8fa7eeb..6d2ce6e8 100644
--- a/packages/create/templates/plugin-ts/instance_empty.ts
+++ b/packages/create/templates/plugin-ts/instance_empty.ts
@@ -1,7 +1,5 @@
-import * as lib from "@clusterio/lib";
-import { BaseInstancePlugin } from "@clusterio/host";
+import type { InstancePluginContext } from "@clusterio/host";
-export class InstancePlugin extends BaseInstancePlugin {
- // This class is empty because an instance plugin must be defined for a module to be injected
- // This requirement may change in the future to allow for standalone modules
-}
+// This entrypoint is empty because an instance entrypoint must be defined for a module to be injected
+// This requirement may change in the future to allow for standalone modules
+export default async function loadInstancePlugin(context: InstancePluginContext) { }
diff --git a/packages/create/templates/plugin-ts/tsconfig.browser.json b/packages/create/templates/plugin-ts/tsconfig.browser.json
index fe5100ca..a8232b97 100644
--- a/packages/create/templates/plugin-ts/tsconfig.browser.json
+++ b/packages/create/templates/plugin-ts/tsconfig.browser.json
@@ -9,5 +9,5 @@
"noEmitOnError": true,
"outDir": "${configDir}/dist/browser",
},
- "include": [ "web/**/*.tsx", "web/**/*.ts", "messages.ts", "package.json" ],
+ "include": [ "web/**/*.tsx", "web/**/*.ts", "index.ts", "messages.ts", "package.json" ],
}
diff --git a/packages/create/templates/plugin-ts/web/plugin.tsx b/packages/create/templates/plugin-ts/web/plugin.tsx
index d4d9d150..513dc7fd 100644
--- a/packages/create/templates/plugin-ts/web/plugin.tsx
+++ b/packages/create/templates/plugin-ts/web/plugin.tsx
@@ -10,7 +10,8 @@ import React, {
// } from "antd";
import {
- BaseWebPlugin, PageLayout, PageHeader, Control, ControlContext, notifyErrorHandler,
+ PageLayout, PageHeader, Control, ControlContext, notifyErrorHandler,
+ type WebPluginContext,
} from "@clusterio/web_ui";
//%if multi_context // Messages requires multi context
@@ -21,16 +22,26 @@ import {
ExampleSubscribableUpdate, ExampleSubscribableValue,
//%endif
//%if multi_context // Messages requires multi context
-} from "../messages";
+} from "../messages.js";
//%endif
import * as lib from "@clusterio/lib";
+//%if controller // Subscribing requires web content and the controller
+
+// Created when the plugin is loaded, before any page is rendered
+let exampleSubscriber: lib.MapSubscriber;
+
+function useSubscribableData() {
+ const control = useContext(ControlContext);
+ const subscribe = useCallback((callback: () => void) => exampleSubscriber.subscribe(callback), [control]);
+ return useSyncExternalStore(subscribe, () => exampleSubscriber.getSnapshot());
+}
+//%endif
function MyTemplatePage() {
const control = useContext(ControlContext);
//%if controller // Subscribing requires web content and the controller
- const plugin = control.plugins.get("__plugin_name__") as WebPlugin;
- const [subscribableData, synced] = plugin.useSubscribableData();
+ const [subscribableData, synced] = useSubscribableData();
//%endif
return
@@ -41,53 +52,39 @@ function MyTemplatePage() {
;
}
-export class WebPlugin extends BaseWebPlugin {
+export default async function loadWebPlugin(context: WebPluginContext) {
+ const { control, logger, plugin } = context;
//%if controller // Subscribing requires web content and the controller
- subscribableData = new lib.EventSubscriber(ExampleSubscribableUpdate, this.control);
-
+ exampleSubscriber = new lib.MapSubscriber(ExampleSubscribableUpdate, 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
+
+ control.hooks.pages.attach(plugin.name, () => [
+ {
+ path: "/__plugin_name__",
+ sidebarName: "__plugin_name__",
+ // This permission is client side only, so it must match the permission string of a resource request to be secure
+ // An undefined value means that the page will always be visible
//%if controller // Subscribing requires web content and the controller
- permission: "__plugin_name__.example.permission.subscribe",
+ permission: "__plugin_name__.example.permission.subscribe",
//%endif
//%if !controller
- permission: "__plugin_name__.example.permission.view",
+ permission: "__plugin_name__.page.view",
//%endif
- content: ,
- },
- ];
+ content: ,
+ },
+ ]);
//%if multi_context
- this.control.handle(PluginExampleEvent, this.handlePluginExampleEvent.bind(this));
- this.control.handle(PluginExampleRequest, this.handlePluginExampleRequest.bind(this));
-//%endif
- }
-//%if controller // Subscribing requires web content and the controller
+ control.handle(PluginExampleEvent, async (event: PluginExampleEvent) => {
+ logger.info(JSON.stringify(event));
+ });
- useSubscribableData() {
- const control = useContext(ControlContext);
- const subscribe = useCallback((callback: () => void) => this.subscribableData.subscribe(callback), [control]);
- return useSyncExternalStore(subscribe, () => this.subscribableData.getSnapshot());
- }
-//%endif
-//%if multi_context
-
- async handlePluginExampleEvent(event: PluginExampleEvent) {
- this.logger.info(JSON.stringify(event));
- }
-
- async handlePluginExampleRequest(request: PluginExampleRequest) {
- this.logger.info(JSON.stringify(request));
+ control.handle(PluginExampleRequest, async (request: PluginExampleRequest) => {
+ logger.info(JSON.stringify(request));
return {
myResponseString: request.myString,
myResponseNumbers: request.myNumberArray,
};
- }
+ });
//%endif
}
diff --git a/packages/ctl/ctl.ts b/packages/ctl/ctl.ts
index 926db7e0..b81562f4 100755
--- a/packages/ctl/ctl.ts
+++ b/packages/ctl/ctl.ts
@@ -7,16 +7,16 @@
import fs from "node:fs/promises";
import yargs, { type Argv } from "yargs";
import path from "path";
-import { version } from "./package.json";
+import packageConfig from "./package.json" with { type: "json" };
import { strict as assert } from "assert";
// Reduce startup time by lazy compiling schemas.
-(global as any).lazySchemaCompilation = true;
+import "./src/set_lazy_schema_compliation.js";
import * as lib from "@clusterio/lib";
import { ConsoleTransport, levels, logger } from "@clusterio/lib";
-import * as commands from "./src/commands";
-import BaseCtlPlugin from "./src/BaseCtlPlugin";
+import * as commands from "./src/commands.js";
+import { BaseCtlPlugin, CtlHooks } from "./src/BaseCtlPlugin.js";
/**
@@ -37,7 +37,7 @@ class ControlConnector extends lib.WebSocketClientConnector {
new lib.MessageRegisterControl(
new lib.RegisterControlData(
this._token,
- version,
+ packageConfig.version,
)
)
);
@@ -53,19 +53,15 @@ class ControlConnector extends lib.WebSocketClientConnector {
export class Control extends lib.Link {
/** Control config used for connecting to the controller. */
config: lib.ControlConfig;
- /** Mapping of plugin names to their instance for loaded plugins. */
- plugins: Map;
/** Keep the control connection alive after the command completes. */
keepOpen = false;
constructor(
connector: ControlConnector,
controlConfig: lib.ControlConfig,
- ctlPlugins: Map
) {
super(connector);
this.config = controlConfig;
- this.plugins = ctlPlugins;
this.handle(lib.LogMessageEvent, this.handleLogMessageEvent.bind(this));
this.handle(lib.DebugWsMessageEvent, this.handleDebugWsMessageEvent.bind(this));
@@ -105,27 +101,23 @@ export class Control extends lib.Link {
}
}
-async function loadPlugins(pluginList: Map) {
+async function loadPlugins(pluginList: Map, hooks: CtlHooks) {
let pluginInfos = await lib.loadPluginInfos(pluginList);
lib.registerPluginMessages(pluginInfos);
+ lib.registerPluginPermissions(pluginInfos);
lib.addPluginConfigFields(pluginInfos);
- let ctlPlugins = new Map();
+ const ctlPlugins = new Set();
for (let pluginInfo of pluginInfos) {
if (!pluginInfo.ctlEntrypoint) {
continue;
}
- let CtlPlugin = await lib.loadPluginClass(
- pluginInfo.name,
- path.posix.join(pluginInfo.requirePath, pluginInfo.ctlEntrypoint),
- "CtlPlugin",
- BaseCtlPlugin,
- );
- let ctlPlugin = new CtlPlugin(pluginInfo, logger);
- ctlPlugins.set(pluginInfo.name, ctlPlugin);
- await ctlPlugin.init();
+ const context = { hooks, logger, plugin: pluginInfo };
+ await lib.loadPlugin(pluginInfo, "ctl", context, "CtlPlugin", BaseCtlPlugin);
+ ctlPlugins.add(pluginInfo);
}
+
return ctlPlugins;
}
@@ -141,14 +133,14 @@ interface CtlArguments {
interface InitializeParameters {
args: CtlArguments;
shouldRun: boolean;
- ctlPlugins?: Map;
+ ctlHooks: CtlHooks;
rootCommands?: lib.CommandTree;
controlConfig?: lib.ControlConfig;
}
export async function initialize(
argv: string | string[],
- ctlPlugins?: Map,
+ ctlHooks: CtlHooks = new CtlHooks(logger),
noLoggerTransport?: boolean,
): Promise {
// Build a fresh, isolated yargs parser each time this function is called.
@@ -207,23 +199,23 @@ export async function initialize(
lib.handleUnhandledErrors();
}
- // Discover and load plugins. This check exists to allow tests to inject plugins.
- if (!ctlPlugins || args._[0] === "plugin") {
+ // Discover and load plugins. ctlHooks.size check exists to allow tests to inject plugins.
+ if (ctlHooks.addCommands.size === 0 || args._[0] === "plugin") {
logger.verbose(`Loading available plugins from ${args.pluginList}`);
const pluginList = await lib.loadPluginList(args.pluginList);
// If the command is plugin management we don't try to load plugins
if (args._[0] === "plugin") {
await lib.handlePluginCommand(args, pluginList, args.pluginList);
- return { args, shouldRun: false };
+ return { args, ctlHooks, shouldRun: false };
}
logger.verbose("Loading Plugins");
- ctlPlugins = await loadPlugins(pluginList);
+ await loadPlugins(pluginList, ctlHooks);
}
// Add all commands including from plugins and reparse with help and strict checking.
- const rootCommands = await commands.registerCommands(ctlPlugins, parser);
+ const rootCommands = await commands.registerCommands(ctlHooks, parser);
args = parser
.help()
.strict()
@@ -260,10 +252,10 @@ export async function initialize(
// Handle the control-config command before trying to connect.
if (args._[0] === "control-config") {
await lib.handleConfigCommand(args, controlConfig, controlConfigLock);
- return { args, controlConfig, ctlPlugins, rootCommands, shouldRun: false };
+ return { args, controlConfig, ctlHooks, rootCommands, shouldRun: false };
}
- return { args, controlConfig, ctlPlugins, rootCommands, shouldRun: true };
+ return { args, controlConfig, ctlHooks, rootCommands, shouldRun: true };
}
export function selectTargetCommand(args: CtlArguments, rootCommands: lib.CommandTree): lib.Command {
@@ -280,11 +272,10 @@ async function startControl() {
const {
args,
shouldRun,
- ctlPlugins,
rootCommands,
controlConfig,
} = await initialize(process.argv.slice(2));
- if (!shouldRun || !ctlPlugins || !rootCommands || !controlConfig) {
+ if (!shouldRun || !rootCommands || !controlConfig) {
return;
}
@@ -300,7 +291,7 @@ async function startControl() {
controlConfig.get("control.controller_token")!,
);
- let control = new Control(controlConnector, controlConfig, ctlPlugins);
+ let control = new Control(controlConnector, controlConfig);
try {
await controlConnector.connect();
} catch (err) {
@@ -377,6 +368,6 @@ ${err.stack}`
});
}
-if (module === require.main) {
+if (import.meta.main) {
bootstrap();
}
diff --git a/packages/ctl/index.ts b/packages/ctl/index.ts
index 54bd5f27..fe5bb9da 100644
--- a/packages/ctl/index.ts
+++ b/packages/ctl/index.ts
@@ -1,7 +1,7 @@
-import { bootstrap } from "./ctl";
-export * from "./ctl";
-export { default as BaseCtlPlugin } from "./src/BaseCtlPlugin";
+import { bootstrap } from "./ctl.js";
+export * from "./ctl.js";
+export { BaseCtlPlugin, CtlHooks, type CtlPluginContext } from "./src/BaseCtlPlugin.js";
-if (module === require.main) {
+if (import.meta.main) {
bootstrap();
}
diff --git a/packages/ctl/package.json b/packages/ctl/package.json
index 96d18c33..04f1373b 100644
--- a/packages/ctl/package.json
+++ b/packages/ctl/package.json
@@ -22,6 +22,7 @@
"access": "public"
},
+ "type": "module",
"main": "dist/node/index.js",
"scripts": {
"prepare": "tsc --build",
diff --git a/packages/ctl/src/BaseCtlPlugin.ts b/packages/ctl/src/BaseCtlPlugin.ts
index bed7534b..6b1dbae1 100644
--- a/packages/ctl/src/BaseCtlPlugin.ts
+++ b/packages/ctl/src/BaseCtlPlugin.ts
@@ -1,38 +1,18 @@
-import type { CommandTree, Logger, PluginNodeEnvInfo } from "@clusterio/lib";
+import * as lib from "@clusterio/lib";
+
+export type CtlPluginContext = lib.PluginLoadContext<{
+ hooks: CtlHooks
+}>;
/**
- * Base class for clusterioctl plugins
- *
- * Ctl plugins are subclasses of this class which get instantiated by
- * clusterioctl in order to extend its functionallity. To be discovered the
- * class must be exported under the name `CtlPlugin` in the module
- * specified by the `ctlEntrypoint` in the plugin's `plugin` export.
+ * Collection of clusterioctl plugin hooks
*/
-export default class BaseCtlPlugin {
- /**
- * Logger for this plugin
- *
- * Instance of winston Logger for sending log messages from this
- * plugin. Supported methods and their corresponding log levels are
- * `error`, `warn`, `audit`, `info` and `verbose`.
- */
- logger: Logger;
-
- constructor(
- /**
- * The plugin's own info module
- */
- public info: PluginNodeEnvInfo,
- logger: Logger,
- ) {
- this.logger = logger.child({ plugin: this.info.name }) as unknown as Logger;
+export class CtlHooks extends lib.AsyncHookCollection {
+ constructor(logger: lib.Logger) {
+ super(logger);
+ this.addCommands = this.newHook();
}
- /**
- * Called immediately after the class is instantiated
- */
- async init() { }
-
/**
* Called to add commands to the command line interface.
*
@@ -43,5 +23,47 @@ export default class BaseCtlPlugin {
* @param rootCommand -
* Root of the clusterioctl command tree.
*/
- async addCommands(rootCommand: CommandTree) { }
+ readonly addCommands: lib.AsyncHook<[rootCommand: lib.CommandTree]>;
+}
+
+// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
+export interface BaseCtlPlugin {
+ addCommands?(rootCommand: lib.CommandTree): Promise;
+}
+
+/**
+ * Base class for clusterioctl plugins
+ *
+ * Ctl plugins are subclasses of this class which get instantiated by
+ * clusterioctl in order to extend its functionallity. To be discovered the
+ * class must be exported under the name `CtlPlugin` in the module
+ * specified by the `ctlEntrypoint` in the plugin's `plugin` export.
+ */
+// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
+export class BaseCtlPlugin {
+ constructor(
+ /**
+ * The plugin's own info module
+ */
+ public info: lib.PluginNodeEnvInfo,
+ public logger: lib.Logger,
+ private hooks: CtlHooks,
+ ) {
+ if (this.addCommands) {
+ hooks.addCommands.attach(info.name, this.addCommands.bind(this));
+ }
+ }
+
+ static fromContext(context: CtlPluginContext) {
+ return new this(context.plugin, context.logger, context.hooks);
+ }
+
+ detachHooks() {
+ this.hooks.detachAll(this.info.name);
+ }
+
+ /**
+ * Called immediately after the class is instantiated
+ */
+ async init() {}
}
diff --git a/packages/ctl/src/commands.ts b/packages/ctl/src/commands.ts
index ffb46193..3bcfdb58 100644
--- a/packages/ctl/src/commands.ts
+++ b/packages/ctl/src/commands.ts
@@ -1,19 +1,19 @@
import type { Argv } from "yargs";
import * as lib from "@clusterio/lib";
-import type BaseCtlPlugin from "./BaseCtlPlugin";
-import { controllerCommands } from "./commands_controller";
-import { hostCommands } from "./commands_host";
-import { instanceCommands } from "./commands_instance";
-import { modPackCommands } from "./commands_mod_pack";
-import { modCommands } from "./commands_mod";
-import { permissionCommands } from "./commands_permission";
-import { roleCommands } from "./commands_role";
-import { userCommands } from "./commands_user";
-import { logCommands } from "./commands_log";
-import { debugCommands } from "./commands_debug";
+import { controllerCommands } from "./commands_controller.js";
+import { hostCommands } from "./commands_host.js";
+import { instanceCommands } from "./commands_instance.js";
+import { modPackCommands } from "./commands_mod_pack.js";
+import { modCommands } from "./commands_mod.js";
+import { permissionCommands } from "./commands_permission.js";
+import { roleCommands } from "./commands_role.js";
+import { userCommands } from "./commands_user.js";
+import { logCommands } from "./commands_log.js";
+import { debugCommands } from "./commands_debug.js";
+import type { CtlHooks } from "./BaseCtlPlugin.js";
-export async function registerCommands(ctlPlugins: Map, yargs: Argv) {
+export async function registerCommands(ctlHooks: CtlHooks, yargs: Argv) {
const rootCommands = new lib.CommandTree({ name: "clusterioctl", description: "Manage cluster" });
rootCommands.add(controllerCommands);
rootCommands.add(hostCommands);
@@ -26,9 +26,7 @@ export async function registerCommands(ctlPlugins: Map, y
rootCommands.add(logCommands);
rootCommands.add(debugCommands);
- for (let controlPlugin of ctlPlugins.values()) {
- await controlPlugin.addCommands(rootCommands);
- }
+ await ctlHooks.addCommands.invoke(rootCommands);
for (let [name, command] of rootCommands.subCommands) {
if (name === command.name) {
diff --git a/packages/ctl/src/commands_controller.ts b/packages/ctl/src/commands_controller.ts
index fbdde7a4..a6684abd 100644
--- a/packages/ctl/src/commands_controller.ts
+++ b/packages/ctl/src/commands_controller.ts
@@ -5,9 +5,9 @@ import os from "os";
import child_process from "child_process";
import * as lib from "@clusterio/lib";
-import type { Control } from "../ctl";
-import { print } from "./command_ops";
-import { serializedConfigToString, getEditor, configToKeyVal } from "./config_ops";
+import type { Control } from "../ctl.js";
+import { print } from "./command_ops.js";
+import { serializedConfigToString, getEditor, configToKeyVal } from "./config_ops.js";
const asTable = asTableModule.configure({ delimiter: " | " });
@@ -28,9 +28,14 @@ controllerCommands.add(new lib.Command({
controllerCommands.add(new lib.Command({
definition: ["update", "Update the controller", (yargs) => {
yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" });
+ yargs.option("all", { alias: "a", type: "boolean", description: "Also update all plugins" });
}],
- handler: async function(args: { restart: boolean }, control: Control) {
- await control.send(new lib.ControllerUpdateRequest());
+ handler: async function(args: { restart: boolean, all: boolean }, control: Control) {
+ if (args.all) {
+ await control.send(new lib.UpdateAllRequest());
+ } else {
+ await control.send(new lib.ControllerUpdateRequest());
+ }
if (args.restart) {
await control.send(new lib.ControllerRestartRequest());
} else {
diff --git a/packages/ctl/src/commands_debug.ts b/packages/ctl/src/commands_debug.ts
index af991afc..c69e9298 100644
--- a/packages/ctl/src/commands_debug.ts
+++ b/packages/ctl/src/commands_debug.ts
@@ -1,5 +1,5 @@
import * as lib from "@clusterio/lib";
-import type { Control } from "../ctl";
+import type { Control } from "../ctl.js";
export const debugCommands = new lib.CommandTree({ name: "debug", description: "Debugging utilities" });
debugCommands.add(new lib.Command({
diff --git a/packages/ctl/src/commands_host.ts b/packages/ctl/src/commands_host.ts
index c44db2fe..fe408679 100644
--- a/packages/ctl/src/commands_host.ts
+++ b/packages/ctl/src/commands_host.ts
@@ -6,9 +6,9 @@ import child_process from "child_process";
import * as lib from "@clusterio/lib";
import { logger } from "@clusterio/lib";
-import type { Control } from "../ctl";
-import { print } from "./command_ops";
-import { serializedConfigToString, getEditor, configToKeyVal } from "./config_ops";
+import type { Control } from "../ctl.js";
+import { print } from "./command_ops.js";
+import { serializedConfigToString, getEditor, configToKeyVal } from "./config_ops.js";
const asTable = asTableModule.configure({ delimiter: " | " });
@@ -87,7 +87,7 @@ hostCommands.add(new lib.Command({
} else {
logger.info(`Writing ${args.output}`);
try {
- await fs.writeFile(args.output, content, { flag: "wx" });
+ await fs.writeFile(args.output, content, { flag: "wx", mode: 0o600 });
} catch (err: any) {
if (err.code === "EEXIST") {
throw new lib.CommandError(`File ${args.output} already exists`);
@@ -101,10 +101,15 @@ hostCommands.add(new lib.Command({
definition: ["update ", "Update the host", (yargs) => {
yargs.positional("host", { describe: "Host to update", type: "string" });
yargs.option("restart", { alias: "r", type: "boolean", description: "Restart after update" });
+ yargs.option("all", { alias: "a", type: "boolean", description: "Also update all plugins" });
}],
- handler: async function(args: { host: string, restart: boolean }, control: Control) {
+ handler: async function(args: { host: string, restart: boolean, all: boolean }, control: Control) {
let hostId = await lib.resolveHost(control, args.host);
- await control.sendTo({ hostId }, new lib.HostUpdateRequest());
+ if (args.all) {
+ await control.sendTo({ hostId }, new lib.UpdateAllRequest());
+ } else {
+ await control.sendTo({ hostId }, new lib.HostUpdateRequest());
+ }
if (args.restart) {
await control.sendTo({ hostId }, new lib.HostRestartRequest());
} else {
diff --git a/packages/ctl/src/commands_instance.ts b/packages/ctl/src/commands_instance.ts
index 173451b4..8451d56c 100644
--- a/packages/ctl/src/commands_instance.ts
+++ b/packages/ctl/src/commands_instance.ts
@@ -9,9 +9,9 @@ import util from "util";
import * as lib from "@clusterio/lib";
import { logger } from "@clusterio/lib";
-import type { Control } from "../ctl";
-import { print } from "./command_ops";
-import { serializedConfigToString, getEditor, configToKeyVal } from "./config_ops";
+import type { Control } from "../ctl.js";
+import { print } from "./command_ops.js";
+import { serializedConfigToString, getEditor, configToKeyVal } from "./config_ops.js";
const asTable = asTableModule.configure({ delimiter: " | " });
const finished = util.promisify(stream.finished);
diff --git a/packages/ctl/src/commands_log.ts b/packages/ctl/src/commands_log.ts
index c121b3bc..4cce13a3 100644
--- a/packages/ctl/src/commands_log.ts
+++ b/packages/ctl/src/commands_log.ts
@@ -2,7 +2,7 @@ import winston from "winston";
import * as lib from "@clusterio/lib";
import { ConsoleTransport, levels, logger } from "@clusterio/lib";
-import type { Control } from "../ctl";
+import type { Control } from "../ctl.js";
export const logCommands = new lib.CommandTree({ name: "log", description: "Log inspection" });
logCommands.add(new lib.Command({
diff --git a/packages/ctl/src/commands_mod.ts b/packages/ctl/src/commands_mod.ts
index 83f8873a..778ead86 100644
--- a/packages/ctl/src/commands_mod.ts
+++ b/packages/ctl/src/commands_mod.ts
@@ -7,8 +7,8 @@ import util from "util";
import * as lib from "@clusterio/lib";
import { logger } from "@clusterio/lib";
-import type { Control } from "../ctl";
-import { print } from "./command_ops";
+import type { Control } from "../ctl.js";
+import { print } from "./command_ops.js";
const asTable = asTableModule.configure({ delimiter: " | " });
const finished = util.promisify(stream.finished);
diff --git a/packages/ctl/src/commands_mod_pack.ts b/packages/ctl/src/commands_mod_pack.ts
index a4af8a90..6c88679f 100644
--- a/packages/ctl/src/commands_mod_pack.ts
+++ b/packages/ctl/src/commands_mod_pack.ts
@@ -2,8 +2,8 @@ import asTableModule from "as-table";
import * as lib from "@clusterio/lib";
import { logger } from "@clusterio/lib";
-import type { Control } from "../ctl";
-import { print } from "./command_ops";
+import type { Control } from "../ctl.js";
+import { print } from "./command_ops.js";
const asTable = asTableModule.configure({ delimiter: " | " });
diff --git a/packages/ctl/src/commands_permission.ts b/packages/ctl/src/commands_permission.ts
index 94d2fbe4..0094314b 100644
--- a/packages/ctl/src/commands_permission.ts
+++ b/packages/ctl/src/commands_permission.ts
@@ -1,8 +1,8 @@
import asTableModule from "as-table";
import * as lib from "@clusterio/lib";
-import type { Control } from "../ctl";
-import { print } from "./command_ops";
+import type { Control } from "../ctl.js";
+import { print } from "./command_ops.js";
const asTable = asTableModule.configure({ delimiter: " | " });
diff --git a/packages/ctl/src/commands_role.ts b/packages/ctl/src/commands_role.ts
index ae99188d..e1707255 100644
--- a/packages/ctl/src/commands_role.ts
+++ b/packages/ctl/src/commands_role.ts
@@ -2,8 +2,8 @@ import asTableModule from "as-table";
import * as lib from "@clusterio/lib";
import { logger } from "@clusterio/lib";
-import type { Control } from "../ctl";
-import { print } from "./command_ops";
+import type { Control } from "../ctl.js";
+import { print } from "./command_ops.js";
const asTable = asTableModule.configure({ delimiter: " | " });
diff --git a/packages/ctl/src/commands_user.ts b/packages/ctl/src/commands_user.ts
index 37887013..1f8817f6 100644
--- a/packages/ctl/src/commands_user.ts
+++ b/packages/ctl/src/commands_user.ts
@@ -3,8 +3,8 @@ import asTableModule from "as-table";
import path from "path";
import * as lib from "@clusterio/lib";
-import type { Control } from "../ctl";
-import { print } from "./command_ops";
+import type { Control } from "../ctl.js";
+import { print } from "./command_ops.js";
const asTable = asTableModule.configure({ delimiter: " | " });
diff --git a/packages/ctl/src/set_lazy_schema_compliation.ts b/packages/ctl/src/set_lazy_schema_compliation.ts
new file mode 100644
index 00000000..f5ff3418
--- /dev/null
+++ b/packages/ctl/src/set_lazy_schema_compliation.ts
@@ -0,0 +1,3 @@
+// This needs to be a separate file due to ECMAScript Modules evaluating code in
+// imported modules before any code in the file that imports it.
+(global as any).lazySchemaCompilation = true;
diff --git a/packages/host/README.md b/packages/host/README.md
index 9fc74d22..7992ff28 100644
--- a/packages/host/README.md
+++ b/packages/host/README.md
@@ -80,9 +80,10 @@ Shows the value for a single config entry.
### `create-scenario