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 ` -Create a scenario with the Clusterio modules patched into it and write it to the `output` directory. +Create a scenario with the Clusterio modules patched into it and write it to `output`. This is the same patching that is applied to saves when an instance starts, see docs/how-it-works.md in the main repository. The result can be used as a regular scenario outside of Clusterio by copying it to the `scenarios` folder of Factorio or into a mod. +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. By default the freeplay scenario from the latest Factorio install in `host.factorio_directory` is patched with the modules from all plugins that are enabled in the host config. @@ -94,6 +95,9 @@ By default the freeplay scenario from the latest Factorio install in `host.facto * `--plugins ...` Only patch in modules from the given plugins. + * `--zip` Write the scenario as a zip file instead of a directory. + Factorio does not load zipped scenarios, extract it before use. + For example, creating a scenario with only the inventory_sync module: npx clusteriohost create-scenario ~/.factorio/scenarios/inventory_sync --plugins inventory_sync diff --git a/packages/host/host.ts b/packages/host/host.ts index 8794e49d..87e6aa1d 100755 --- a/packages/host/host.ts +++ b/packages/host/host.ts @@ -15,7 +15,7 @@ import fs from "node:fs/promises"; import path from "path"; import yargs from "yargs"; -import { version } from "./package.json"; +import packageConfig from "./package.json" with { type: "json" }; import winston from "winston"; import "winston-daily-rotate-file"; @@ -23,8 +23,8 @@ import "winston-daily-rotate-file"; import * as lib from "@clusterio/lib"; import { ConsoleTransport, levels, logger } from "@clusterio/lib"; -import Host from "./src/Host"; -import { createScenarioCommand, handleCreateScenarioCommand } from "./src/create_scenario"; +import Host from "./src/Host.js"; +import { createScenarioCommand, handleCreateScenarioCommand } from "./src/create_scenario.js"; let host: Host | undefined; @@ -70,7 +70,7 @@ export class HostConnector extends lib.WebSocketClientConnector { new lib.MessageRegisterHost( new lib.RegisterHostData( this.hostConfig.get("host.controller_token"), - version, + packageConfig.version, this.hostConfig.get("host.id"), plugins, ) @@ -83,7 +83,7 @@ export class HostConnector extends lib.WebSocketClientConnector { async function startHost() { // argument parsing // eslint-disable-next-line node/no-sync - const args = yargs + const args = yargs(process.argv.slice(2)) .scriptName("host") .usage("$0 [options]") .option("log-level", { @@ -151,7 +151,7 @@ async function startHost() { let command = args._[0]; if (command === "run") { - logger.info(`Starting Clusterio host ${version}`); + logger.info(`Starting Clusterio host ${packageConfig.version}`); if (args.recovery) { logger.warn("Host recovery mode enabled. Some features will be disabled."); } @@ -169,6 +169,7 @@ async function startHost() { logger.info("Loading Plugin info"); let pluginInfos = await lib.loadPluginInfos(pluginList); lib.registerPluginMessages(pluginInfos); + lib.registerPluginPermissions(pluginInfos); lib.addPluginConfigFields(pluginInfos); const hostConfigPath = args.config; @@ -193,7 +194,7 @@ async function startHost() { } } - hostConfig.set("host.version", version); // Allows tracking last loaded version + hostConfig.set("host.version", packageConfig.version); // Allows tracking last loaded version if (command === "config") { await lib.handleConfigCommand(args, hostConfig, hostConfigLock); @@ -319,6 +320,6 @@ ${err.stack}` }); } -if (module === require.main) { +if (import.meta.main) { bootstrap(); } diff --git a/packages/host/index.ts b/packages/host/index.ts index f39ce536..c9d81c49 100644 --- a/packages/host/index.ts +++ b/packages/host/index.ts @@ -1,10 +1,10 @@ -import { bootstrap } from "./host"; -export { default as BaseHostPlugin } from "./src/BaseHostPlugin"; -export { default as BaseInstancePlugin } from "./src/BaseInstancePlugin"; -export { default as Host, HostRouter } from "./src/Host"; -export { default as Instance } from "./src/Instance"; -export { default as InstanceConnection } from "./src/InstanceConnection"; +import { bootstrap } from "./host.js"; +export { BaseHostPlugin, HostHooks, type HostPluginContext } from "./src/BaseHostPlugin.js"; +export { BaseInstancePlugin, InstanceHooks, type InstancePluginContext } from "./src/BaseInstancePlugin.js"; +export { default as Host, HostRouter } from "./src/Host.js"; +export { default as Instance } from "./src/Instance.js"; +export { default as InstanceConnection } from "./src/InstanceConnection.js"; -if (module === require.main) { +if (import.meta.main) { bootstrap(); } diff --git a/packages/host/package.json b/packages/host/package.json index e80c6082..542caa42 100644 --- a/packages/host/package.json +++ b/packages/host/package.json @@ -22,6 +22,7 @@ "access": "public" }, + "type": "module", "main": "dist/node/index.js", "scripts": { "prepare": "tsc --build", diff --git a/packages/host/src/BaseHostPlugin.ts b/packages/host/src/BaseHostPlugin.ts index 131da3db..06afe419 100644 --- a/packages/host/src/BaseHostPlugin.ts +++ b/packages/host/src/BaseHostPlugin.ts @@ -1,45 +1,24 @@ -import type { - CollectorResult, Logger, ParsedFactorioOutput, PlayerEvent, PluginNodeEnvInfo, -} from "@clusterio/lib"; -import type Host from "./Host"; +import type Host from "./Host.js"; + +import * as lib from "@clusterio/lib"; + +export type HostPluginContext = lib.PluginLoadContext<{ + host: Host; +}>; /** - * Base class for host plugins - * - * Host plugins are subclasses of this class which get instantiated by - * the host when it starts up with the plugin enabled in the config. To be - * discovered the class must be exported under the name `HostPlugin` in the - * module specified by the `hostEntrypoint` in the plugin's `plugin` export. + * Collection of host plugin hooks */ -export default class BaseHostPlugin { - /** - * 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, - /** - * Host the plugin started for - */ - public host: Host, - logger: Logger, - ) { - this.logger = logger.child({ plugin: this.info.name }) as unknown as Logger; +export class HostHooks extends lib.AsyncHookCollection { + constructor(logger: lib.Logger) { + super(logger); + this.metrics = this.newHook(); + this.shutdown = this.newHook(); + this.hostConfigFieldChanged = this.newHook(); + this.controllerConnectionEvent = this.newHook(); + this.prepareControllerDisconnect = this.newHook(); } - /** - * Called immediately after the class is instantiated - */ - async init() { } - /** * Called when the value of a host config field changed. * @@ -50,7 +29,7 @@ export default class BaseHostPlugin { * @param curr - The current value of the field. * @param prev - The previous value of the field. */ - async onHostConfigFieldChanged(field: string, curr: unknown, prev: unknown) { } + readonly hostConfigFieldChanged: lib.AsyncHook<[field: string, curr: unknown, prev: unknown]>; /** * Called before collecting Prometheus metrics @@ -67,12 +46,12 @@ export default class BaseHostPlugin { * * @returns an async iterator of prometheus metric results or undefined. */ - async onMetrics(): Promise> { } + readonly metrics: lib.AsyncHook<[], AsyncIterable>; /** * Called when the host is shutting down - */ - async onShutdown() { } + */ + readonly shutdown: lib.AsyncHook<[]>; /** * Called when an event on the controller connection happens @@ -108,7 +87,7 @@ export default class BaseHostPlugin { * * @param event - one of connect, drop, resume and close */ - onControllerConnectionEvent(event: "connect" | "drop" | "resume" | "close") { } + readonly controllerConnectionEvent: lib.AsyncHook<[event: "connect" | "drop" | "resume" | "close"]>; /** * Called when the controller is preparing to disconnect from the host @@ -123,5 +102,60 @@ export default class BaseHostPlugin { * @param connection - * The connection to the host preparing to disconnect. */ - async onPrepareControllerDisconnect(connection: Host) { } + readonly prepareControllerDisconnect: lib.AsyncHook<[connection: Host]>; +} + +// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging +export interface BaseHostPlugin { + onHostConfigFieldChanged?(field: string, curr: unknown, prev: unknown): Promise; + onMetrics?(): Promise>; + onShutdown?(): Promise; + onControllerConnectionEvent?(event: "connect" | "drop" | "resume" | "close"): void; + onPrepareControllerDisconnect?(connection: Host): Promise; +} + +/** + * Base class for host plugins + * + * Host plugins are subclasses of this class which get instantiated by + * the host when it starts up with the plugin enabled in the config. To be + * discovered the class must be exported under the name `HostPlugin` in the + * module specified by the `hostEntrypoint` in the plugin's `plugin` export. + */ +// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging +export class BaseHostPlugin { + constructor( + public info: lib.PluginNodeEnvInfo, + public host: Host, + public logger: lib.Logger, + ) { + const attach = ( + hook: lib.AsyncHook, + fn?: lib.HookHandler, + ) => { + if (fn) { + hook.attach(info.name, fn.bind(this)); + } + }; + + attach(host.hooks.hostConfigFieldChanged, this.onHostConfigFieldChanged); + attach(host.hooks.metrics, this.onMetrics); + attach(host.hooks.shutdown, this.onShutdown); + attach(host.hooks.controllerConnectionEvent, this.onControllerConnectionEvent); + attach(host.hooks.prepareControllerDisconnect, this.onPrepareControllerDisconnect); + } + + static fromContext(context: HostPluginContext): BaseHostPlugin { + const plugin = new this(context.plugin, context.host, context.logger); + return plugin; + } + + detachHooks() { + this.host.hooks.detachAll(this.info.name); + } + + /** + * Called immediately after the class is instantiated + */ + async init() {} } diff --git a/packages/host/src/BaseInstancePlugin.ts b/packages/host/src/BaseInstancePlugin.ts index 515af1ad..ac88662e 100644 --- a/packages/host/src/BaseInstancePlugin.ts +++ b/packages/host/src/BaseInstancePlugin.ts @@ -1,69 +1,30 @@ -import type { - CollectorResult, Logger, ParsedFactorioOutput, PlayerEvent, PluginNodeEnvInfo, -} from "@clusterio/lib"; -import type Instance from "./Instance"; -import type Host from "./Host"; +import type Instance from "./Instance.js"; +import type Host from "./Host.js"; + +import * as lib from "@clusterio/lib"; + +export type InstancePluginContext = lib.PluginLoadContext<{ + host: Host; + instance: Instance; +}>; /** - * Base class for instance plugins - * - * Instance plugins are subclasses of this class which get instantiated by - * the host when it brings up an instance with the plugin enabled in the - * config. To be discovered the class must be exported under the name - * `InstancePlugin` in the module specified by the `instanceEntrypoint` in - * the plugin's `plugin` export. - * - * Instances may be started and stopped many times, and many instances may - * be running at the same time, each of which will have their own instance - * of the InstancePlugin class. + * Collection of instance plugin hooks */ -export default class BaseInstancePlugin { - /** - * 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; - - private _pendingRconMessages: { - resolve: (result: string) => void, - reject: (err: Error) => void, - message: string, - expectEmpty: boolean, - }[] = []; - - private _sendingRconMessages = false; - - constructor( - /** - * The plugin's own info module - */ - public info: PluginNodeEnvInfo, - /** - * Instance the plugin started for - */ - public instance: Instance, - /** - * Host running the instance - * - * With the exepction of accessing the host's config you should - * avoid ineracting with the host object directly. - */ - public host: Host, - ) { - this.logger = instance.logger.child({ plugin: this.info.name }) as unknown as Logger; - - this._pendingRconMessages = []; - this._sendingRconMessages = false; +export class InstanceHooks extends lib.AsyncHookCollection { + constructor(logger: lib.Logger) { + super(logger); + this.metrics = this.newHook(); + this.start = this.newHook(); + this.stop = this.newHook(); + this.exit = this.newHook(); + this.output = this.newHook(); + this.instanceConfigFieldChanged = this.newHook(); + this.controllerConnectionEvent = this.newHook(); + this.prepareControllerDisconnect = this.newHook(); + this.playerEvent = this.newHook(); } - /** - * Called immediately after the class is instantiated - */ - async init() { } - /** * Called when the value of a config field changed. * @@ -74,7 +35,7 @@ export default class BaseInstancePlugin { * @param curr - The current value of the field. * @param prev - The previous value of the field. */ - async onInstanceConfigFieldChanged(field: string, curr: unknown, prev: unknown) { } + readonly instanceConfigFieldChanged: lib.AsyncHook<[field: string, curr: unknown, prev: unknown]>; /** * Called before collecting Prometheus metrics @@ -91,12 +52,12 @@ export default class BaseInstancePlugin { * * @returns an async iterator of prometheus metric results or undefined. */ - async onMetrics(): Promise> { } + readonly metrics: lib.AsyncHook<[], AsyncIterable>; /** * Called after the Factorio server is started */ - async onStart() { } + readonly start: lib.AsyncHook<[]>; /** * Called before the Factorio server is stopped @@ -104,7 +65,7 @@ export default class BaseInstancePlugin { * This will not be called if for example the Factorio server crashes or * is killed. */ - async onStop() { } + readonly stop: lib.AsyncHook<[]>; /** * Called when the instance exits @@ -113,7 +74,7 @@ export default class BaseInstancePlugin { * has been called if an error occurs during startup. Note that if * the plugin's init() throws this method will still be invoked. */ - onExit() { } + readonly exit: lib.AsyncHook<[]>; /** * Called when the Factorio outputs a line @@ -125,7 +86,7 @@ export default class BaseInstancePlugin { * @param parsed - parsed server output. * @param line - raw line of server output. */ - async onOutput(parsed: ParsedFactorioOutput, line: string) { } + readonly output: lib.AsyncHook<[parsed: lib.ParsedFactorioOutput, line: string]>; /** * Called when an event on the controller connection happens @@ -161,7 +122,7 @@ export default class BaseInstancePlugin { * * @param event - one of connect, drop, resume and close */ - onControllerConnectionEvent(event: "connect" | "drop" | "resume" | "close") { } + readonly controllerConnectionEvent: lib.AsyncHook<[event: "connect" | "drop" | "resume" | "close"]>; /** * Called when the controller is preparing to disconnect from the host @@ -176,7 +137,7 @@ export default class BaseInstancePlugin { * @param connection - * The connection to the host preparing to disconnect. */ - async onPrepareControllerDisconnect(connection: Instance) { } + readonly prepareControllerDisconnect: lib.AsyncHook<[connection: Instance]>; /** * Called when a player joins or leaves the game @@ -185,7 +146,96 @@ export default class BaseInstancePlugin { * * @param event - Information about the event. */ - async onPlayerEvent(event: PlayerEvent) { } + readonly playerEvent: lib.AsyncHook<[event: lib.PlayerEvent]>; +} + +// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging +export interface BaseInstancePlugin { + onInstanceConfigFieldChanged?(field: string, curr: unknown, prev: unknown): Promise; + onMetrics?(): Promise>; + onStart?(): Promise; + onStop?(): Promise; + onExit?(): void; + onOutput?(parsed: lib.ParsedFactorioOutput, line: string): Promise; + onControllerConnectionEvent?(event: "connect" | "drop" | "resume" | "close"): void; + onPrepareControllerDisconnect?(connection: Instance): Promise; + onPlayerEvent?(event: lib.PlayerEvent): Promise; +} + +/** + * Base class for instance plugins + * + * Instance plugins are subclasses of this class which get instantiated by + * the host when it brings up an instance with the plugin enabled in the + * config. To be discovered the class must be exported under the name + * `InstancePlugin` in the module specified by the `instanceEntrypoint` in + * the plugin's `plugin` export. + * + * Instances may be started and stopped many times, and many instances may + * be running at the same time, each of which will have their own instance + * of the InstancePlugin class. + */ +// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging +export class BaseInstancePlugin { + private _pendingRconMessages: { + resolve: (result: string) => void, + reject: (err: Error) => void, + message: string, + expectEmpty: boolean, + }[] = []; + + private _sendingRconMessages = false; + + constructor( + /** + * The plugin's own info module + */ + public info: lib.PluginNodeEnvInfo, + /** + * Instance the plugin started for + */ + public instance: Instance, + /** + * Host running the instance + * + * With the exepction of accessing the host's config you should + * avoid ineracting with the host object directly. + */ + public host: Host, + public logger: lib.Logger, + ) { + const attach = ( + hook: lib.AsyncHook, + fn?: lib.HookHandler, + ) => { + if (fn) { + hook.attach(info.name, fn.bind(this)); + } + }; + + attach(instance.hooks.instanceConfigFieldChanged, this.onInstanceConfigFieldChanged); + attach(instance.hooks.metrics, this.onMetrics); + attach(instance.hooks.start, this.onStart); + attach(instance.hooks.stop, this.onStop); + attach(instance.hooks.exit, this.onExit); + attach(instance.hooks.output, this.onOutput); + attach(instance.hooks.controllerConnectionEvent, this.onControllerConnectionEvent); + attach(instance.hooks.prepareControllerDisconnect, this.onPrepareControllerDisconnect); + attach(instance.hooks.playerEvent, this.onPlayerEvent); + } + + static fromContext(context: InstancePluginContext) { + return new this(context.plugin, context.instance, context.host, context.logger); + } + + detachHooks() { + this.instance.hooks.detachAll(this.info.name); + } + + /** + * Called immediately after the class is instantiated + */ + async init() {} /** * Send RCON message to instance diff --git a/packages/host/src/Host.ts b/packages/host/src/Host.ts index c97ca6fc..a586a982 100644 --- a/packages/host/src/Host.ts +++ b/packages/host/src/Host.ts @@ -1,22 +1,20 @@ import fs from "node:fs/promises"; import path from "path"; -import events from "events"; import pidusage from "pidusage"; import semver from "semver"; import setBlocking from "set-blocking"; import stream from "stream"; +import { fileURLToPath } from "node:url"; import util from "util"; // internal libraries import * as lib from "@clusterio/lib"; import { logger } from "@clusterio/lib"; -import type { HostConnector } from "../host"; -import Instance from "./Instance"; -import InstanceConnection from "./InstanceConnection"; -import BaseHostPlugin from "./BaseHostPlugin"; - -const finished = util.promisify(stream.finished); +import type { HostConnector } from "../host.js"; +import Instance from "./Instance.js"; +import InstanceConnection from "./InstanceConnection.js"; +import { BaseHostPlugin, HostHooks } from "./BaseHostPlugin.js"; function checkRequestSaveName(name: string) { @@ -272,8 +270,10 @@ export default class Host extends lib.Link { pluginInfos: lib.PluginNodeEnvInfo[]; config: lib.HostConfig; - /** Mapping of plugin name to loaded plugin */ - plugins: Map = new Map(); + /** Hooks which plugins can attach to */ + hooks = new HostHooks(logger); + /** Plugins which are currently loaded */ + loadedPlugins: Set = new Set(); /** A map from instance id to instance connection. Only present when instance is running. */ instanceConnections = new Map(); @@ -332,7 +332,7 @@ export default class Host extends lib.Link { if (name === "host.name" || name === "host.public_address") { this.sendHostUpdate(); } - lib.invokeHook(this.plugins, "onHostConfigFieldChanged", name, curr, prev); + this.hooks.hostConfigFieldChanged.invoke(name, curr, prev); }); this.connector.on("hello", data => { @@ -384,9 +384,7 @@ export default class Host extends lib.Link { for (let instanceConnection of this.instanceConnections.values()) { instanceConnection.send(message); } - for (let plugin of this.plugins.values()) { - plugin.onControllerConnectionEvent(event); - } + this.hooks.controllerConnectionEvent.invoke(event); }); } @@ -403,6 +401,7 @@ export default class Host extends lib.Link { 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.snoopEvent(lib.InstanceAdminlistUpdateEvent, this.handleAdminlistUpdateEvent.bind(this)); this.snoopEvent(lib.InstanceBanlistUpdateEvent, this.handleBanlistUpdateEvent.bind(this)); @@ -423,6 +422,7 @@ export default class Host extends lib.Link { } async loadPlugins() { + const context = { logger, host: this }; for (let pluginInfo of this.pluginInfos) { if ( !pluginInfo.hostEntrypoint && !pluginInfo.instanceEntrypoint @@ -436,20 +436,16 @@ export default class Host extends lib.Link { continue; } - let HostPluginClass = BaseHostPlugin; try { - if (pluginInfo.hostEntrypoint) { - HostPluginClass = await lib.loadPluginClass( - pluginInfo.name, - path.posix.join(pluginInfo.requirePath, pluginInfo.hostEntrypoint), - "HostPlugin", - BaseHostPlugin, - ); - } + await lib.loadPlugin( + pluginInfo, + "host", + context, + "HostPlugin", + BaseHostPlugin, + ); - let hostPlugin = new HostPluginClass(pluginInfo, this, logger); - await hostPlugin.init(); - this.plugins.set(pluginInfo.name, hostPlugin); + this.loadedPlugins.add(pluginInfo); } catch (err: any) { if (err.code === "InstallationError") { @@ -859,7 +855,7 @@ export default class Host extends lib.Link { try { // First check the clusterio version const runningVersion = this.config.get("host.version"); - const packageJsonPath = require.resolve("@clusterio/host/package.json"); + const packageJsonPath = fileURLToPath(import.meta.resolve("@clusterio/host/package.json")); const packageJson = JSON.parse(await fs.readFile(packageJsonPath, "utf8")); if (runningVersion !== packageJson.version) { this.config.restartRequired = true; @@ -876,15 +872,14 @@ export default class Host extends lib.Link { } 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; @@ -893,7 +888,7 @@ export default class Host extends lib.Link { async checkRestartDowngrade() { try { const runningVersion = this.config.get("host.version"); - const packageJsonPath = require.resolve("@clusterio/host/package.json"); + const packageJsonPath = fileURLToPath(import.meta.resolve("@clusterio/host/package.json")); const packageJson = JSON.parse(await fs.readFile(packageJsonPath, "utf8")); const installedVersion = packageJson.version; @@ -922,7 +917,7 @@ export default class Host extends lib.Link { } let results = []; - let pluginResults = await lib.invokeHook(this.plugins, "onMetrics"); + let pluginResults = await this.hooks.metrics.collect(); for (let metricIterator of pluginResults) { for await (let metric of metricIterator) { results.push(metric); @@ -1151,10 +1146,20 @@ export default class Host extends lib.Link { return await lib.handlePluginInstall(request.pluginPackage); } + async handleUpdateAllRequest(request: lib.UpdateAllRequest) { + if (!this.config.get("host.allow_remote_updates")) { + throw new lib.RequestError("Remote updates are disabled on this machine"); + } + if (!this.config.get("host.allow_plugin_updates")) { + throw new lib.RequestError("Plugin updates are disabled on this machine"); + } + return await lib.handleUpdateAll("@clusterio/host", this.pluginInfos); + } + async handlePluginListRequest(request: lib.PluginListRequest) { return this.pluginInfos.map(pluginInfo => lib.PluginDetails.fromNodeEnvInfo( pluginInfo, - this.plugins.has(pluginInfo.name), + this.loadedPlugins.has(pluginInfo), this.config.get(`${pluginInfo.name}.load_plugin`), )); } @@ -1215,7 +1220,7 @@ export default class Host extends lib.Link { } async prepareDisconnect() { - await lib.invokeHook(this.plugins, "onPrepareControllerDisconnect", this); + await this.hooks.prepareControllerDisconnect.invoke(this); for (let instanceConnection of this.instanceConnections.values()) { await instanceConnection.send(new lib.PrepareControllerDisconnectRequest()); } @@ -1232,7 +1237,7 @@ export default class Host extends lib.Link { } this._shuttingDown = true; - await lib.invokeHook(this.plugins, "onShutdown"); + await this.hooks.shutdown.invoke(); for (let instanceConnection of this.instanceConnections.values()) { try { diff --git a/packages/host/src/Instance.ts b/packages/host/src/Instance.ts index 0a658048..7e8e68db 100644 --- a/packages/host/src/Instance.ts +++ b/packages/host/src/Instance.ts @@ -9,11 +9,11 @@ const execAsync = util.promisify(exec); // internal libraries import * as lib from "@clusterio/lib"; -import { FactorioServer } from "./server"; -import { loadModules, patch } from "./patch"; -import { exportData } from "./export"; -import type Host from "./Host"; -import BaseInstancePlugin from "./BaseInstancePlugin"; +import { FactorioServer } from "./server.js"; +import { loadModules, patch } from "./patch.js"; +import { exportData } from "./export.js"; +import type Host from "./Host.js"; +import { BaseInstancePlugin, InstanceHooks } from "./BaseInstancePlugin.js"; const scriptCommands = [ "/cheat", "/editor", @@ -140,7 +140,6 @@ export default class Instance extends lib.Link { * ID of this instance, equivalenet to `instance.config.get("instance.id")`. */ readonly id: number; - plugins: Map; config: lib.InstanceConfig; logger: lib.Logger; server: FactorioServer; @@ -156,6 +155,10 @@ export default class Instance extends lib.Link { * Players currently online on the instance. */ playersOnline: Set = new Set(); + /** Hooks which plugins can attach to */ + hooks: InstanceHooks; + /** Plugins which are currently loaded */ + loadedPlugins: Set = new Set(); _host: Host; @@ -179,7 +182,6 @@ export default class Instance extends lib.Link { this._host = host; this._dir = dir; - this.plugins = new Map(); this.config = instanceConfig; this.id = this.config.get("instance.id"); @@ -189,8 +191,10 @@ export default class Instance extends lib.Link { instance_name: this.name, }); + this.hooks = new InstanceHooks(this.logger); + this._configFieldChanged = (field: string, curr: unknown, prev: unknown) => { - let hook = () => lib.invokeHook(this.plugins, "onInstanceConfigFieldChanged", field, curr, prev); + let hook = () => this.hooks.instanceConfigFieldChanged.invoke(field, curr, prev); if (field === "factorio.shutdown_timeout") { this.server.shutdownTimeoutMs = curr as number * 1000; @@ -231,7 +235,7 @@ export default class Instance extends lib.Link { this.server.on("output", (parsed, line) => { this.logger.log("server", { message: line, instance_id: this.id, parsed }); - lib.invokeHook(this.plugins, "onOutput", parsed, line); + this.hooks.output.invoke(parsed, line); }); this.server.on("error", err => { @@ -398,7 +402,7 @@ export default class Instance extends lib.Link { stats, }; this.sendTo("controller", new lib.InstancePlayerUpdateEvent("join", name, stats)); - lib.invokeHook(this.plugins, "onPlayerEvent", event); + this.hooks.playerEvent.invoke(event); } _recordPlayerLeave(name: string, reason: string) { @@ -419,7 +423,7 @@ export default class Instance extends lib.Link { stats, }; this.sendTo("controller", new lib.InstancePlayerUpdateEvent("leave", name, stats, reason)); - lib.invokeHook(this.plugins, "onPlayerEvent", event); + this.hooks.playerEvent.invoke(event); } async handleInstanceExtractPlayersRequest() { @@ -450,7 +454,7 @@ end`.replace(/\r?\n/g, " "); stats, }; this.sendTo("controller", new lib.InstancePlayerUpdateEvent("import", name, stats)); - lib.invokeHook(this.plugins, "onPlayerEvent", event); + this.hooks.playerEvent.invoke(event); count += 1; } this.logger.info(`Extracted data for ${count} player(s)`); @@ -588,9 +592,7 @@ end`.replace(/\r?\n/g, " "); } // Notify plugins of exit - for (let pluginInstance of this.plugins.values()) { - pluginInstance.onExit(); - } + this.hooks.exit.invoke(); for (let player of this.playersOnline) { this._recordPlayerLeave(player, "server_quit"); @@ -598,21 +600,6 @@ end`.replace(/\r?\n/g, " "); this._saveStats().catch(err => this.logger.error(`Error saving stats:\n${err.stack}`)); } - async _loadPlugin(pluginInfo: lib.PluginNodeEnvInfo, host: Host) { - let pluginLoadStartedMs = Date.now(); - let InstancePluginClass = await lib.loadPluginClass( - pluginInfo.name, - path.posix.join(pluginInfo.requirePath, pluginInfo.instanceEntrypoint!), - "InstancePlugin", - BaseInstancePlugin, - ); - let instancePlugin = new InstancePluginClass(pluginInfo, this, host); - this.plugins.set(pluginInfo.name, instancePlugin); - await instancePlugin.init(); - - this.logger.info(`Loaded plugin ${pluginInfo.name} in ${Date.now() - pluginLoadStartedMs}ms`); - } - async _loadStats() { let instanceStats; try { @@ -667,6 +654,7 @@ end`.replace(/\r?\n/g, " "); } // load plugins + const plugins: Record = {}; for (let pluginInfo of pluginInfos) { if ( !pluginInfo.instanceEntrypoint @@ -682,7 +670,18 @@ end`.replace(/\r?\n/g, " "); } try { - await this._loadPlugin(pluginInfo, this._host); + let pluginLoadStartedMs = Date.now(); + await lib.loadPlugin( + pluginInfo, + "instance", + { logger: this.logger, host: this._host, instance: this }, + "InstancePlugin", + BaseInstancePlugin, + ); + + this.loadedPlugins.add(pluginInfo); + plugins[pluginInfo.name] = pluginInfo.version; + this.logger.info(`Loaded plugin ${pluginInfo.name} in ${Date.now() - pluginLoadStartedMs}ms`); } catch (err) { this.notifyExit(); await this.sendSaveListUpdate(); @@ -690,10 +689,6 @@ end`.replace(/\r?\n/g, " "); } } - let plugins: Record = {}; - for (let [name, plugin] of this.plugins) { - plugins[name] = plugin.info.version; - } this.send(new lib.InstanceInitialisedEvent(plugins)); } @@ -796,6 +791,7 @@ end`.replace(/\r?\n/g, " "); } else { modPack = await this.sendTo("controller", new lib.ModPackGetRequest(modPackId)); } + this.checkModPackVersion(modPack); this.activeModPack = modPack; const mods = await this._host.fetchMods(modPack.mods.values(), modPack.factorioVersion); @@ -847,6 +843,25 @@ end`.replace(/\r?\n/g, " "); await fs.writeFile(this.path("mods", "mod-settings.dat"), this.activeModPack.toModSettingsDat()); } + /** + * Check that the mod pack does not target a newer Factorio than the server + * + * The mod pack's Factorio version is written into mod-settings.dat and + * Factorio refuses to read it if it is newer than the game. It then + * loads default settings instead, which can corrupt the save. + * + * @throws {lib.RequestError} if the mod pack version is higher than the server version. + */ + checkModPackVersion(modPack: lib.ModPack) { + const serverVersion = this.server.version; + if (serverVersion && modPack.integerFactorioVersion > lib.integerFullVersion(serverVersion)) { + throw new lib.RequestError( + `Mod pack ${modPack.name} is for Factorio ${modPack.factorioVersion} ` + + `which is newer than the Factorio ${serverVersion} this instance runs` + ); + } + } + /** * Prepare instance for starting * @@ -951,7 +966,7 @@ end`.replace(/\r?\n/g, " "); // Patch save with lua modules from plugins this.logger.verbose("Patching save"); - let modules = await loadModules([...this.plugins.values()].map(plugin => plugin.info)); + let modules = await loadModules(this.loadedPlugins); await patch(this.path("saves", saveName), [...modules.values()]); return saveName; } @@ -986,7 +1001,7 @@ end`.replace(/\r?\n/g, " "); } await this.sendSaveListUpdate(); - await lib.invokeHook(this.plugins, "onStart"); + await this.hooks.start.invoke(); this.notifyStatus("running"); } @@ -1011,7 +1026,7 @@ end`.replace(/\r?\n/g, " "); await this.server.startScenario(scenario, seed, mapGenSettings, mapSettings); this._watchServerLogActions(); - await lib.invokeHook(this.plugins, "onStart"); + await this.hooks.start.invoke(); this.notifyStatus("running"); } @@ -1147,7 +1162,7 @@ end`.replace(/\r?\n/g, " "); // XXX this needs more thought to it if (this.server._state === "running") { - await lib.invokeHook(this.plugins, "onStop"); + await this.hooks.stop.invoke(); await this.server.stop(); await this.sendSaveListUpdate(); } else if ( @@ -1166,17 +1181,17 @@ end`.replace(/\r?\n/g, " "); } async handleControllerConnectionEvent(event: lib.ControllerConnectionEvent) { - await lib.invokeHook(this.plugins, "onControllerConnectionEvent", event.event); + await this.hooks.controllerConnectionEvent.invoke(event.event); } async handlePrepareControllerDisconnectRequest() { - await lib.invokeHook(this.plugins, "onPrepareControllerDisconnect", this); + await this.hooks.prepareControllerDisconnect.invoke(this); } async handleInstanceMetricsRequest() { let results: ReturnType[] = []; if (!["stopped", "stopping"].includes(this._status)) { - let pluginResults = await lib.invokeHook(this.plugins, "onMetrics"); + let pluginResults = await this.hooks.metrics.collect(); for (let metricIterator of pluginResults) { for await (let metric of metricIterator) { results.push(lib.serializeResult(metric)); diff --git a/packages/host/src/InstanceConnection.ts b/packages/host/src/InstanceConnection.ts index e9f01053..5ae602be 100644 --- a/packages/host/src/InstanceConnection.ts +++ b/packages/host/src/InstanceConnection.ts @@ -1,6 +1,6 @@ import * as lib from "@clusterio/lib"; -import type Host from "./Host"; -import type Instance from "./Instance"; +import type Host from "./Host.js"; +import type Instance from "./Instance.js"; export default class InstanceConnection extends lib.Link { diff --git a/packages/host/src/create_scenario.ts b/packages/host/src/create_scenario.ts index 4aff341c..6a49d168 100644 --- a/packages/host/src/create_scenario.ts +++ b/packages/host/src/create_scenario.ts @@ -7,24 +7,35 @@ import path from "path"; import * as lib from "@clusterio/lib"; import { logger } from "@clusterio/lib"; -import { loadModules, patch, SaveModule } from "./patch"; -import { findVersion } from "./server"; +import { loadModules, patch, SaveModule } from "./patch.js"; +import { findVersion } from "./server.js"; /** * Create a scenario patched with the given modules * * Runs the save patching on the scenario at scenarioPath and writes the - * result to the directory at outputPath. The scenario can be a directory, - * a zipped scenario or a save. + * result to outputPath. The scenario can be a directory, a zipped + * scenario or a save. * * @param scenarioPath - Path to the scenario to patch. - * @param outputPath - Directory to write the patched scenario to. + * @param outputPath - Path to write the patched scenario to. * @param modules - Description of the modules to patch. + * @param zip - Write a zip file instead of a directory. */ -export async function createScenario(scenarioPath: string, outputPath: string, modules: SaveModule[]) { - if (await fs.access(outputPath).then(() => true, () => false)) { +export async function createScenario( + scenarioPath: string, + outputPath: string, + modules: SaveModule[], + zip = false, +) { + try { + await fs.stat(outputPath); throw new Error(`${outputPath} already exists`); + } catch (err: any) { + if (err.code !== "ENOENT") { + throw err; + } } const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clusterio-scenario-")); @@ -37,7 +48,12 @@ export async function createScenario(scenarioPath: string, outputPath: string, m } await patch(tempPath, modules); - await extractZip(tempPath, outputPath); + if (zip) { + await fs.mkdir(path.dirname(outputPath), { recursive: true }); + await fs.copyFile(tempPath, outputPath); + } else { + await extractZip(tempPath, outputPath); + } } finally { await fs.rm(tempDir, { recursive: true, force: true }); } @@ -83,12 +99,18 @@ async function extractZip(zipPath: string, dirPath: string) { export function createScenarioCommand(yargs: any) { yargs .positional("output", { - describe: "Directory to write the scenario to", + describe: "Path to write the scenario to", type: "string", }) + .option("zip", { + type: "boolean", nargs: 0, default: false, + describe: "Write the scenario as a zip file instead of a directory", + }) .option("scenario", { nargs: 1, - describe: "Scenario directory, zipped scenario or save to patch, defaults to freeplay", + describe: + "Path to a scenario directory, zipped scenario or save to patch. " + + "Defaults to the freeplay scenario of the Factorio install in host.factorio_directory", type: "string", }) .option("factorio-version", { @@ -144,6 +166,6 @@ export async function handleCreateScenarioCommand( const modules = await loadModules(selected); logger.info(`Patching ${scenarioPath} with modules ${[...modules.keys()].join(", ")}`); - await createScenario(scenarioPath, args.output as string, [...modules.values()]); + await createScenario(scenarioPath, args.output as string, [...modules.values()], Boolean(args.zip)); logger.info(`Created scenario ${args.output}`); } diff --git a/packages/host/src/export.ts b/packages/host/src/export.ts index 198ffb0a..e8986af2 100644 --- a/packages/host/src/export.ts +++ b/packages/host/src/export.ts @@ -5,9 +5,9 @@ import { Jimp, JimpMime, type JimpInstance } from "jimp"; import JSZip from "jszip"; import * as lib from "@clusterio/lib"; -import * as libBuildMod from "@clusterio/lib/dist/node/build_mod"; -import type { FactorioServer } from "./server"; -import { definesPrototypesFallback } from "./export_fallback"; +import * as libBuildMod from "@clusterio/lib/dist/node/build_mod.js"; +import type { FactorioServer } from "./server.js"; +import { definesPrototypesFallback } from "./export_fallback.js"; interface SimpleIconSpecification { icons?: never; @@ -56,7 +56,7 @@ async function generateExportMod(server: FactorioServer) { clean: false, build: true, pack: true, - sourceDir: path.join(__dirname, "..", "..", "..", "lua", "export"), + sourceDir: path.join(import.meta.dirname, "..", "..", "..", "lua", "export"), outputDir: server.writePath("mods"), bumpPatch: false, factorioVersion: server.version?.replace(/\.\d+$/, ""), diff --git a/packages/host/src/patch.ts b/packages/host/src/patch.ts index 031d1565..f4ed658c 100644 --- a/packages/host/src/patch.ts +++ b/packages/host/src/patch.ts @@ -9,9 +9,6 @@ import { Type, Static } from "@sinclair/typebox"; import * as lib from "@clusterio/lib"; -import BaseInstancePlugin from "./BaseInstancePlugin"; - - /** * Describes a module that can be patched into a save */ @@ -90,13 +87,8 @@ export class SaveModule { return module; } - static async fromPlugin(plugin: BaseInstancePlugin) { - return SaveModule.fromPluginInfo(plugin.info); - } - - static async fromPluginInfo(info: lib.PluginNodeEnvInfo) { - let pluginPackagePath = require.resolve(path.posix.join(info.requirePath, "package.json")); - let moduleDirectory = path.join(path.dirname(pluginPackagePath), "module"); + static async fromPluginInfo(pluginInfo: lib.PluginNodeEnvInfo) { + let moduleDirectory = path.join(path.dirname(pluginInfo.packagePath), "module"); try { await fs.access(moduleDirectory); } catch (err: any) { @@ -107,17 +99,17 @@ export class SaveModule { let moduleJson; try { moduleJson = { - name: info.name, - version: info.version, + name: pluginInfo.name, + version: pluginInfo.version, dependencies: { "clusterio": "*" }, ...JSON.parse(await fs.readFile(moduleJsonPath, "utf8")), }; } catch (err: any) { - throw new Error(`Loading module/module.json in plugin ${info.name} failed: ${err.message}`); + throw new Error(`Loading module/module.json in plugin ${pluginInfo.name} failed: ${err.message}`); } if (!lib.ModuleInfo.validate(moduleJson)) { throw new Error( - `module/module.json in plugin ${info.name} failed validation:\n` + + `module/module.json in plugin ${pluginInfo.name} failed validation:\n` + `${JSON.stringify(lib.ModuleInfo.validate.errors, null, "\t")}` ); } @@ -174,7 +166,7 @@ export async function loadModules(pluginInfos: Iterable) // Find stand alone modules to load // XXX for now only the included clusterio module is loaded - let modulesDirectory = path.join(__dirname, "..", "..", "..", "modules"); + let modulesDirectory = path.join(import.meta.dirname, "..", "..", "..", "modules"); for (let entry of await fs.readdir(modulesDirectory, { withFileTypes: true })) { if (entry.isDirectory()) { if (modules.has(entry.name)) { diff --git a/packages/lib/browser.ts b/packages/lib/browser.ts index 1d7bc9c9..153a0370 100644 --- a/packages/lib/browser.ts +++ b/packages/lib/browser.ts @@ -1,4 +1,5 @@ export * from "./src/api"; +export * from "./src/AsyncHook"; export * from "./src/config"; export * from "./src/data"; export * from "./src/errors"; @@ -10,14 +11,14 @@ export * from "./src/link"; export * from "./src/lua_tools"; export * from "./src/logging"; export * from "./src/permissions"; -export * from "./src/plugin"; +export * from "./src/loadPlugin"; export * from "./src/schema"; export * from "./src/subscriptions"; export { default as ExponentialBackoff } from "./src/ExponentialBackoff"; export { default as RateLimiter } from "./src/RateLimiter"; export { default as ValueCache } from "./src/ValueCache"; -export { default as isDeepStrictEqual } from "./src/is_deep_strict_equal"; +export { default as isDeepStrictEqual } from "#is_deep_strict_equal"; import { checkSingletonImport } from "./src/check_singleton_import"; checkSingletonImport(import.meta.url); diff --git a/packages/lib/build_mod.js b/packages/lib/build_mod.js index 1406eeda..d25ae4ba 100644 --- a/packages/lib/build_mod.js +++ b/packages/lib/build_mod.js @@ -1,11 +1,10 @@ /* eslint-disable no-console */ -"use strict"; -const fs = require("node:fs/promises"); -const JSZip = require("jszip"); -const path = require("path"); -const stream = require("stream"); -const util = require("util"); -const yargs = require("yargs"); +import fs from "node:fs/promises"; +import JSZip from "jszip"; +import path from "node:path"; +import stream from "node:stream"; +import util from "node:util"; +import yargs from "yargs"; const finished = util.promisify(stream.finished); @@ -79,7 +78,7 @@ async function buildMod(args, info, modName) { } } -async function build(args) { +export async function build(args) { let info = JSON.parse(await fs.readFile(path.join(args.sourceDir, "info.json"))); if (args.factorioVersion) { @@ -121,7 +120,7 @@ async function build(args) { async function main() { - const args = yargs + const args = yargs(process.argv.slice(2)) .scriptName("build") .options({ "clean": { describe: "Remove previous builds", type: "boolean", default: false }, @@ -144,16 +143,12 @@ async function main() { }, }) .strict() - .argv + .parse() ; await build(args); } -if (module === require.main) { +if (import.meta.main) { main().catch(err => { console.log(err); }); } - -module.exports = { - build, -}; diff --git a/packages/lib/index.ts b/packages/lib/index.ts index 48ed7462..c6ffa92d 100644 --- a/packages/lib/index.ts +++ b/packages/lib/index.ts @@ -2,44 +2,46 @@ * Shared library for Clusterio * @module lib */ -export * from "./build_mod"; -export * from "./src/api"; -export * from "./src/command"; -export * from "./src/config"; -export * from "./src/data"; -export * from "./src/database"; -export * from "./src/errors"; -export * from "./src/export"; -export * from "./src/external"; -export * from "./src/factorio"; -export * from "./src/file_ops"; -export * from "./src/hash"; -export * from "./src/helpers"; -export * from "./src/ini"; -export * from "./src/link"; -export * from "./src/logging"; -export * from "./src/logging_utils"; -export * from "./src/lua_tools"; -export * from "./src/permissions"; -export * from "./src/plugin"; -export * from "./src/plugin_loader"; -export * from "./src/prometheus"; -export * from "./src/schema"; -export * from "./src/shared_commands"; -export * from "./src/stream"; -export * from "./src/system_collectors"; -export * from "./src/zip_ops"; -export * from "./src/subscriptions"; -export * from "./src/datastore"; -export * from "./src/load_plugin_list"; -export * from "./src/rce_ops"; -export * from "./src/LockFile"; +export * from "./build_mod.js"; +export * from "./src/api.js"; +export * from "./src/command.js"; +export * from "./src/config/index.js"; +export * from "./src/data/index.js"; +export * from "./src/database.js"; +export * from "./src/errors.js"; +export * from "./src/export.js"; +export * from "./src/external/index.js"; +export * from "./src/factorio/index.js"; +export * from "./src/file_ops.js"; +export * from "./src/hash.js"; +export * from "./src/helpers.js"; +export * from "./src/ini.js"; +export * from "./src/link/index.js"; +export * from "./src/logging.js"; +export * from "./src/logging_utils.js"; +export * from "./src/lua_tools.js"; +export * from "./src/permissions.js"; +export * from "./src/plugin.js"; +export * from "./src/plugin_loader.js"; +export * from "./src/loadPlugin.js"; +export * from "./src/prometheus.js"; +export * from "./src/schema.js"; +export * from "./src/shared_commands.js"; +export * from "./src/stream.js"; +export * from "./src/system_collectors.js"; +export * from "./src/zip_ops.js"; +export * from "./src/subscriptions.js"; +export * from "./src/datastore.js"; +export * from "./src/load_plugin_list.js"; +export * from "./src/rce_ops.js"; +export * from "./src/LockFile.js"; +export * from "./src/AsyncHook.js"; -export { default as ExponentialBackoff } from "./src/ExponentialBackoff"; -export { default as ModStore } from "./src/ModStore"; -export { default as RateLimiter } from "./src/RateLimiter"; -export { default as ValueCache } from "./src/ValueCache"; -export { default as isDeepStrictEqual } from "./src/is_deep_strict_equal"; +export { default as ExponentialBackoff } from "./src/ExponentialBackoff.js"; +export { default as ModStore } from "./src/ModStore.js"; +export { default as RateLimiter } from "./src/RateLimiter.js"; +export { default as ValueCache } from "./src/ValueCache.js"; +export { default as isDeepStrictEqual } from "#is_deep_strict_equal"; -import { checkSingletonImport } from "./src/check_singleton_import"; -checkSingletonImport(__filename); +import { checkSingletonImport } from "./src/check_singleton_import.js"; +checkSingletonImport(import.meta.filename); diff --git a/packages/lib/package.json b/packages/lib/package.json index 4da29bf8..8cc7bcb7 100644 --- a/packages/lib/package.json +++ b/packages/lib/package.json @@ -22,6 +22,17 @@ "access": "public" }, + "type": "module", + "imports": { + "#WebSocket": { + "node": "./src/link/WebSocket.node.ts", + "import": "./src/link/WebSocket.browser.ts" + }, + "#is_deep_strict_equal": { + "node": "./src/is_deep_strict_equal.node.ts", + "import": "./src/is_deep_strict_equal.browser.ts" + } + }, "browser": "browser.ts", "main": "dist/node/index.js", "scripts": { diff --git a/packages/lib/scripts/compile_validator.js b/packages/lib/scripts/compile_validator.js index 47bcee1e..7c1d932d 100644 --- a/packages/lib/scripts/compile_validator.js +++ b/packages/lib/scripts/compile_validator.js @@ -1,15 +1,14 @@ -"use strict"; -const fs = require("node:fs"); -const path = require("path"); -const Ajv = require("ajv"); -const standaloneCode = require("ajv/dist/standalone").default; +import fs from "node:fs"; +import path from "node:path"; +import Ajv from "ajv"; +import standaloneCode from "ajv/dist/standalone/index.js"; /* eslint-disable node/no-sync */ // Provide an empty file for message_core to load -fs.writeFileSync(path.join(__dirname, "../dist/node/src/data/message_validate.js"), ""); +fs.writeFileSync(path.join(import.meta.dirname, "../dist/node/src/data/message_validate.js"), "export default {};"); -const { Message } = require("../dist/node/src/data/messages_core"); +const { Message } = await import("../dist/node/src/data/messages_core.js"); const ajv = new Ajv({ allowUnionTypes: true, @@ -18,13 +17,14 @@ const ajv = new Ajv({ verbose: true, code: { source: true, + esm: true, }, }); const validate = ajv.compile(Message.jsonSchema); let moduleCode = standaloneCode(ajv, validate); function write(output) { fs.writeFileSync( - path.join(__dirname, output), + path.join(import.meta.dirname, output), `// generated by scripts/compile_validator.js\n// @ts-nocheck\n/* eslint-disable */\n${moduleCode}` ); } diff --git a/packages/lib/src/AsyncHook.ts b/packages/lib/src/AsyncHook.ts new file mode 100644 index 00000000..a0b8274c --- /dev/null +++ b/packages/lib/src/AsyncHook.ts @@ -0,0 +1,184 @@ +import { logger } from "./logging.js"; +import * as libHelpers from "./helpers.js"; + +export type HookHandler = (...args: Args) => Result | Promise; + +export class AsyncHook< + Args extends unknown[], + Result = void, +> { + private static readonly _timeoutToken = Symbol("timeout"); + private _handlers = new Map>(); + + constructor( + private _logger = logger, + private _timeoutMs = 15_000, + ) { } + + get size() { + return this._handlers.size; + } + + get attached() { + return this._handlers.keys(); + } + + /** + * Stable event listener reference bound to this hook instance. + * + * This function is pre-bound and guaranteed to remain referentially stable + * for the lifetime of the hook instance. It can safely be passed directly + * into event emitters (e.g. EventEmitter.on / off) without losing context + * or requiring re-binding. + * + * Example: + * `eventEmitter.on("change", hook.listener);` + */ + readonly listener = this.invoke.bind(this); + + /** + * Attach a handler to this hook. + * + * An error is thrown if another handler with the same name is already attached. + */ + attach( + name: string, + handler: HookHandler, + ) { + if (this._handlers.has(name)) { + throw new Error(`Handler with name ${name} is already attached`); + } + this._handlers.set(name, handler); + } + + /** + * Remove a previously attached handler. + * + * If no handler exists, then no action is performed. + */ + detach(name: string) { + this._handlers.delete(name); + } + + /** + * Remove all attached handlers. + */ + clear() { + this._handlers.clear(); + } + + private async _invokeHandler( + name: string, + handler: HookHandler, + args: Args, + ): Promise { + try { + const result = await libHelpers.timeout< + Result | typeof AsyncHook._timeoutToken + >( + Promise.resolve(handler(...args)), + this._timeoutMs, + AsyncHook._timeoutToken, + ); + + if (result === AsyncHook._timeoutToken) { + throw new Error(`Hook ${name} timed out after ${this._timeoutMs}ms`); + } + + return result; + + } catch (err: any) { + this._logger.error(`Ignoring error in hook "${name}":\n${err?.stack ?? err}`); + return undefined; + } + } + + /** + * Invoke all handlers attached to this hook. + * + * Any return values are discarded. + */ + async invoke(...args: Args): Promise { + await Promise.all( + [...this._handlers.entries()].map( + ([name, handler]) => this._invokeHandler(name, handler, args) + ) + ); + } + + /** + * Invoke all handlers attached to this hook. + * + * Returns all successful results. + */ + async collect(...args: Args): Promise { + const results = await Promise.all( + [...this._handlers.entries()].map( + ([name, handler]) => this._invokeHandler(name, handler, args) + ) + ); + + return results.filter( + (result): result is Awaited => result !== undefined + ); + } + + /** + * Invoke all handlers attached to this hook. + * + * Returns all successful results and their source. + */ + async collectEntries(...args: Args): Promise<[string, Result][]> { + const results = await Promise.all( + [...this._handlers.entries()].map(async ([name, handler]) => { + const result = await this._invokeHandler(name, handler, args); + return result !== undefined ? [name, result] as [string, Result] : undefined; + }) + ); + + return results.filter( + (entry): entry is [string, Result] => entry !== undefined + ); + } +} + +export class AsyncHookCollection { + private hooks: AsyncHook[] = []; + + constructor( + private _logger = logger, + ) {} + + protected newHook< + Args extends unknown[], + Result = void, + > ( + _logger = this._logger + ) { + const hook = new AsyncHook(_logger); + this.hooks.push(hook); + return hook; + } + + get size() { + const attached = new Set(this.hooks.flatMap(hook => [...hook.attached])); + return attached.size; + } + + get attached() { + const attached = new Set(this.hooks.flatMap(hook => [...hook.attached])); + return attached.values(); + } + + detachAll(name: string) { + for (const hook of this.hooks) { + hook.detach(name); + } + } + + clearAll() { + for (const hook of this.hooks) { + hook.clear(); + } + } +} diff --git a/packages/lib/src/ModStore.ts b/packages/lib/src/ModStore.ts index 625ae253..999157f3 100644 --- a/packages/lib/src/ModStore.ts +++ b/packages/lib/src/ModStore.ts @@ -2,10 +2,10 @@ import fs from "node:fs/promises"; import path from "path"; import events from "events"; import { Static } from "@sinclair/typebox"; -import { logger } from "./logging"; -import { downloadFile, safeOutputFile } from "./file_ops"; -import { ModPortalReleaseSchema, ModPortalDetailsSchema, ModNameVersionPair } from "./data/messages_mod"; -import { ModInfo, ModPack, MajorMinorVersion, ModVersionEquality, SourceVersion } from "./data"; +import { logger } from "./logging.js"; +import { downloadFile, safeOutputFile } from "./file_ops.js"; +import { ModPortalReleaseSchema, ModPortalDetailsSchema, ModNameVersionPair } from "./data/messages_mod.js"; +import { ModInfo, ModPack, MajorMinorVersion, ModVersionEquality, SourceVersion } from "./data/index.js"; export interface ModStoreEvents { /** A stored mod was created, updated or deleted */ diff --git a/packages/lib/src/api.ts b/packages/lib/src/api.ts index 43289178..86a25393 100644 --- a/packages/lib/src/api.ts +++ b/packages/lib/src/api.ts @@ -4,7 +4,8 @@ export type PluginWebApi = { enabled: boolean; loaded: boolean; web: { - main: string; + /** Path to the web bundle, not present if the plugin has no web build. */ + main?: string; error?: string; }; /** diff --git a/packages/lib/src/check_singleton_import.ts b/packages/lib/src/check_singleton_import.ts index 6a734128..a238fbe8 100644 --- a/packages/lib/src/check_singleton_import.ts +++ b/packages/lib/src/check_singleton_import.ts @@ -1,4 +1,4 @@ -import { InstallationError } from "./errors"; +import { InstallationError } from "./errors.js"; declare global { // eslint-disable-next-line vars-on-top var _clusterioLibImportedFrom: string; diff --git a/packages/lib/src/command.ts b/packages/lib/src/command.ts index 2a55c22e..556a104e 100644 --- a/packages/lib/src/command.ts +++ b/packages/lib/src/command.ts @@ -6,9 +6,9 @@ * @author Hornwitser * @module lib/command */ -import * as libData from "./data"; -import * as libErrors from "./errors"; -import type { Link } from "./link"; +import * as libData from "./data/index.js"; +import * as libErrors from "./errors.js"; +import type { Link } from "./link/index.js"; import type { Argv } from "yargs"; export type CommandHandler = (args: any, control: any) => Promise; @@ -146,6 +146,22 @@ export class CommandTree { } +/** + * Resolve a string with either an id or a name to the id of an entry + * + * An integer like string is treated as an id if an entry with that id + * exists, otherwise it is looked up as a name. + */ +function resolveId(entries: { id: number, name: string }[], idOrName: string) { + if (/^-?\d+$/.test(idOrName)) { + const id = parseInt(idOrName, 10); + if (entries.some(entry => entry.id === id)) { + return id; + } + } + return entries.find(entry => entry.name === idOrName)?.id; +} + /** * Resolve a string into a host ID * @@ -157,23 +173,11 @@ export class CommandTree { * @returns host ID. */ export async function resolveHost(client: Link, hostName: string) { - let hostId: number | undefined; - if (/^-?\d+$/.test(hostName)) { - hostId = parseInt(hostName, 10); - } else { - let hosts = await client.sendTo("controller", new libData.HostListRequest()); - for (let host of hosts) { - if (host.name === hostName) { - hostId = host.id; - break; - } - } - - if (hostId === undefined) { - throw new libErrors.CommandError(`No host named ${hostName}`); - } + const hosts = await client.sendTo("controller", new libData.HostListRequest()); + const hostId = resolveId(hosts, hostName); + if (hostId === undefined) { + throw new libErrors.CommandError(`No host named ${hostName}`); } - return hostId; } @@ -188,23 +192,11 @@ export async function resolveHost(client: Link, hostName: string) { * @returns instance ID. */ export async function resolveInstance(client: Link, instanceName: string) { - let instanceId: number | undefined; - if (/^-?\d+$/.test(instanceName)) { - instanceId = parseInt(instanceName, 10); - } else { - let instances = await client.sendTo("controller", new libData.InstanceDetailsListRequest()); - for (let instance of instances) { - if (instance.name === instanceName) { - instanceId = instance.id; - break; - } - } - - if (instanceId === undefined) { - throw new libErrors.CommandError(`No instance named ${instanceName}`); - } + const instances = await client.sendTo("controller", new libData.InstanceDetailsListRequest()); + const instanceId = resolveId(instances, instanceName); + if (instanceId === undefined) { + throw new libErrors.CommandError(`No instance named ${instanceName}`); } - return instanceId; } @@ -220,23 +212,11 @@ export async function resolveInstance(client: Link, instanceName: string) { * @returns mod pack ID. */ export async function resolveModPack(client: Link, modPackName: string) { - let modPackId: number | undefined; - if (/^-?\d+/.test(modPackName)) { - modPackId = parseInt(modPackName, 10); - } else { - let modPacks = await client.sendTo("controller", new libData.ModPackListRequest()); - for (let modPack of modPacks) { - if (modPack.name === modPackName) { - modPackId = modPack.id; - break; - } - } - - if (modPackId === undefined) { - throw new libErrors.CommandError(`No mod pack named ${modPackName}`); - } + const modPacks = await client.sendTo("controller", new libData.ModPackListRequest()); + const modPackId = resolveId(modPacks, modPackName); + if (modPackId === undefined) { + throw new libErrors.CommandError(`No mod pack named ${modPackName}`); } - return modPackId; } @@ -251,27 +231,9 @@ export async function resolveModPack(client: Link, modPackName: string) { * @returns Role info. */ export async function retrieveRole(client: Link, roleName: string) { - let roles = await client.sendTo("controller", new libData.RoleListRequest()); - - let resolvedRole: libData.Role | undefined; - if (/^-?\d+$/.test(roleName)) { - let roleId = parseInt(roleName, 10); - for (let role of roles) { - if (role.id === roleId) { - resolvedRole = role; - break; - } - } - - } else { - for (let role of roles) { - if (role.name === roleName) { - resolvedRole = role; - break; - } - } - } - + const roles = await client.sendTo("controller", new libData.RoleListRequest()); + const roleId = resolveId(roles, roleName); + const resolvedRole = roles.find(role => role.id === roleId); if (!resolvedRole) { throw new libErrors.CommandError(`No role named ${roleName}`); } diff --git a/packages/lib/src/config/classes.ts b/packages/lib/src/config/classes.ts index d982f174..769079a0 100644 --- a/packages/lib/src/config/classes.ts +++ b/packages/lib/src/config/classes.ts @@ -3,13 +3,13 @@ import { Type, Static } from "@sinclair/typebox"; import * as fs from "node:fs/promises"; import events from "events"; -import isDeepStrictEqual from "../is_deep_strict_equal"; -import { basicType } from "../helpers"; -import * as libSchema from "../schema"; -import { StringEnum } from "../data/composites"; -import { safeOutputFile } from "../file_ops"; -import * as validators from "./validators"; -import { RequestError } from "../errors"; +import isDeepStrictEqual from "#is_deep_strict_equal"; +import { basicType } from "../helpers.js"; +import * as libSchema from "../schema.js"; +import { StringEnum } from "../data/composites.js"; +import { safeOutputFile } from "../file_ops.js"; +import * as validators from "./validators.js"; +import { RequestError } from "../errors.js"; const ConfigLocation = StringEnum(["controller", "host", "control"]); export type ConfigLocation = Static; @@ -468,7 +468,7 @@ export class Config< if (!this.filepath) { throw new Error("Cannot save config which has no filepath"); } - await safeOutputFile(this.filepath, JSON.stringify(this, null, "\t")); + await safeOutputFile(this.filepath, JSON.stringify(this, null, "\t"), { mode: 0o600 }); this.dirty = false; } diff --git a/packages/lib/src/config/definitions.ts b/packages/lib/src/config/definitions.ts index 8510c280..0f109141 100644 --- a/packages/lib/src/config/definitions.ts +++ b/packages/lib/src/config/definitions.ts @@ -1,9 +1,9 @@ // Core definitions for the configuration system -import * as classes from "./classes"; -import * as validators from "./validators"; -import type { PluginNodeEnvInfo, PluginWebpackEnvInfo } from "../plugin"; +import * as classes from "./classes.js"; +import * as validators from "./validators.js"; +import type { PluginNodeEnvInfo, PluginWebpackEnvInfo } from "../plugin.js"; import { Static } from "@sinclair/typebox"; -import { isTargetVersion } from "../data/version"; +import { isTargetVersion } from "../data/version.js"; type configFromJSON = (...args: Parameters) => T; type configFromFile = (...args: Parameters) => Promise; diff --git a/packages/lib/src/config/index.ts b/packages/lib/src/config/index.ts index a111da14..0614402f 100644 --- a/packages/lib/src/config/index.ts +++ b/packages/lib/src/config/index.ts @@ -3,5 +3,5 @@ * @author Hornwitser * @module lib/config */ -export * from "./classes"; -export * from "./definitions"; +export * from "./classes.js"; +export * from "./definitions.js"; diff --git a/packages/lib/src/data/InstanceDetails.ts b/packages/lib/src/data/InstanceDetails.ts index 04c1a686..adf0b9d8 100644 --- a/packages/lib/src/data/InstanceDetails.ts +++ b/packages/lib/src/data/InstanceDetails.ts @@ -1,6 +1,6 @@ import { Static, Type } from "@sinclair/typebox"; -import { StringEnum } from "./composites"; -import { TargetVersion, TargetVersionSchema } from "./version"; +import { StringEnum } from "./composites.js"; +import { TargetVersion, TargetVersionSchema } from "./version.js"; export const InstanceStatus = StringEnum([ "unknown", diff --git a/packages/lib/src/data/ModInfo.ts b/packages/lib/src/data/ModInfo.ts index 3205e2aa..dc702d91 100644 --- a/packages/lib/src/data/ModInfo.ts +++ b/packages/lib/src/data/ModInfo.ts @@ -2,16 +2,16 @@ import fs from "node:fs/promises"; import JSZip from "jszip"; import { Type, Static } from "@sinclair/typebox"; -import * as libHash from "../hash"; -import * as libSchema from "../schema"; -import { findRoot } from "../zip_ops"; -import { ModRecord } from "./ModPack"; +import * as libHash from "../hash.js"; +import * as libSchema from "../schema.js"; +import { findRoot } from "../zip_ops.js"; +import { ModRecord } from "./ModPack.js"; import { MajorMinorVersion, MajorMinorVersionSchema, normaliseMajorMinorVersion, integerMajorMinorVersion, SourceVersion, SourceVersionSchema, isSourceVersion, normaliseSourceVersion, integerSourceVersion, ModVersionEquality, -} from "./version"; +} from "./version.js"; type ModDependencyType = "incompatible" | "optional" | "hidden" | "unordered" | "required" | "recommended"; diff --git a/packages/lib/src/data/ModPack.ts b/packages/lib/src/data/ModPack.ts index 33251845..29458422 100644 --- a/packages/lib/src/data/ModPack.ts +++ b/packages/lib/src/data/ModPack.ts @@ -1,19 +1,19 @@ import zlib from "zlib"; import { Type, Static } from "@sinclair/typebox"; -import * as libSchema from "../schema"; -import * as libLuaTools from "../lua_tools"; +import * as libSchema from "../schema.js"; +import * as libLuaTools from "../lua_tools.js"; -import ExportManifest from "./ExportManifest"; -import type { ExportSettings } from "../export"; -import type { Logger } from "../logging"; +import ExportManifest from "./ExportManifest.js"; +import type { ExportSettings } from "../export.js"; +import type { Logger } from "../logging.js"; -import ModInfo, { ModDependencyUnsatisfiedReason } from "./ModInfo"; +import ModInfo, { ModDependencyUnsatisfiedReason } from "./ModInfo.js"; import { PartialVersion, PartialVersionSchema, integerPartialVersion, SourceVersion, SourceVersionSchema, normaliseFullVersion, integerSourceVersion, -} from "./version"; +} from "./version.js"; export const ModSettingColor = Type.Object({ diff --git a/packages/lib/src/data/ModuleInfo.ts b/packages/lib/src/data/ModuleInfo.ts index 968a456e..8c43fc0b 100644 --- a/packages/lib/src/data/ModuleInfo.ts +++ b/packages/lib/src/data/ModuleInfo.ts @@ -1,5 +1,5 @@ import { Type, Static } from "@sinclair/typebox"; -import { compile } from "../schema"; +import { compile } from "../schema.js"; /** * Information about a module stored in its module.json file diff --git a/packages/lib/src/data/Role.ts b/packages/lib/src/data/Role.ts index 19fb17e7..954d5def 100644 --- a/packages/lib/src/data/Role.ts +++ b/packages/lib/src/data/Role.ts @@ -1,5 +1,5 @@ import { Static, Type } from "@sinclair/typebox"; -import { permissions as globalPermissions } from "../permissions"; +import { permissions as globalPermissions } from "../permissions.js"; interface RoleCollection { get(id: number): Role; diff --git a/packages/lib/src/data/UserDetails.ts b/packages/lib/src/data/UserDetails.ts index 5a99dfed..ffdb2585 100644 --- a/packages/lib/src/data/UserDetails.ts +++ b/packages/lib/src/data/UserDetails.ts @@ -1,6 +1,6 @@ import { Static, Type } from "@sinclair/typebox"; - -import PlayerStats from "./PlayerStats"; +import type { PermissionName } from "../permissions.js"; +import PlayerStats from "./PlayerStats.js"; /** Represents a user in the cluster */ export default class UserDetails { @@ -164,5 +164,5 @@ export interface IUser extends UserDetails { * @throws {Error} If the given permission does not exist. * @throws {libErrors.PermissionError} if the user does noh have the given permission. */ - checkPermission(permission: string): void + checkPermission(permission: PermissionName): void } diff --git a/packages/lib/src/data/index.ts b/packages/lib/src/data/index.ts index a8ca8a96..ff473748 100644 --- a/packages/lib/src/data/index.ts +++ b/packages/lib/src/data/index.ts @@ -3,26 +3,26 @@ * @module lib/data * @author Hornwitser */ -export { default as ExportManifest } from "./ExportManifest"; -export { default as ModInfo, ModDependency } from "./ModInfo"; -export { ModSettingColor, getInstalledModUpdates, applyModRecordAdvisories } from "./ModPack"; -export type { ModSetting, ModRecord, ModRecordAdvisory } from "./ModPack"; -export { default as ModPack } from "./ModPack"; -export { default as ModuleInfo } from "./ModuleInfo"; -export { default as Permission } from "./Permission"; -export { default as PlayerStats } from "./PlayerStats"; -export { default as Role } from "./Role"; -export { default as InstanceDetails, InstanceStatus } from "./InstanceDetails"; -export { default as HostDetails } from "./HostDetails"; -export { default as UserDetails } from "./UserDetails"; -export type { IUser } from "./UserDetails"; -export * from "./composites"; -export * from "./messages_core"; -export * from "./messages_controller"; -export * from "./messages_host"; -export * from "./messages_instance"; -export * from "./messages_mod"; -export * from "./messages_user"; -export * from "./messages_role"; -export * from "./messages_plugin"; -export * from "./version"; +export { default as ExportManifest } from "./ExportManifest.js"; +export { default as ModInfo, ModDependency } from "./ModInfo.js"; +export { ModSettingColor, getInstalledModUpdates, applyModRecordAdvisories } from "./ModPack.js"; +export type { ModSetting, ModRecord, ModRecordAdvisory } from "./ModPack.js"; +export { default as ModPack } from "./ModPack.js"; +export { default as ModuleInfo } from "./ModuleInfo.js"; +export { default as Permission } from "./Permission.js"; +export { default as PlayerStats } from "./PlayerStats.js"; +export { default as Role } from "./Role.js"; +export { default as InstanceDetails, InstanceStatus } from "./InstanceDetails.js"; +export { default as HostDetails } from "./HostDetails.js"; +export { default as UserDetails } from "./UserDetails.js"; +export type { IUser } from "./UserDetails.js"; +export * from "./composites.js"; +export * from "./messages_core.js"; +export * from "./messages_controller.js"; +export * from "./messages_host.js"; +export * from "./messages_instance.js"; +export * from "./messages_mod.js"; +export * from "./messages_user.js"; +export * from "./messages_role.js"; +export * from "./messages_plugin.js"; +export * from "./version.js"; diff --git a/packages/lib/src/data/message_validate.d.ts b/packages/lib/src/data/message_validate.d.ts index d9ec5838..bbb3d8b3 100644 --- a/packages/lib/src/data/message_validate.d.ts +++ b/packages/lib/src/data/message_validate.d.ts @@ -1,4 +1,4 @@ import type { ValidateFunction } from "ajv"; -import type { Message } from "./messages_core"; +import type { Message } from "./messages_core.js"; const message_validate: ValidateFunction; export default message_validate; diff --git a/packages/lib/src/data/messages_controller.ts b/packages/lib/src/data/messages_controller.ts index 9075cfb9..25ca7789 100644 --- a/packages/lib/src/data/messages_controller.ts +++ b/packages/lib/src/data/messages_controller.ts @@ -1,8 +1,8 @@ import { Type, Static } from "@sinclair/typebox"; -import { JsonString, StringEnum, StringKey, plainJson } from "./composites"; -import { levels } from "../logging"; -import { ControllerConfig, HostConfig } from "../config"; -import { ExternalFactorioVersionSchema, LatestReleasesSchema } from "../external"; +import { JsonString, StringEnum, StringKey, plainJson } from "./composites.js"; +import { levels } from "../logging.js"; +import { ControllerConfig, HostConfig } from "../config/index.js"; +import { ExternalFactorioVersionSchema, LatestReleasesSchema } from "../external/index.js"; export class ControllerStopRequest { declare ["constructor"]: typeof ControllerStopRequest; diff --git a/packages/lib/src/data/messages_core.ts b/packages/lib/src/data/messages_core.ts index c4145a47..ff391b21 100644 --- a/packages/lib/src/data/messages_core.ts +++ b/packages/lib/src/data/messages_core.ts @@ -1,8 +1,8 @@ // Note: Run compile_validator script after making changes to this file. import { TSchema, Type, Static } from "@sinclair/typebox"; -import messageValidate from "./message_validate"; // generated file -import { StringEnum } from "./composites"; +import messageValidate from "./message_validate.js"; // generated file +import { StringEnum } from "./composites.js"; export type AddressType = "controller" | "host" | "instance" | "control" | "broadcast"; export type AddressShorthand = diff --git a/packages/lib/src/data/messages_host.ts b/packages/lib/src/data/messages_host.ts index e707d856..aa4edbc9 100644 --- a/packages/lib/src/data/messages_host.ts +++ b/packages/lib/src/data/messages_host.ts @@ -1,8 +1,8 @@ import { Type, Static } from "@sinclair/typebox"; -import { jsonArray, plainJson, StringEnum } from "./composites"; -import { CollectorResultSerialized } from "../prometheus"; -import { HostConfig } from "../config/definitions"; -import HostDetails from "./HostDetails"; +import { jsonArray, plainJson, StringEnum } from "./composites.js"; +import { CollectorResultSerialized } from "../prometheus.js"; +import { HostConfig } from "../config/definitions.js"; +import HostDetails from "./HostDetails.js"; export class HostStopRequest { declare ["constructor"]: typeof HostStopRequest; diff --git a/packages/lib/src/data/messages_instance.ts b/packages/lib/src/data/messages_instance.ts index 51bcd0db..d107632c 100644 --- a/packages/lib/src/data/messages_instance.ts +++ b/packages/lib/src/data/messages_instance.ts @@ -1,12 +1,12 @@ import { Type, Static } from "@sinclair/typebox"; -import PlayerStats from "./PlayerStats"; -import { JsonString, StringEnum, jsonArray, plainJson } from "./composites"; -import { InstanceConfig } from "../config"; -import type { IUser } from "./UserDetails"; -import type { MessageRequest } from "./messages_core"; -import { CollectorResultSerialized } from "../prometheus"; -import { TargetVersion, TargetVersionSchema, PartialVersion, PartialVersionSchema } from "./version"; -import InstanceDetails, { InstanceStatus } from "./InstanceDetails"; +import PlayerStats from "./PlayerStats.js"; +import { JsonString, StringEnum, jsonArray, plainJson } from "./composites.js"; +import { InstanceConfig } from "../config/index.js"; +import type { IUser } from "./UserDetails.js"; +import type { MessageRequest } from "./messages_core.js"; +import { CollectorResultSerialized } from "../prometheus.js"; +import { TargetVersion, TargetVersionSchema, PartialVersion, PartialVersionSchema } from "./version.js"; +import InstanceDetails, { InstanceStatus } from "./InstanceDetails.js"; export class InstanceDetailsGetRequest { declare ["constructor"]: typeof InstanceDetailsGetRequest; diff --git a/packages/lib/src/data/messages_mod.ts b/packages/lib/src/data/messages_mod.ts index 4d5a09e3..5d26fc78 100644 --- a/packages/lib/src/data/messages_mod.ts +++ b/packages/lib/src/data/messages_mod.ts @@ -1,13 +1,13 @@ import { Type, Static } from "@sinclair/typebox"; -import ModInfo, { ModDependency } from "./ModInfo"; -import ModPack from "./ModPack"; -import { JsonString, jsonArray } from "./composites"; +import ModInfo, { ModDependency } from "./ModInfo.js"; +import ModPack from "./ModPack.js"; +import { JsonString, jsonArray } from "./composites.js"; import { MajorMinorVersion, MajorMinorVersionSchema, normaliseMajorMinorVersion, SourceVersion, SourceVersionSchema, ModVersionEquality, -} from "./version"; +} from "./version.js"; export class ModPackGetRequest { diff --git a/packages/lib/src/data/messages_plugin.ts b/packages/lib/src/data/messages_plugin.ts index edbe7664..25eb0045 100644 --- a/packages/lib/src/data/messages_plugin.ts +++ b/packages/lib/src/data/messages_plugin.ts @@ -1,6 +1,8 @@ import { Type, Static } from "@sinclair/typebox"; -import { jsonArray } from "./composites"; -import { PluginNodeEnvInfo } from "../plugin"; +import { jsonArray } from "./composites.js"; +import type { PluginNodeEnvInfo } from "../plugin.js"; +import { Address, MessageRequest } from "./messages_core.js"; +import type { IUser } from "./UserDetails.js"; /* This is similar to other plugin definitions but with lots removed */ export class PluginDetails { @@ -91,3 +93,14 @@ export class PluginInstallRequest { return this.pluginPackage; } } + +export class UpdateAllRequest { + declare ["constructor"]: typeof UpdateAllRequest; + static type = "request" as const; + static src = "control" as const; + static dst = ["controller", "host"] as const; + static permission(user: IUser, message: MessageRequest) { + user.checkPermission(message.dst.type === Address.host ? "core.host.update" : "core.controller.update"); + user.checkPermission("core.plugin.update"); + } +} diff --git a/packages/lib/src/data/messages_role.ts b/packages/lib/src/data/messages_role.ts index 423c1d65..1a3986cb 100644 --- a/packages/lib/src/data/messages_role.ts +++ b/packages/lib/src/data/messages_role.ts @@ -1,7 +1,7 @@ import { Type, Static } from "@sinclair/typebox"; -import { JsonNumber, jsonArray } from "./composites"; -import Permission from "./Permission"; -import Role from "./Role"; +import { JsonNumber, jsonArray } from "./composites.js"; +import Permission from "./Permission.js"; +import Role from "./Role.js"; export class PermissionListRequest { declare ["constructor"]: typeof PermissionListRequest; diff --git a/packages/lib/src/data/messages_user.ts b/packages/lib/src/data/messages_user.ts index 2c73d8be..2de27c19 100644 --- a/packages/lib/src/data/messages_user.ts +++ b/packages/lib/src/data/messages_user.ts @@ -1,7 +1,7 @@ import { Type, Static } from "@sinclair/typebox"; -import UserDetails, { IUser } from "./UserDetails"; -import { StringEnum, jsonArray, plainJson } from "./composites"; -import { MessageRequest } from "./messages_core"; +import UserDetails, { IUser } from "./UserDetails.js"; +import { StringEnum, jsonArray, plainJson } from "./composites.js"; +import { MessageRequest } from "./messages_core.js"; export class UserGetRequest { declare ["constructor"]: typeof UserGetRequest; diff --git a/packages/lib/src/database.ts b/packages/lib/src/database.ts index 81ea71ac..1e94d298 100644 --- a/packages/lib/src/database.ts +++ b/packages/lib/src/database.ts @@ -6,8 +6,8 @@ */ import fs from "node:fs/promises"; -import * as libFileOps from "./file_ops"; -import { basicType } from "./helpers"; +import * as libFileOps from "./file_ops.js"; +import { basicType } from "./helpers.js"; export type ItemCountWithQuality = { [quality: string]: number }; diff --git a/packages/lib/src/datastore.ts b/packages/lib/src/datastore.ts index ed5113d9..23c294e0 100644 --- a/packages/lib/src/datastore.ts +++ b/packages/lib/src/datastore.ts @@ -2,10 +2,10 @@ import path from "path"; import fs from "node:fs/promises"; import { EventEmitter } from "events"; -import { safeOutputFile } from "./file_ops"; -import { SubscribableValue } from "./subscriptions"; -import { logger } from "./logging"; -import { ControllerConfig } from "./config"; +import { safeOutputFile } from "./file_ops.js"; +import { SubscribableValue } from "./subscriptions.js"; +import { logger } from "./logging.js"; +import { ControllerConfig } from "./config/index.js"; type DatastoreKey = string | number; type DatastoreValue = string | number | boolean | object; diff --git a/packages/lib/src/export.ts b/packages/lib/src/export.ts index 10f06089..2ae9eb3f 100644 --- a/packages/lib/src/export.ts +++ b/packages/lib/src/export.ts @@ -1,4 +1,4 @@ -import { FactorioColor } from "./lua_tools"; +import { FactorioColor } from "./lua_tools.js"; /** * A localised string. diff --git a/packages/lib/src/external/FactorioVersions.ts b/packages/lib/src/external/FactorioVersions.ts index 86360b39..ae1f2e39 100644 --- a/packages/lib/src/external/FactorioVersions.ts +++ b/packages/lib/src/external/FactorioVersions.ts @@ -1,5 +1,5 @@ import { Static, Type } from "@sinclair/typebox"; -import { FullVersionSchema, integerFullVersion, isFullVersion } from "../data/version"; +import { FullVersionSchema, integerFullVersion, isFullVersion } from "../data/version.js"; const ARCHIVE_URL = "https://factorio.com/download/archive/"; diff --git a/packages/lib/src/external/LatestReleases.ts b/packages/lib/src/external/LatestReleases.ts index 91a0f322..885565ae 100644 --- a/packages/lib/src/external/LatestReleases.ts +++ b/packages/lib/src/external/LatestReleases.ts @@ -1,5 +1,5 @@ import { Static, Type } from "@sinclair/typebox"; -import { FullVersion, FullVersionSchema } from "../data/version"; +import { FullVersion, FullVersionSchema } from "../data/version.js"; const LATEST_RELEASES_URL = "https://factorio.com/api/latest-releases"; diff --git a/packages/lib/src/external/index.ts b/packages/lib/src/external/index.ts index 0bd258c4..0bf27ace 100644 --- a/packages/lib/src/external/index.ts +++ b/packages/lib/src/external/index.ts @@ -2,5 +2,5 @@ * Library for interfacing with external apis * @module lib/external */ -export * from "./FactorioVersions"; -export * from "./LatestReleases"; +export * from "./FactorioVersions.js"; +export * from "./LatestReleases.js"; diff --git a/packages/lib/src/factorio/index.ts b/packages/lib/src/factorio/index.ts index 72335610..dbf0e1bf 100644 --- a/packages/lib/src/factorio/index.ts +++ b/packages/lib/src/factorio/index.ts @@ -3,4 +3,4 @@ * @module lib/factorio * @author Hornwitser */ -export * from "./exchange_string"; +export * from "./exchange_string.js"; diff --git a/packages/lib/src/file_ops.ts b/packages/lib/src/file_ops.ts index 55d2bda7..908f57a3 100644 --- a/packages/lib/src/file_ops.ts +++ b/packages/lib/src/file_ops.ts @@ -7,7 +7,7 @@ import fs from "node:fs/promises"; import path from "path"; import stream from "stream"; -import { redactUrl } from "./helpers"; +import { redactUrl } from "./helpers.js"; /** @@ -168,6 +168,10 @@ export async function safeOutputFile( throw err; } } + if (options?.mode !== undefined) { + // writeFile only applies mode when creating, a leftover temporary keeps its old mode. + await fs.chmod(temporary, options.mode); + } await fs.rename(temporary, file); } diff --git a/packages/lib/src/helpers.ts b/packages/lib/src/helpers.ts index b14d8e2a..dbbf2f26 100644 --- a/packages/lib/src/helpers.ts +++ b/packages/lib/src/helpers.ts @@ -3,7 +3,7 @@ * @module lib/helpers */ -import type { Logger } from "./logging"; +import type { Logger } from "./logging.js"; import setBlocking from "set-blocking"; /** diff --git a/packages/lib/src/is_deep_strict_equal.browser.ts b/packages/lib/src/is_deep_strict_equal.browser.ts new file mode 100644 index 00000000..9fdd65d2 --- /dev/null +++ b/packages/lib/src/is_deep_strict_equal.browser.ts @@ -0,0 +1 @@ +export { default } from "fast-deep-equal/es6/index.js"; diff --git a/packages/lib/src/is_deep_strict_equal.d.ts b/packages/lib/src/is_deep_strict_equal.d.ts deleted file mode 100644 index 3ec0532c..00000000 --- a/packages/lib/src/is_deep_strict_equal.d.ts +++ /dev/null @@ -1 +0,0 @@ -export default function isDeepStrictEqual(a: any, b: any): boolean; diff --git a/packages/lib/src/is_deep_strict_equal.js b/packages/lib/src/is_deep_strict_equal.js deleted file mode 100644 index 0f695078..00000000 --- a/packages/lib/src/is_deep_strict_equal.js +++ /dev/null @@ -1,9 +0,0 @@ - -"use strict"; - -if (process.env.APP_ENV === "browser") { - - module.exports = require("fast-deep-equal/es6"); -} else { - module.exports = require("util").isDeepStrictEqual; -} diff --git a/packages/lib/src/is_deep_strict_equal.node.ts b/packages/lib/src/is_deep_strict_equal.node.ts new file mode 100644 index 00000000..4714580c --- /dev/null +++ b/packages/lib/src/is_deep_strict_equal.node.ts @@ -0,0 +1 @@ +export { isDeepStrictEqual as default } from "util"; diff --git a/packages/lib/src/link/WebSocket.browser.ts b/packages/lib/src/link/WebSocket.browser.ts new file mode 100644 index 00000000..a1444375 --- /dev/null +++ b/packages/lib/src/link/WebSocket.browser.ts @@ -0,0 +1,2 @@ +const NativeWebSocket = WebSocket; +export { NativeWebSocket as WebSocket }; diff --git a/packages/lib/src/link/WebSocket.d.ts b/packages/lib/src/link/WebSocket.d.ts deleted file mode 100644 index c44f2672..00000000 --- a/packages/lib/src/link/WebSocket.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -import ws = require("ws"); -export = ws; diff --git a/packages/lib/src/link/WebSocket.js b/packages/lib/src/link/WebSocket.js deleted file mode 100644 index 06879e25..00000000 --- a/packages/lib/src/link/WebSocket.js +++ /dev/null @@ -1,3 +0,0 @@ -"use strict"; - -module.exports = process.env.APP_ENV === "browser" ? window.WebSocket : require("ws"); diff --git a/packages/lib/src/link/WebSocket.node.ts b/packages/lib/src/link/WebSocket.node.ts new file mode 100644 index 00000000..64683e43 --- /dev/null +++ b/packages/lib/src/link/WebSocket.node.ts @@ -0,0 +1 @@ +export { WebSocket } from "ws"; diff --git a/packages/lib/src/link/connectors.ts b/packages/lib/src/link/connectors.ts index ebed9978..d14b89e3 100644 --- a/packages/lib/src/link/connectors.ts +++ b/packages/lib/src/link/connectors.ts @@ -2,12 +2,12 @@ import { strict as assert } from "assert"; import events from "events"; -import WebSocket from "./WebSocket"; -import * as libData from "../data"; -import * as libErrors from "../errors"; -import { logger } from "../logging"; -import ExponentialBackoff from "../ExponentialBackoff"; -import type { Request, Event } from "./link"; +import { WebSocket } from "#WebSocket"; +import * as libData from "../data/index.js"; +import * as libErrors from "../errors.js"; +import { logger } from "../logging.js"; +import ExponentialBackoff from "../ExponentialBackoff.js"; +import type { Request, Event } from "./link.js"; /** * Numbered codes describing why a connection was closed @@ -151,7 +151,7 @@ type WebSocketBaseConnectorEvents = { "drop": [], }; -export type WebSocketClusterio = WebSocket.WebSocket & { +export type WebSocketClusterio = WebSocket & { clusterio_ignore_dump?: boolean; }; @@ -275,7 +275,9 @@ export abstract class WebSocketBaseConnector< if (process.env.APP_ENV === "browser") { this._socket!.close(ConnectionClosed.Timeout, "Heartbeat timeout"); } else { - this._socket!.terminate(); + // This is only available in node, but the typechecking doesn't + // detect that this code is unreachable in the browser build. + (this._socket! as any).terminate(); } } else { @@ -635,7 +637,7 @@ export abstract class WebSocketClientConnector extends WebSocketBaseConnector { + this._socket!.onerror = ((event: { error?: any, message?: string, }) => { // It's assumed that close is always called by ws let code = !event.error ? "" : `, code: ${event.error.code}`; let message = `Connector | Socket error: ${event.message || "unknown error"}${code}`; @@ -657,7 +659,8 @@ export abstract class WebSocketClientConnector extends WebSocketBaseConnector { logger.verbose("Connector | Open"); @@ -710,12 +713,14 @@ export abstract class WebSocketClientConnector extends WebSocketBaseConnector { constructor: Partial>> & { @@ -444,7 +444,7 @@ export class Link { let pending = this._pendingRequests.get(message.dst.requestId!); if (!pending) { throw new libErrors.InvalidMessage( - `Received response ${message.dst.requestId} without a pending request` + `Received response ${message.dst.requestId} from ${message.src} without a pending request` ); } @@ -465,7 +465,7 @@ export class Link { let pending = this._pendingRequests.get(message.dst.requestId!); if (!pending) { throw new libErrors.InvalidMessage( - `Received error response ${message.dst.requestId} without a pending request` + `Received error response ${message.dst.requestId} from ${message.src} without a pending request` ); } diff --git a/packages/lib/src/link/messages.ts b/packages/lib/src/link/messages.ts index 1db48338..b3c856d5 100644 --- a/packages/lib/src/link/messages.ts +++ b/packages/lib/src/link/messages.ts @@ -1,13 +1,13 @@ -import * as core from "../data/messages_core"; -import * as controller from "../data/messages_controller"; -import * as host from "../data/messages_host"; -import * as instance from "../data/messages_instance"; -import * as mod from "../data/messages_mod"; -import * as user from "../data/messages_user"; -import * as role from "../data/messages_role"; -import * as plugin from "../data/messages_plugin"; -import * as subscriptions from "../subscriptions"; -import type { RequestClass, EventClass } from "./link"; +import * as core from "../data/messages_core.js"; +import * as controller from "../data/messages_controller.js"; +import * as host from "../data/messages_host.js"; +import * as instance from "../data/messages_instance.js"; +import * as mod from "../data/messages_mod.js"; +import * as user from "../data/messages_user.js"; +import * as role from "../data/messages_role.js"; +import * as plugin from "../data/messages_plugin.js"; +import * as subscriptions from "../subscriptions.js"; +import type { RequestClass, EventClass } from "./link.js"; /** * Event and Request payloads registered with the Link class. Used to decode @@ -140,4 +140,5 @@ export const dataClasses: (RequestClass | EventClass) plugin.PluginListRequest, plugin.PluginUpdateRequest, plugin.PluginInstallRequest, + plugin.UpdateAllRequest, ]; diff --git a/packages/lib/src/loadPlugin.ts b/packages/lib/src/loadPlugin.ts new file mode 100644 index 00000000..7a6f0a87 --- /dev/null +++ b/packages/lib/src/loadPlugin.ts @@ -0,0 +1,54 @@ +// Contained in its own file to allow use within web build +import type { PluginDeclaration, PluginType, PluginLoadContext, PluginClass } from "./plugin.js"; + + +export async function loadPluginEntrypoint< + Context extends object, + Info extends PluginDeclaration +>( + pluginInfo: Info, + pluginType: PluginType, + context: PluginLoadContext, + module: Record +) { + const init = module.default; + + if (typeof init !== "function") { + throw new Error(`Expected ${pluginType} plugin ${pluginInfo.name} to export a default function`); + } + + await init(context); +} + +export async function loadPluginClass< + Context extends object, + Class extends PluginClass, + Info extends PluginDeclaration +>( + pluginInfo: Info, + pluginType: PluginType, + context: PluginLoadContext, + module: Record, + exportName: string, + baseClass: Class +) { + const pluginClass = module[exportName]; + + if (typeof pluginClass !== "function") { + throw new Error(`Expected ${pluginType} plugin ${pluginInfo.name} to export a class named ${exportName}`); + } + + if (!(pluginClass.prototype instanceof baseClass)) { + throw new Error(`Expected ${exportName} exported from ${pluginInfo.name} to extend ${baseClass.name}`); + } + + const plugin = (pluginClass as Class).fromContext(context); + try { + await plugin.init(); + } catch (err) { + plugin.detachHooks(); + throw err; + } + + return plugin as InstanceType; +} diff --git a/packages/lib/src/load_plugin_list.ts b/packages/lib/src/load_plugin_list.ts index 8d4d1689..d91977e2 100644 --- a/packages/lib/src/load_plugin_list.ts +++ b/packages/lib/src/load_plugin_list.ts @@ -1,8 +1,8 @@ import fs from "node:fs/promises"; import { createRequire } from "node:module"; import path from "path"; -import { logger } from "./logging"; -import * as libFileOps from "./file_ops"; +import { logger } from "./logging.js"; +import * as libFileOps from "./file_ops.js"; import vm from "vm"; /** @@ -67,6 +67,7 @@ async function findLocalPlugins(pluginList: Map): Promise 0; } +const require = createRequire(import.meta.url); function getPluginName(requireSpec: string) { const context = vm.createContext({ require: require, pluginInfo: null }); const code = `pluginInfo = require(${JSON.stringify(requireSpec)}).plugin;`; diff --git a/packages/lib/src/logging_utils.ts b/packages/lib/src/logging_utils.ts index eebb1c2e..2a02f57d 100644 --- a/packages/lib/src/logging_utils.ts +++ b/packages/lib/src/logging_utils.ts @@ -14,12 +14,12 @@ import Transport from "winston-transport"; import { LEVEL, MESSAGE } from "triple-beam"; import chalk from "chalk"; -import * as libData from "./data"; -import * as libErrors from "./errors"; -import * as libFileOps from "./file_ops"; -import { type LogFilter, levels, logFilter, logger } from "./logging"; -import * as libStream from "./stream"; -import type { Link } from "./link"; +import * as libData from "./data/index.js"; +import * as libErrors from "./errors.js"; +import * as libFileOps from "./file_ops.js"; +import { type LogFilter, levels, logFilter, logger } from "./logging.js"; +import * as libStream from "./stream.js"; +import type { Link } from "./link/index.js"; const finished = util.promisify(stream.finished); diff --git a/packages/lib/src/permissions.ts b/packages/lib/src/permissions.ts index ce1bb44c..2c9abed0 100644 --- a/packages/lib/src/permissions.ts +++ b/packages/lib/src/permissions.ts @@ -6,10 +6,44 @@ * @author Hornwitser * @module lib/users */ -import { Permission } from "./data"; +import type { PluginNodeEnvInfo, PluginWebpackEnvInfo } from "./plugin.js"; +import { Permission } from "./data/index.js"; export const permissions = new Map(); +/** + * Registry of known permission names. + * + * Plugins add their permissions with module augmentation: + * + * ```ts + * declare module "@clusterio/lib" { + * export interface Permissions { + * "foo_frobber.frobnicate": never; + * } + * } + * ``` + * + * Only the keys matter, the values are always never. + */ +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface Permissions extends Record<(typeof corePermissions)[number]["name"], never> {} + +/** Name of a permission known at compile time, see {@link Permissions}. */ +export type PermissionName = keyof Permissions; + +/** Definition of a permission, see {@link definePermission}. */ +export type PermissionDefinition = { + /** The internal name for the permission. */ + name: PermissionName, + /** User facing name for the permission. */ + title: string, + /** User facing description. */ + description: string, + /** If true this permission is granted by the generated Player role by default. */ + grantByDefault?: boolean, +}; + /** * Define a new user permission for Clusterio * @@ -17,25 +51,18 @@ export const permissions = new Map(); * granted to roles, and checked on users. Name should be `lower_case` * style and start with the plugin name followed by a dot. * - * @param def - The definition for the permission. - * @param def.name - The internal name for the permission. - * @param def.title - User facing name for permission. - * @param def.description - User facing description. - * @param def.grantByDefault - - * If true this role is granted by the generated Player role by default. + * Plugins should prefer declaring permissions in the `permissions` array + * of their {@link PluginDeclaration}, which is registered by + * {@link registerPluginPermissions}. * + * @param def - The definition for the permission. */ export function definePermission({ name, title, description, grantByDefault = false, -}: { - name: string, - title: string, - description: string, - grantByDefault?: boolean, -}) { +}: PermissionDefinition) { if (typeof name !== "string" || !name.length) { throw new Error("Expected name to be a non-empty string"); } @@ -56,477 +83,508 @@ export function definePermission({ permissions.set(name, new Permission(name, title, description, grantByDefault)); } +/** + * Define all permissions declared by the provided plugin infos + * + * @param pluginInfos - Array of plugin info objects. + */ +export function registerPluginPermissions(pluginInfos: PluginNodeEnvInfo[] | PluginWebpackEnvInfo[]) { + for (const pluginInfo of pluginInfos) { + for (const permission of pluginInfo.permissions ?? []) { + if (typeof permission.name !== "string" || !permission.name.startsWith(`${pluginInfo.name}.`)) { + throw new Error( + `Expected name of permission '${permission.name}' for ${pluginInfo.name} ` + + `to start with '${pluginInfo.name}.'` + ); + } + definePermission(permission); + } + } +} + // Definitions for the built in permissions used in Clusterio. // description should answer "this permission allows you to ___" -definePermission({ - name: "core.admin", - title: "Administrator", - description: "Bypass all permission checks.", -}); -definePermission({ - name: "core.control.connect", - title: "Connect to WebSocket", - description: "Connect to the controller's WebSocket control interface.", - grantByDefault: true, -}); +export const corePermissions = [ + { + name: "core.admin", + title: "Administrator", + description: "Bypass all permission checks.", + }, + { + name: "core.control.connect", + title: "Connect to WebSocket", + description: "Connect to the controller's WebSocket control interface.", + grantByDefault: true, + }, -definePermission({ - name: "core.controller.stop", - title: "Stop controller", - description: - "Stop the Node.js controller process making the cluster inoperable until someone with access to the system " + - "it runs on manually starts it again.", -}); -definePermission({ - name: "core.controller.restart", - title: "Restart controller", - description: "Restart the Node.js controller process if the system is set up for restarting.", -}); -definePermission({ - name: "core.controller.update", - title: "Update controller", - description: "Remotely update the controller if the controller allows for remote updates.", -}); -definePermission({ - name: "core.controller.get_config", - title: "Get controller config", - description: "Get the config of controller.", -}); -definePermission({ - name: "core.controller.update_config", - title: "Modify controller config", - description: "Modify the controller config or entries of the controller config.", -}); -definePermission({ - name: "core.system.subscribe", - title: "Subscribe to system updates", - description: - "Subscribe to be notified when the system info detailing system specific information along with metrics " + - "such as cpu, memory and disk usage for the controller and hosts are updated.", -}); -definePermission({ - name: "core.host.stop", - title: "Stop Hosts", - description: - "Stop Node.js host processes making the host inaccessible until someone with access to the system " + - "it runs on manually starts it again.", -}); -definePermission({ - name: "core.host.restart", - title: "Restart Hosts", - description: "Restart Node.js host processes if the system they run on are set up for restarting.", -}); -definePermission({ - name: "core.host.update", - title: "Update Hosts", - description: "Remotely update a host if the host allows for remote updates.", -}); -definePermission({ - name: "core.host.get_config", - title: "Get host configs", - description: "Get the config of hosts.", -}); -definePermission({ - name: "core.host.update_config", - title: "Modify host configs", - description: "Modify the host config or entries of the host config.", -}); -definePermission({ - name: "core.host.list", - title: "List hosts", - description: "Get the full list of hosts in the cluster.", - grantByDefault: true, -}); -definePermission({ - name: "core.host.subscribe", - title: "Subscribe to host updates", - description: "Subscribe to be notified on updates on the details of hosts.", - grantByDefault: true, -}); -definePermission({ - name: "core.host.generate_token", - title: "Generate host token", - description: "Generate tokens for hosts to connect to the cluster with.", -}); -definePermission({ - name: "core.host.revoke_token", - title: "Revoke host token", - description: "Revoke access token and terminate existing connection for hosts.", -}); -definePermission({ - name: "core.host.create_config", - title: "Create host config", - description: "Create host configs via the controller.", -}); + { + name: "core.controller.stop", + title: "Stop controller", + description: + "Stop the Node.js controller process making the cluster inoperable until someone with access to the " + + "system it runs on manually starts it again.", + }, + { + name: "core.controller.restart", + title: "Restart controller", + description: "Restart the Node.js controller process if the system is set up for restarting.", + }, + { + name: "core.controller.update", + title: "Update controller", + description: "Remotely update the controller if the controller allows for remote updates.", + }, + { + name: "core.controller.get_config", + title: "Get controller config", + description: "Get the config of controller.", + }, + { + name: "core.controller.update_config", + title: "Modify controller config", + description: "Modify the controller config or entries of the controller config.", + }, + { + name: "core.system.subscribe", + title: "Subscribe to system updates", + description: + "Subscribe to be notified when the system info detailing system specific information along with metrics " + + "such as cpu, memory and disk usage for the controller and hosts are updated.", + }, + { + name: "core.host.stop", + title: "Stop Hosts", + description: + "Stop Node.js host processes making the host inaccessible until someone with access to the system " + + "it runs on manually starts it again.", + }, + { + name: "core.host.restart", + title: "Restart Hosts", + description: "Restart Node.js host processes if the system they run on are set up for restarting.", + }, + { + name: "core.host.update", + title: "Update Hosts", + description: "Remotely update a host if the host allows for remote updates.", + }, + { + name: "core.host.get_config", + title: "Get host configs", + description: "Get the config of hosts.", + }, + { + name: "core.host.update_config", + title: "Modify host configs", + description: "Modify the host config or entries of the host config.", + }, + { + name: "core.host.list", + title: "List hosts", + description: "Get the full list of hosts in the cluster.", + grantByDefault: true, + }, + { + name: "core.host.subscribe", + title: "Subscribe to host updates", + description: "Subscribe to be notified on updates on the details of hosts.", + grantByDefault: true, + }, + { + name: "core.host.generate_token", + title: "Generate host token", + description: "Generate tokens for hosts to connect to the cluster with.", + }, + { + name: "core.host.revoke_token", + title: "Revoke host token", + description: "Revoke access token and terminate existing connection for hosts.", + }, + { + name: "core.host.create_config", + title: "Create host config", + description: "Create host configs via the controller.", + }, -definePermission({ - name: "core.instance.get", - title: "Get instance", - description: "Get the details of an instance in the cluster.", - grantByDefault: true, -}); -definePermission({ - name: "core.instance.list", - title: "List instances", - description: "Get the full list of instances in the cluster.", - grantByDefault: true, -}); -definePermission({ - name: "core.instance.subscribe", - title: "Subscribe to instance updates", - description: "Subscribe to be notified on updates on the details and status of instances.", - grantByDefault: true, -}); -definePermission({ - name: "core.instance.create", - title: "Create instance", - description: "Create new instances on the controller.", -}); -definePermission({ - name: "core.instance.get_config", - title: "Get instance config", - description: "Get the config of instances.", -}); -definePermission({ - name: "core.instance.update_config", - title: "Modify instance config", - description: "Modify the config or entries of the config for instances.", -}); -definePermission({ - name: "core.instance.assign", - title: "Assign instance", - description: "Assign or reassign instances to a host.", -}); -definePermission({ - name: "core.instance.save.list", - title: "List saves", - description: "List the saves currently on instances.", -}); -definePermission({ - name: "core.instance.save.subscribe", - title: "Subscribe to save updates", - description: "Subscribe to be notifed on updates to the list of saves of instances.", -}); -definePermission({ - name: "core.instance.save.create", - title: "Create new save", - description: "Create new savegames on instances.", -}); -definePermission({ - name: "core.instance.save.rename", - title: "Rename save", - description: "Rename savegames on instances.", -}); -definePermission({ - name: "core.instance.save.copy", - title: "Copy save", - description: "Create copies of savegames on instances.", -}); -definePermission({ - name: "core.instance.save.delete", - title: "Delete save", - description: "Delete savegames on instances.", -}); -definePermission({ - name: "core.instance.save.upload", - title: "Upload save", - description: "Upload savegames to instances.", -}); -definePermission({ - name: "core.instance.save.transfer", - title: "Transfer save", - description: "Transfer savegames between instances.", -}); -definePermission({ - name: "core.instance.save.download", - title: "Download save", - description: "Download savegames from instances.", -}); -definePermission({ - name: "core.instance.export_data", - title: "Export locale and icons", - description: "Export the the locale and icons from an instance and upload it to the controller.", -}); -definePermission({ - name: "core.instance.extract_players", - title: "Extract player stats from running save", - description: "Run extraction to create a user for each player in the save and set the online time from the save.", -}); -definePermission({ - name: "core.instance.start", - title: "Start instance", - description: "Start instances.", -}); -definePermission({ - name: "core.instance.restart", - title: "Restart instance", - description: "Restart instances.", -}); -definePermission({ - name: "core.instance.load_scenario", - title: "Load scenario", - description: "Start instances by loading a scenario.", -}); -definePermission({ - name: "core.instance.stop", - title: "Stop instance", - description: "Stop instances.", -}); -definePermission({ - name: "core.instance.kill", - title: "Kill instance", - description: "Terminate running instances without saving or cleanup.", -}); -definePermission({ - name: "core.instance.delete", - title: "Delete instance", - description: "Delete instances (includes config and all files stored like saves and logs).", -}); -definePermission({ - name: "core.instance.send_rcon", - title: "Send RCON", - description: "Send arbitrary RCON commands to instances.", -}); + { + name: "core.instance.get", + title: "Get instance", + description: "Get the details of an instance in the cluster.", + grantByDefault: true, + }, + { + name: "core.instance.list", + title: "List instances", + description: "Get the full list of instances in the cluster.", + grantByDefault: true, + }, + { + name: "core.instance.subscribe", + title: "Subscribe to instance updates", + description: "Subscribe to be notified on updates on the details and status of instances.", + grantByDefault: true, + }, + { + name: "core.instance.create", + title: "Create instance", + description: "Create new instances on the controller.", + }, + { + name: "core.instance.get_config", + title: "Get instance config", + description: "Get the config of instances.", + }, + { + name: "core.instance.update_config", + title: "Modify instance config", + description: "Modify the config or entries of the config for instances.", + }, + { + name: "core.instance.assign", + title: "Assign instance", + description: "Assign or reassign instances to a host.", + }, + { + name: "core.instance.save.list", + title: "List saves", + description: "List the saves currently on instances.", + }, + { + name: "core.instance.save.subscribe", + title: "Subscribe to save updates", + description: "Subscribe to be notifed on updates to the list of saves of instances.", + }, + { + name: "core.instance.save.create", + title: "Create new save", + description: "Create new savegames on instances.", + }, + { + name: "core.instance.save.rename", + title: "Rename save", + description: "Rename savegames on instances.", + }, + { + name: "core.instance.save.copy", + title: "Copy save", + description: "Create copies of savegames on instances.", + }, + { + name: "core.instance.save.delete", + title: "Delete save", + description: "Delete savegames on instances.", + }, + { + name: "core.instance.save.upload", + title: "Upload save", + description: "Upload savegames to instances.", + }, + { + name: "core.instance.save.transfer", + title: "Transfer save", + description: "Transfer savegames between instances.", + }, + { + name: "core.instance.save.download", + title: "Download save", + description: "Download savegames from instances.", + }, + { + name: "core.instance.export_data", + title: "Export locale and icons", + description: "Export the the locale and icons from an instance and upload it to the controller.", + }, + { + name: "core.instance.extract_players", + title: "Extract player stats from running save", + description: + "Run extraction to create a user for each player in the save and set the online time from the save.", + }, + { + name: "core.instance.start", + title: "Start instance", + description: "Start instances.", + }, + { + name: "core.instance.restart", + title: "Restart instance", + description: "Restart instances.", + }, + { + name: "core.instance.load_scenario", + title: "Load scenario", + description: "Start instances by loading a scenario.", + }, + { + name: "core.instance.stop", + title: "Stop instance", + description: "Stop instances.", + }, + { + name: "core.instance.kill", + title: "Kill instance", + description: "Terminate running instances without saving or cleanup.", + }, + { + name: "core.instance.delete", + title: "Delete instance", + description: "Delete instances (includes config and all files stored like saves and logs).", + }, + { + name: "core.instance.send_rcon", + title: "Send RCON", + description: "Send arbitrary RCON commands to instances.", + }, -definePermission({ - name: "core.mod_pack.get", - title: "Retrieve mod pack", - description: "Get the details of a mod pack in the cluster.", - grantByDefault: true, -}); -definePermission({ - name: "core.mod_pack.list", - title: "List mod packs", - description: "Get the full list of a mod packs in the cluster.", - grantByDefault: true, -}); -definePermission({ - name: "core.mod_pack.subscribe", - title: "Subscribe to mod pack updates", - description: "Subscribe to be notified on updates on the details of mod packs.", - grantByDefault: true, -}); -definePermission({ - name: "core.mod_pack.create", - title: "Create mod pack", - description: "Create new mod pack in the cluster.", -}); -definePermission({ - name: "core.mod_pack.update", - title: "Update mod pack", - description: "Edit existing mod pack definition in the cluster.", -}); -definePermission({ - name: "core.mod_pack.delete", - title: "Delete mod pack", - description: "Delete mod pack definition in the cluster.", -}); -definePermission({ - name: "core.mod.get", - title: "Get mods", - description: "Get the details of a mod stored in the cluster.", - grantByDefault: true, -}); -definePermission({ - name: "core.mod.list", - title: "List mods", - description: "Get the full list of mods stored on the controller.", - grantByDefault: true, -}); -definePermission({ - name: "core.mod.search", - title: "Search mods", - description: "Search through the list of mods stored on the controller.", - grantByDefault: true, -}); -definePermission({ - name: "core.mod.search_portal", - title: "Search mod portal", - description: "Search for mods on the Factorio mod portal.", - grantByDefault: true, -}); -definePermission({ - name: "core.mod.subscribe", - title: "Subscribe to mod updates", - description: "Subscribe to be notified on updates on the details of mods.", - grantByDefault: true, -}); -definePermission({ - name: "core.mod.upload", - title: "Upload mods", - description: "Upload mods to the controller.", -}); -definePermission({ - name: "core.mod.download", - title: "Download mod", - description: "Download mods stored on the controller.", - grantByDefault: true, -}); -definePermission({ - name: "core.mod.delete", - title: "Delete mod", - description: "Delete mods stored on the controller.", -}); -definePermission({ - name: "core.mod.download_from_portal", - title: "Download mods from portal", - description: "Allow downloading mods directly from the Factorio mod portal to the controller.", - grantByDefault: true, -}); + { + name: "core.mod_pack.get", + title: "Retrieve mod pack", + description: "Get the details of a mod pack in the cluster.", + grantByDefault: true, + }, + { + name: "core.mod_pack.list", + title: "List mod packs", + description: "Get the full list of a mod packs in the cluster.", + grantByDefault: true, + }, + { + name: "core.mod_pack.subscribe", + title: "Subscribe to mod pack updates", + description: "Subscribe to be notified on updates on the details of mod packs.", + grantByDefault: true, + }, + { + name: "core.mod_pack.create", + title: "Create mod pack", + description: "Create new mod pack in the cluster.", + }, + { + name: "core.mod_pack.update", + title: "Update mod pack", + description: "Edit existing mod pack definition in the cluster.", + }, + { + name: "core.mod_pack.delete", + title: "Delete mod pack", + description: "Delete mod pack definition in the cluster.", + }, + { + name: "core.mod.get", + title: "Get mods", + description: "Get the details of a mod stored in the cluster.", + grantByDefault: true, + }, + { + name: "core.mod.list", + title: "List mods", + description: "Get the full list of mods stored on the controller.", + grantByDefault: true, + }, + { + name: "core.mod.search", + title: "Search mods", + description: "Search through the list of mods stored on the controller.", + grantByDefault: true, + }, + { + name: "core.mod.search_portal", + title: "Search mod portal", + description: "Search for mods on the Factorio mod portal.", + grantByDefault: true, + }, + { + name: "core.mod.subscribe", + title: "Subscribe to mod updates", + description: "Subscribe to be notified on updates on the details of mods.", + grantByDefault: true, + }, + { + name: "core.mod.upload", + title: "Upload mods", + description: "Upload mods to the controller.", + }, + { + name: "core.mod.download", + title: "Download mod", + description: "Download mods stored on the controller.", + grantByDefault: true, + }, + { + name: "core.mod.delete", + title: "Delete mod", + description: "Delete mods stored on the controller.", + }, + { + name: "core.mod.download_from_portal", + title: "Download mods from portal", + description: "Allow downloading mods directly from the Factorio mod portal to the controller.", + grantByDefault: true, + }, -definePermission({ - name: "core.permission.list", - title: "List permissions", - description: "Get the full list of permissions in the cluster.", - grantByDefault: true, -}); + { + name: "core.permission.list", + title: "List permissions", + description: "Get the full list of permissions in the cluster.", + grantByDefault: true, + }, -definePermission({ - name: "core.role.list", - title: "List roles", - description: "Get the full list of roles and their permissions in the cluster.", - grantByDefault: true, -}); -definePermission({ - name: "core.role.subscribe", - title: "Subscribe to role updates", - description: "Subscribe to be notified on updates on the details of roles.", - grantByDefault: true, -}); -definePermission({ - name: "core.role.create", - title: "Create role", - description: "Create new roles with permissions.", -}); -definePermission({ - name: "core.role.update", - title: "Update role", - description: "Edit existing roles including permissions they grant.", -}); -definePermission({ - name: "core.role.delete", - title: "Delete role", - description: "Permanently delete roles.", -}); + { + name: "core.role.list", + title: "List roles", + description: "Get the full list of roles and their permissions in the cluster.", + grantByDefault: true, + }, + { + name: "core.role.subscribe", + title: "Subscribe to role updates", + description: "Subscribe to be notified on updates on the details of roles.", + grantByDefault: true, + }, + { + name: "core.role.create", + title: "Create role", + description: "Create new roles with permissions.", + }, + { + name: "core.role.update", + title: "Update role", + description: "Edit existing roles including permissions they grant.", + }, + { + name: "core.role.delete", + title: "Delete role", + description: "Permanently delete roles.", + }, -definePermission({ - name: "core.user.get", - title: "Get user", - description: "Get the details of a user in the cluster.", - grantByDefault: true, -}); -definePermission({ - name: "core.user.list", - title: "List users", - description: "Get the full list of users in the cluster.", - grantByDefault: true, -}); -definePermission({ - name: "core.user.subscribe", - title: "Subscribe to user updates", - description: "Subscribe to be notified on updates on the details and status of users.", - grantByDefault: true, -}); -definePermission({ - name: "core.user.create", - title: "Create users", - description: "Create user acounts with a given name.", -}); -definePermission({ - name: "core.user.revoke_token", - title: "Revoke user token", - description: "Revoke access token and terminate all existing control connections for oneself.", - grantByDefault: true, -}); -definePermission({ - name: "core.user.revoke_other_token", - title: "Revoke other user's token", - description: "Allow revoking tokens for other users than oneself. Requires Revoke user token.", -}); -definePermission({ - name: "core.user.update_roles", - title: "Update user roles", - description: "Add or remove any role to any user.", -}); -definePermission({ - name: "core.user.set_admin", - title: "Set user admin status", - description: "Promote or demote any user to Factorio admin.", -}); -definePermission({ - name: "core.user.set_banned", - title: "Set user ban status", - description: "Ban or unban any user.", -}); -definePermission({ - name: "core.user.set_whitelisted", - title: "Set user whitelist status", - description: "Add or remove any user to/from the whitelist.", -}); -definePermission({ - name: "core.user.delete", - title: "Delete user", - description: "Delete users and all data stored for them.", -}); -definePermission({ - name: "core.user.bulk_import", - title: "Bulk user import", - description: "Bulk import users including admin, whitelist, and bans." + - " (Imports types are restricted by other permissions, e.g. core.user.set_admin)", -}); -definePermission({ - name: "core.user.bulk_export", - title: "Bulk user export", - description: "Bulk export users including admin, whitelist, and bans.", -}); -definePermission({ - name: "core.user.bulk_restore", - title: "Bulk user restore", - description: "Bulk restore users including admin, whitelist, and bans." + - " (Restore types are restricted by other permissions, e.g. core.user.set_admin)", -}); + { + name: "core.user.get", + title: "Get user", + description: "Get the details of a user in the cluster.", + grantByDefault: true, + }, + { + name: "core.user.list", + title: "List users", + description: "Get the full list of users in the cluster.", + grantByDefault: true, + }, + { + name: "core.user.subscribe", + title: "Subscribe to user updates", + description: "Subscribe to be notified on updates on the details and status of users.", + grantByDefault: true, + }, + { + name: "core.user.create", + title: "Create users", + description: "Create user acounts with a given name.", + }, + { + name: "core.user.revoke_token", + title: "Revoke user token", + description: "Revoke access token and terminate all existing control connections for oneself.", + grantByDefault: true, + }, + { + name: "core.user.revoke_other_token", + title: "Revoke other user's token", + description: "Allow revoking tokens for other users than oneself. Requires Revoke user token.", + }, + { + name: "core.user.update_roles", + title: "Update user roles", + description: "Add or remove any role to any user.", + }, + { + name: "core.user.set_admin", + title: "Set user admin status", + description: "Promote or demote any user to Factorio admin.", + }, + { + name: "core.user.set_banned", + title: "Set user ban status", + description: "Ban or unban any user.", + }, + { + name: "core.user.set_whitelisted", + title: "Set user whitelist status", + description: "Add or remove any user to/from the whitelist.", + }, + { + name: "core.user.delete", + title: "Delete user", + description: "Delete users and all data stored for them.", + }, + { + name: "core.user.bulk_import", + title: "Bulk user import", + description: "Bulk import users including admin, whitelist, and bans." + + " (Imports types are restricted by other permissions, e.g. core.user.set_admin)", + }, + { + name: "core.user.bulk_export", + title: "Bulk user export", + description: "Bulk export users including admin, whitelist, and bans.", + }, + { + name: "core.user.bulk_restore", + title: "Bulk user restore", + description: "Bulk restore users including admin, whitelist, and bans." + + " (Restore types are restricted by other permissions, e.g. core.user.set_admin)", + }, -definePermission({ - name: "core.log.follow", - title: "Follow cluster log", - description: "Receive new entries in the cluster log. Required to see instance console.", -}); -definePermission({ - name: "core.log.query", - title: "Query cluster log", - description: "Query past entries in the cluster log. Required to see past entries in instance console.", -}); + { + name: "core.log.follow", + title: "Follow cluster log", + description: "Receive new entries in the cluster log. Required to see instance console.", + }, + { + name: "core.log.query", + title: "Query cluster log", + description: "Query past entries in the cluster log. Required to see past entries in instance console.", + }, -definePermission({ - name: "core.plugin.list", - title: "List plugins", - description: "List all installed plugins on a machine.", -}); -definePermission({ - name: "core.plugin.update", - title: "Update plugin", - description: "Remotely update a plugin if the target allows remote updates of plugins.", -}); -definePermission({ - name: "core.plugin.install", - title: "Install plugin", - description: "Remotely install a plugin if the target allows remote installs of plugins.", -}); + { + name: "core.plugin.list", + title: "List plugins", + description: "List all installed plugins on a machine.", + }, + { + name: "core.plugin.update", + title: "Update plugin", + description: "Remotely update a plugin if the target allows remote updates of plugins.", + }, + { + name: "core.plugin.install", + title: "Install plugin", + description: "Remotely install a plugin if the target allows remote installs of plugins.", + }, -definePermission({ - name: "core.debug.dump_ws", - title: "Dump WebSocket", - description: "Dump all WebSocket communicatation from the controller.", -}); + { + name: "core.debug.dump_ws", + title: "Dump WebSocket", + description: "Dump all WebSocket communicatation from the controller.", + }, -definePermission({ - name: "core.external.get_factorio_versions", - title: "Get factorio versions", - description: "Get list of all factorio versions from Wube", -}); + { + name: "core.external.get_factorio_versions", + title: "Get factorio versions", + description: "Get list of all factorio versions from Wube", + }, -definePermission({ - name: "core.external.get_latest_releases", - title: "Get factorio releases", - description: "Get the latest stable and experimental factorio releases from Wube", -}); + { + name: "core.external.get_latest_releases", + title: "Get factorio releases", + description: "Get the latest stable and experimental factorio releases from Wube", + }, +] as const satisfies readonly { + name: string, + title: string, + description: string, + grantByDefault?: boolean, +}[]; + +for (const permission of corePermissions) { + definePermission(permission); +} diff --git a/packages/lib/src/plugin.ts b/packages/lib/src/plugin.ts index 94942f07..d1cfd359 100644 --- a/packages/lib/src/plugin.ts +++ b/packages/lib/src/plugin.ts @@ -2,11 +2,10 @@ * Plugin interfaces and utilities. * @module lib/plugin */ -import * as libHelpers from "./helpers"; -import type { Logger } from "./logging"; -import type { FieldDefinition } from "./config"; -import type { PlayerStats } from "./data"; - +import type { Logger } from "./logging.js"; +import type { FieldDefinition } from "./config/index.js"; +import type { PermissionDefinition } from "./permissions.js"; +import type { PlayerStats } from "./data/index.js"; export const PluginFeatureFlags = [ /** The plugin requires module code to be patched into the save */ @@ -37,15 +36,37 @@ export type PluginDeclaration = { features?: (typeof PluginFeatureFlags)[number][]; messages?: any[]; + permissions?: PermissionDefinition[]; routes?: string[]; } +/** + * Check if a plugin is expected to ship a web build. + * + * Mirrors the rule used by the create tool: a web build is generated when the + * plugin has a web or controller entrypoint or defines config fields. + */ +export function pluginNeedsWebBuild(info: PluginDeclaration) { + return Boolean( + info.webEntrypoint + || info.controllerEntrypoint + || info.controllerConfigFields + || info.hostConfigFields + || info.instanceConfigFields + || info.controlConfigFields + ); +} + export type PluginNodeEnvInfo = PluginDeclaration & { /** * Path to the folder with the static files that should be hosted on the web * server in order for the web interface to be able to load the plugin. */ webStaticPath: string; + /** + * Absolute path to the package.json file for the plugin. + */ + packagePath: string; requirePath: string; version: string; manifest: any; @@ -85,42 +106,24 @@ export interface PlayerEvent { stats: PlayerStats, } -/** - * Invokes the given hook on all plugins - * - * @param plugins - - * Mapping of plugin names to plugins to invoke the hook on. - * @param hook - Name of hook to invoke. - * @param args - Arguments to pass on to the hook. - * @returns Non-undefined return values from the hooks. - */ -export async function invokeHook< - Hook extends string, - R, - Args extends [...any], - Plugin extends { logger: Logger } & Record R | Promise> ->( - plugins: Map, - hook: Hook, - ...args: Args -): Promise>, void>[]> { - let results: any[] = []; - for (let [name, plugin] of plugins) { - try { - const timeout = Symbol("timeout-token"); - let result = await libHelpers.timeout( - plugin[hook](...args) as Promise, - 15000, - timeout - ); - if (result === timeout) { - throw new Error(`Invoking hook ${hook} timed out for plugin ${name}`); - } else if (result !== undefined) { - results.push(result); - } - } catch (err: any) { - plugin.logger.error(`Ignoring error from plugin ${name} in ${hook}:\n${err.stack}`); - } - } - return results; -} +export type PluginLoadContext< + Context extends object, + Info extends PluginDeclaration = PluginNodeEnvInfo +> = Context & { + logger: Logger; + plugin: Info; +}; + +export type PluginClass< + Context extends object, + Info extends PluginDeclaration, +> = { + new (...args: any[]): any; + fromContext(context: PluginLoadContext): { + init(): Promise; + detachHooks(): void; + }; +}; + +export type PluginType = + Extract extends `${infer P}Entrypoint` ? P : never; diff --git a/packages/lib/src/plugin_loader.ts b/packages/lib/src/plugin_loader.ts index 90a1fc72..818efff1 100644 --- a/packages/lib/src/plugin_loader.ts +++ b/packages/lib/src/plugin_loader.ts @@ -5,9 +5,11 @@ */ import path from "path"; import fs from "node:fs/promises"; -import * as libErrors from "./errors"; -import * as libPlugin from "./plugin"; -import { logger } from "./logging"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import * as libErrors from "./errors.js"; +import * as libPlugin from "./plugin.js"; +import { type Logger, logger } from "./logging.js"; +import { loadPluginEntrypoint, loadPluginClass } from "./loadPlugin.js"; /** @@ -26,11 +28,17 @@ export async function loadPluginInfos(pluginList: Map) { for (let [pluginName, pluginPath] of pluginList) { let pluginInfo: libPlugin.PluginNodeEnvInfo; let pluginPackage: { name?: string, version: string, main?: string, private?: boolean }; + let packagePath; - // Check if plugin path exists, otherwise remove it + // Check if plugin has a package.json file, otherwise remove it try { - require.resolve(pluginPath); - } catch { + const absolute = path.isAbsolute(pluginPath); + const packageImport = path.posix.join(pluginPath, "package.json"); + packagePath = fileURLToPath(import.meta.resolve( + absolute ? pathToFileURL(packageImport).href : packageImport + )); + await fs.access(packagePath, fs.constants.F_OK); + } catch (err) { let errMsg = `Plugin path ${pluginPath} does not exist`; try { await fs.access(pluginPath, fs.constants.F_OK); @@ -42,8 +50,10 @@ export async function loadPluginInfos(pluginList: Map) { } try { - pluginInfo = require(pluginPath).plugin; - pluginPackage = require(path.posix.join(pluginPath, "package.json")); + pluginPackage = (await import(pathToFileURL(packagePath).href, { with: { type: "json" }})).default; + pluginInfo = (await import( + pathToFileURL(path.posix.join(pluginPath, pluginPackage.main ?? "index.js")).href + )).plugin; } catch (err: any) { if (err.code === "InstallationError") { throw err; @@ -69,12 +79,8 @@ export async function loadPluginInfos(pluginList: Map) { continue; } - pluginInfo.webStaticPath = path.join( - path.dirname( - require.resolve(path.posix.join(pluginPath, "package.json")) - ), - "dist", "web", "static", - ); + pluginInfo.webStaticPath = path.join(path.dirname(packagePath), "dist", "web", "static"); + pluginInfo.packagePath = packagePath; pluginInfo.requirePath = pluginPath; pluginInfo.version = pluginPackage.version; pluginInfo.npmPackage = !pluginPackage.private && pluginPath === pluginPackage.name ? pluginPath : undefined; @@ -83,22 +89,41 @@ export async function loadPluginInfos(pluginList: Map) { return plugins; } -export async function loadPluginClass( - pluginName: string, - requirePath: string, - className: string, - pluginClass: Class, -): Promise { - let entrypoint = require(requirePath); - if (!entrypoint[className]) { - throw new libErrors.PluginError(pluginName, - new Error(`Expected ${requirePath} to export a class named ${className}`) - ); +export async function loadPlugin< + Context extends { logger: Logger }, + Class extends libPlugin.PluginClass, +> ( + pluginInfo: libPlugin.PluginNodeEnvInfo, + pluginType: libPlugin.PluginType, + context: Context, + exportName: `${string}Plugin`, + baseClass: Class, +) { + const entrypoint = `${pluginType}Entrypoint` as const; + const requirePath = pluginInfo[entrypoint]; + + if (!requirePath) { + return; } - if (!(entrypoint[className].prototype instanceof pluginClass)) { - throw new libErrors.PluginError(pluginName, - new Error(`Expected ${className} exported from ${requirePath} to be a subclass of ${pluginClass.name}`) - ); + + const module = await import(pathToFileURL(path.posix.join(pluginInfo.requirePath, requirePath)).href); + const pluginContext: libPlugin.PluginLoadContext = { + ...context, + plugin: pluginInfo, + logger: context.logger.child({ plugin: pluginInfo.name }), + }; + + if (typeof module.default === "function") { + await loadPluginEntrypoint(pluginInfo, pluginType, pluginContext, module); + return; } - return entrypoint[className]; + + // migrate: accept plugins which export classes + if (module[exportName]) { + pluginContext.logger.warn(`Plugin ${pluginInfo.name} is using deprecated class export`); + await loadPluginClass(pluginInfo, pluginType, pluginContext, module, exportName, baseClass); + return; + } + + throw new Error(`Plugin ${pluginInfo.name} must export either a default function or ${exportName} class`); } diff --git a/packages/lib/src/prometheus.ts b/packages/lib/src/prometheus.ts index 84999093..89082788 100644 --- a/packages/lib/src/prometheus.ts +++ b/packages/lib/src/prometheus.ts @@ -12,7 +12,7 @@ * the listed interfaces for more information. * * @example - * const { Counter, exposition } = require("@clusterio/lib"); + * import { Counter, exposition } from "@clusterio/lib"; * * // Collectors are by default registered to the default collector regitry. * const totalRequests = new Counter( @@ -27,7 +27,8 @@ * * // Code handling the /metrics HTTP request, here shown for express * // but any http framework may be used - * const app = require("express")(); + * import express from "express"; + * const app = express(); * async function getMetrics(req, res) { * // By default exposition uses the default collector registry * let text = await exposition(); @@ -39,7 +40,7 @@ * @module lib/prometheus */ import { Type, Static } from "@sinclair/typebox"; -import { StringEnum } from "./data/composites"; +import { StringEnum } from "./data/composites.js"; /** * Result from collecting a {@link Collector} @@ -1669,7 +1670,8 @@ async function* expositionLines( * express.js. * * @example - * const app = require("express")(); + * import express from "express"; + * const app = express(); * async function getMetrics(req, res) { * // By default exposition uses the default collector registry * let text = await exposition(); diff --git a/packages/lib/src/rce_ops.ts b/packages/lib/src/rce_ops.ts index b5ad5be5..1946a5eb 100644 --- a/packages/lib/src/rce_ops.ts +++ b/packages/lib/src/rce_ops.ts @@ -1,15 +1,15 @@ import util from "util"; import path from "path"; import { exec } from "child_process"; -import { logger } from "./logging"; -import { RequestError } from "./errors"; -import { PluginNodeEnvInfo } from "./plugin"; +import { logger } from "./logging.js"; +import { RequestError } from "./errors.js"; +import type { PluginNodeEnvInfo } from "./plugin.js"; const execAsync = util.promisify(exec); function isDev() { // dev: /packages/lib/dist/src/rce_ops.js // prod: /node_modules/@clusterio/lib/dist/src/rce_ops.js - return __dirname.split(path.sep).at(-5) === "packages"; // opposed to "@clusterio" + return import.meta.dirname.split(path.sep).at(-5) === "packages"; // opposed to "@clusterio" } async function logExec(cmd: string) { @@ -19,8 +19,8 @@ async function logExec(cmd: string) { } } -export async function updatePackage(name: string) { - return logExec(`npm update --save ${name}`); +export async function updatePackage(...names: string[]) { + return logExec(`npm update --save ${names.join(" ")}`); } export async function installPackage(name: string) { @@ -35,6 +35,13 @@ export async function handlePluginUpdate(pluginName: string, pluginInfos: Plugin return await updatePackage(pluginName); } +export async function handleUpdateAll(corePackage: string, pluginInfos: PluginNodeEnvInfo[]) { + const pluginPackages = pluginInfos + .map(plugin => plugin.npmPackage) + .filter((name): name is string => name !== undefined); + return await updatePackage(corePackage, ...pluginPackages); +} + export async function handlePluginInstall(pluginName: string) { if (pluginName.length > 214 || /[^a-zA-Z0-9\-_.+@\/]/.test(pluginName)) { // https://docs.npmjs.com/cli/v11/configuring-npm/package-json#name diff --git a/packages/lib/src/schema.ts b/packages/lib/src/schema.ts index c783a3c9..4224086b 100644 --- a/packages/lib/src/schema.ts +++ b/packages/lib/src/schema.ts @@ -2,7 +2,7 @@ * JSON schemas used for validating link messages * @module lib/schema */ -import Ajv, { JSONSchemaType, ValidateFunction } from "ajv"; +import { Ajv, JSONSchemaType, ValidateFunction } from "ajv"; const ajv = new Ajv({ // These are duplicated in scripts/compile_validator allowUnionTypes: true, diff --git a/packages/lib/src/shared_commands.ts b/packages/lib/src/shared_commands.ts index d9f6eb09..654f0d90 100644 --- a/packages/lib/src/shared_commands.ts +++ b/packages/lib/src/shared_commands.ts @@ -4,11 +4,12 @@ */ import path from "path"; -import * as libConfig from "./config"; -import * as libFileOps from "./file_ops"; -import { logger } from "./logging"; -import * as libHelpers from "./helpers"; -import { LockFile } from "./LockFile"; +import * as libConfig from "./config/index.js"; +import * as libFileOps from "./file_ops.js"; +import { logger } from "./logging.js"; +import * as libHelpers from "./helpers.js"; +import { LockFile } from "./LockFile.js"; +import { pathToFileURL } from "url"; function print(...content: any[]) { @@ -59,8 +60,13 @@ export async function handlePluginCommand( let pluginInfo: { name: string }; try { - - pluginInfo = require(pluginPath).plugin; + const pluginPackage = (await import( + pathToFileURL(path.posix.join(pluginPath, "package.json")).href, + { with: { type: "json" }}, + )).default; + pluginInfo = (await import( + pathToFileURL(path.posix.join(pluginPath, pluginPackage.main ?? "index.js")).href + )).plugin; } catch (err: any) { logger.error(`Unable to import plugin info from ${args.path}:\n${err.stack}`); process.exitCode = 1; diff --git a/packages/lib/src/subscriptions.ts b/packages/lib/src/subscriptions.ts index 1b3311b6..298bf5d7 100644 --- a/packages/lib/src/subscriptions.ts +++ b/packages/lib/src/subscriptions.ts @@ -1,9 +1,10 @@ import { Type, Static } from "@sinclair/typebox"; -import { Link, Event, EventClass, RequestHandler, WebSocketBaseConnector } from "./link"; -import { Address, MessageRequest, IUser, JsonBoolean, StringEnum, AccountDetails } from "./data"; -import isDeepStrictEqual from "./is_deep_strict_equal"; -import { RequestError } from "./errors"; -import { logger } from "./logging"; +import { Link, Event, EventClass, RequestHandler, WebSocketBaseConnector } from "./link/index.js"; +import { Address, MessageRequest, IUser, JsonBoolean, StringEnum } from "./data/index.js"; +import isDeepStrictEqual from "#is_deep_strict_equal"; +import type { PermissionName } from "./permissions.js"; +import { RequestError } from "./errors.js"; +import { logger } from "./logging.js"; export type SubscriptionRequestHandler = RequestHandler | null>; export type EventSubscriberCallback = (event: T | null, synced: boolean) => void @@ -155,7 +156,7 @@ export class SubscriptionRequest { const entry = Link._eventsByName.get(data[0]); if (entry && entry.Event.permission) { if (typeof entry.Event.permission === "string") { - user.checkPermission(entry.Event.permission); + user.checkPermission(entry.Event.permission as PermissionName); } else { entry.Event.permission(user, message); } diff --git a/packages/lib/src/system_collectors.ts b/packages/lib/src/system_collectors.ts index adadc0ad..6132dfe2 100644 --- a/packages/lib/src/system_collectors.ts +++ b/packages/lib/src/system_collectors.ts @@ -1,8 +1,8 @@ import fs from "node:fs/promises"; import os from "os"; import util from "util"; -import { Gauge } from "./prometheus"; -import { SystemInfo } from "./data"; +import { Gauge } from "./prometheus.js"; +import { SystemInfo } from "./data/index.js"; function filterCpuModel() { const model = os.cpus()[0].model; diff --git a/packages/lib/tsconfig.browser.json b/packages/lib/tsconfig.browser.json index 8a87bca4..4b6c539b 100644 --- a/packages/lib/tsconfig.browser.json +++ b/packages/lib/tsconfig.browser.json @@ -4,6 +4,7 @@ "browser.ts", "package.json", "./src/api.ts", + "./src/AsyncHook.ts", "./src/config/*.ts", "./src/data/*.ts", "./src/external/*.ts", @@ -15,11 +16,13 @@ "./src/helpers.ts", "./src/hash.ts", "./src/ini.ts", + "./src/is_deep_strict_equal.browser.ts", "./src/link/*.ts", "./src/lua_tools.ts", "./src/logging.ts", "./src/permissions.ts", "./src/prometheus.ts", + "./src/loadPlugin.ts", "./src/plugin.ts", "./src/schema.ts", "./src/subscriptions.ts", diff --git a/packages/lib/tsconfig.node.json b/packages/lib/tsconfig.node.json index ef6ccd30..983999cb 100644 --- a/packages/lib/tsconfig.node.json +++ b/packages/lib/tsconfig.node.json @@ -1,6 +1,7 @@ { "extends": "../../tsconfig.node.json", "include": ["index.ts", "build_mod.js", "src/**/*.ts", "src/**/*.js"], + "exclude": ["src/**/*.browser.ts"], "compilerOptions": { "allowJs": true, }, diff --git a/packages/web_ui/global.d.ts b/packages/web_ui/global.d.ts index 90fd88c1..e9d5b8c7 100644 --- a/packages/web_ui/global.d.ts +++ b/packages/web_ui/global.d.ts @@ -3,6 +3,11 @@ declare module "*.png" { export default value; } +declare module "*.css" { + const value: string; + export default value; +} + declare module "*.svg" { const value: string; export default value; diff --git a/packages/web_ui/package.json b/packages/web_ui/package.json index 76b2d028..9371bdc6 100644 --- a/packages/web_ui/package.json +++ b/packages/web_ui/package.json @@ -60,8 +60,8 @@ "terser-webpack-plugin": "^5.3.11", "typescript": "^5.7.3", "util": "^0.12.5", - "webpack": "^5.98.0", - "webpack-cli": "^5.1.4", + "webpack": "catalog:", + "webpack-cli": "catalog:", "webpack-manifest-plugin": "^5.0.0" }, "devDependencies": { diff --git a/packages/web_ui/src/BaseWebPlugin.ts b/packages/web_ui/src/BaseWebPlugin.ts index 4ce654ae..a55a5920 100644 --- a/packages/web_ui/src/BaseWebPlugin.ts +++ b/packages/web_ui/src/BaseWebPlugin.ts @@ -1,6 +1,6 @@ import type React from "react"; -import type { AccountRole, FieldDefinition, Logger, PluginWebpackEnvInfo } from "@clusterio/lib"; import type { Control } from "./util/websocket"; +import * as lib from "@clusterio/lib"; /** * Plugin supplied login form @@ -23,25 +23,17 @@ export interface PluginLoginForm { Component: React.ComponentType<{ setToken(token: string): void }>; } -export interface InputComponentProps { - disabled?: boolean, - fieldDefinition: FieldDefinition, - value: null | boolean | number | string, - onChange: (value: null | boolean | number | string) => void, -} -export type InputComponent = React.ComponentType; - export type UserAccount = { /** Name of the currently logged in account. */ name: string; - /** Roles of the corrently logged in account. */ - roles: AccountRole[]; + /** Roles of the currently logged in account. */ + roles: lib.AccountRole[]; /** Check if the currently logged in account has the given permission. */ - hasPermission: (permission: string) => boolean | null; + hasPermission: (permission: lib.PermissionName) => boolean | null; /** Check if the currently logged in account has any of the given permissions. */ - hasAnyPermission: (...permissions: string[]) => boolean | null; + hasAnyPermission: (...permissions: lib.PermissionName[]) => boolean | null; /** Check if the currently logged in account has all of given permissions. */ - hasAllPermission: (...permissions: string[]) => boolean | null; + hasAllPermission: (...permissions: lib.PermissionName[]) => boolean | null; /** Logs out of the web interface. */ logOut: () => void; }; @@ -75,109 +67,91 @@ export interface PluginPage { /** * Permission to access page. function are expected to throw an error if access is deny. */ - permission?: string | ((account: UserAccount) => (boolean|null)); + permission?: lib.PermissionName | ((account: UserAccount) => (boolean|null)); }; -/** - * Base class for web interface plugins - */ -export default class BaseWebPlugin { +export interface InputComponentProps { + disabled?: boolean, + fieldDefinition: lib.FieldDefinition, + value: null | boolean | number | string, + onChange: (value: null | boolean | number | string) => void, +} + +export type InputComponent = React.ComponentType; + +export type ExtensionSlotProps = { + /** Placed at the end of the overview page. */ + OverviewPage: Record; + /** Placed at the end of the controller page. */ + ControllerPage: Record; + /** Placed at the end of the hosts list page. */ + HostsPage: Record; /** - * Contents of the plugin's package.json file + * Placed at the end of each host page. + * `host` is the host the page is displayed for. */ + HostViewPage: { + host: lib.HostDetails; + }; + /** Placed at the end of the instance list page. */ + InstancesPage: Record; + /** + * Placed at the end of each instance page. + * `instance` is the instance the page is displayed for. + */ + InstanceViewPage: { + instance: lib.InstanceDetails; + }; + /** Placed at the end of the users list page. */ + UsersPage: Record; + /** + * Placed at the end of each user page. + * `user` is the user the page is displayed for. + */ + UserViewPage: { + user: lib.UserDetails; + }; + /** Placed at the end of the mods list page */ + ModsPage: Record; + /** Placed at the end of the roles list page. */ + RolesPage: Record; + /** + * Placed at the end of each role page. + * `role` is the role the page is displayed for. + */ + RoleViewPage: { + role: lib.Role; + search?: string; + }; +}; + +export type PluginExtensionSlot = keyof ExtensionSlotProps; + +export type PluginExtensionProps = ExtensionSlotProps[K] & { component: K }; + +export type ExtensionComponent = React.ComponentType>; + +export type ExtensionComponents = { [K in PluginExtensionSlot]: Map> }; + +export type WebPluginContext = lib.PluginLoadContext<{ + control: Control; + container: any; package: any; - /** - * 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; - /** - * List of login forms provided by this plugin - */ - loginForms: PluginLoginForm[] = []; - /** - * List of pages provided by this plugin - */ - pages: PluginPage[] = []; - /** - * Additional Config inputComponent types available to render config - * entries with. - */ - inputComponents: Record = {}; - /** - * Extra react component to add to core components - * - * Interface to augment core components of the web UI. Setting a - * component as one of the supported properties of this object will - * cause the web UI to render it when displaying that component, - * usually at the end. Each component will receive a `plugin` param - * which is the instance of the web plugin that contained the - * component extra. - */ - componentExtra: { - /** Placed at the end of the controller page. */ - ControllerPage?: React.ComponentType, - /** Placed at the end of the hosts list page. */ - HostsPage?: React.ComponentType, - /** - * Placed at the end of each host page. Takes a `host` param which - * is the host the page is displayed for. - */ - HostViewPage?: React.ComponentType, - /** Placed at the end of the instance list page. */ - InstancesPage?: React.ComponentType, - /** - * Placed at the end of each instance page. Takes an `instance` - * param which is the instance the page is displayed for. - */ - InstanceViewPage?: React.ComponentType, - /** Placed at the end of the users list page. */ - UsersPage?: React.ComponentType, - /** - * Placed at the end of each user page. Takes a `user` param which - * is the user object the page is displayed for. - */ - UserViewPage?: React.ComponentType, - /** Placed at the end of the roles list page. */ - RolesPage?: React.ComponentType, - /** - * Placed at the end of each role page. Takes a `role` param which - * is the role object the page is displayed for. - */ - RoleViewPage?: React.ComponentType, - } = {}; +}, lib.PluginWebpackEnvInfo>; - - constructor( - /** - * Webpack container for this plugin - */ - public container: any, - packageData: any, - /** - * The plugin's own info module - */ - public info: PluginWebpackEnvInfo, - /** - * Control link to the controller - * - * Not connected at the time init is invoked. - */ - public control: Control, - logger: Logger, - ) { - this.package = packageData; - this.logger = logger.child({ plugin: this.info.name }) as unknown as Logger; +/** + * Collection of web plugin hooks + */ +export class WebHooks extends lib.AsyncHookCollection { + constructor(logger: lib.Logger) { + super(logger); + this.controllerConnectionEvent = this.newHook(); + this.extensionComponents = this.newHook(); + this.inputComponents = this.newHook(); + this.loginForms = this.newHook(); + this.pages = this.newHook(); } - /** - * Called immediately after the class is instantiated. - */ - async init() { } - /** * Called when an event on the controller connection happens * @@ -212,5 +186,99 @@ export default class BaseWebPlugin { * * @param event - one of connect, drop, resume and close */ - onControllerConnectionEvent(event: "connect" | "drop" | "resume" | "close") { } + readonly controllerConnectionEvent: lib.AsyncHook<[event: "connect" | "drop" | "resume" | "close"]>; + + /** + * Collect additional UI components to render in core ui extension points. + * + * Each plugin may return a partial mapping of extension slot names + * to React components. All returned components are rendered. + * + * Multiple plugins may contribute to the same slot. + */ + readonly extensionComponents: lib.AsyncHook<[], Partial<{ + [K in PluginExtensionSlot]: ExtensionComponent; + }>>; + + /** + * Collect additional config input components provided by plugins. + * + * Each plugin may return a mapping of input component names to React components. + * These mappings are merged together to form the final registry. + * + * If multiple plugins define the same key, the last attached handler wins. + */ + readonly inputComponents: lib.AsyncHook<[], Record>; + + /** + * Additional methods used to login to the web ui. + * + * All returned components are rendered. + * Each must has a unique name prefixed with the plugin name. + */ + readonly loginForms: lib.AsyncHook<[], PluginLoginForm[]>; + + /** + * Additional pages accessible on the web ui. + * + * Each page must have a unique path. + */ + readonly pages: lib.AsyncHook<[], PluginPage[]>; +} + +// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging +export interface BaseWebPlugin { + onControllerConnectionEvent?(event: "connect" | "drop" | "resume" | "close"): void; +} + +/** + * Base class for web interface plugins + */ +// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging +export class BaseWebPlugin { + /** + * Contents of the plugin's package.json file + */ + package: any; + + pages: PluginPage[] = []; + loginForms: PluginLoginForm[] = []; + inputComponents: Record = {}; + componentExtra: Partial<{ + [K in PluginExtensionSlot]: ExtensionComponent; + }> = {}; + + constructor( + public container: any, + packageData: any, + public info: lib.PluginWebpackEnvInfo, + public control: Control, + public logger: lib.Logger, + ) { + this.package = packageData; // strict mode complains if we don't assign it this way + + if (this.onControllerConnectionEvent) { + control.hooks.controllerConnectionEvent.attach(info.name, this.onControllerConnectionEvent.bind(this)); + } + + // Subclasses populate these fields in init(), which runs after the + // constructor, so the handlers read them when the hook is collected. + control.hooks.inputComponents.attach(info.name, () => this.inputComponents); + control.hooks.extensionComponents.attach(info.name, () => this.componentExtra); + control.hooks.loginForms.attach(info.name, () => this.loginForms); + control.hooks.pages.attach(info.name, () => this.pages); + } + + static fromContext(context: WebPluginContext): BaseWebPlugin { + return new this(context.container, context.package, context.plugin, context.control, context.logger); + } + + detachHooks() { + this.control.hooks.detachAll(this.info.name); + } + + /** + * Called immediately after the class is instantiated. + */ + async init() {} } diff --git a/packages/web_ui/src/bootstrap.tsx b/packages/web_ui/src/bootstrap.tsx index 2695d154..8c38c456 100644 --- a/packages/web_ui/src/bootstrap.tsx +++ b/packages/web_ui/src/bootstrap.tsx @@ -6,12 +6,13 @@ import { createRoot } from "react-dom/client"; import * as lib from "@clusterio/lib"; import App from "./components/App"; -import BaseWebPlugin, { InputComponent } from "./BaseWebPlugin"; import InputRole from "./components/InputRole"; import InputModPack from "./components/InputModPack"; import { InputTargetVersion, InputPartialVersion, InputFullVersion } from "./components/InputVersion"; import { Control, ControlConnector } from "./util/websocket"; import { loadedPluginSetKey } from "./util/pluginSet"; +import * as WebPlugin from "./BaseWebPlugin"; +import { pages } from "./pages"; const { ConsoleTransport, WebConsoleFormat, logger } = lib; @@ -49,6 +50,9 @@ async function loadPluginInfos(): Promise<[lib.PluginWebpackEnvInfo[], string]> logger.error(`Failed to load plugin ${meta.name}: ${meta.web.error}`); continue; } + if (!meta.web.main) { + continue; + } try { await loadScript(`${staticRoot}${meta.web.main}`); let container: any = (window as { [key: string]: any })[`plugin_${meta.name}`]; @@ -78,26 +82,44 @@ async function loadPluginInfos(): Promise<[lib.PluginWebpackEnvInfo[], string]> return [pluginInfos, pluginSetKey]; } -async function loadPlugins(pluginInfos: lib.PluginWebpackEnvInfo[], control: Control) { - let plugins = new Map(); - for (let pluginInfo of pluginInfos) { - if (!pluginInfo.enabled) { +async function loadPlugins( + pluginInfos: lib.PluginWebpackEnvInfo[], + control: Control +) { + const plugins = new Map(); + + for (const pluginInfo of pluginInfos) { + if (!pluginInfo.enabled || !pluginInfo.webEntrypoint) { continue; } + try { - let WebPluginClass = BaseWebPlugin; - if (pluginInfo.webEntrypoint) { - let webModule = (await pluginInfo.container.get(pluginInfo.webEntrypoint))(); - if (!webModule.WebPlugin) { - pluginInfo.error = "Plugin webEntrypoint does not export WebPlugin class"; - throw new Error(pluginInfo.error); - } - WebPluginClass = webModule.WebPlugin; + const moduleFactory = await pluginInfo.container.get(pluginInfo.webEntrypoint); + const webModule = moduleFactory(); + + const pluginContext: WebPlugin.WebPluginContext = { + control, + plugin: pluginInfo, + container: pluginInfo.container, + package: pluginInfo.package, + logger: logger.child({ plugin: pluginInfo.name }), + }; + + if (typeof webModule.default === "function") { + await lib.loadPluginEntrypoint(pluginInfo, "web", pluginContext, webModule); + continue; } - let plugin = new WebPluginClass(pluginInfo.container, pluginInfo.package, pluginInfo, control, logger); - await plugin.init(); - plugins.set(pluginInfo.name, plugin); + // migrate: accept plugins which export classes + if (webModule.WebPlugin) { + logger.warn(`Plugin ${pluginInfo.name} is using deprecated class hooks`); + plugins.set(pluginInfo.name, await lib.loadPluginClass( + pluginInfo, "web", pluginContext, webModule, "WebPlugin", WebPlugin.BaseWebPlugin + )); + continue; + } + + throw new Error(`Plugin ${pluginInfo.name} must export either a default function or WebPlugin`); } catch (err: any) { pluginInfo.error = `Error loading plugin: ${err.message}`; @@ -107,26 +129,80 @@ async function loadPlugins(pluginInfos: lib.PluginWebpackEnvInfo[], control: Con } } } + return plugins; } -function inputComponentsFromPlugins(plugins: Map) { - const inputComponents: Record = { +function mergeWithWarning( + entries: Iterable<[string, Iterable]>, + getKey: (item: V) => string, + label: string +): V[] { + const map = new Map(); + const sources = new Map(); + + for (const [source, items] of entries) { + for (const item of items) { + const key = getKey(item); + if (sources.has(key)) { + lib.logger.warn( + `Plugin ${source} is redefining ${label} "${key}" previously defined by ${sources.get(key)}` + ); + } + + sources.set(key, source); + map.set(key, item); + } + } + + return [...map.values()]; +} + +async function inputComponentsFromHooks(control: Control) { + const result = await control.hooks.inputComponents.collectEntries(); + + result.unshift(["core", { "full_version": InputFullVersion, "partial_version": InputPartialVersion, "target_version": InputTargetVersion, "mod_pack": InputModPack, "role": InputRole, - }; - for (let [pluginName, plugin] of plugins) { - for (const [name, Component] of Object.entries(plugin.inputComponents)) { - if (Object.prototype.hasOwnProperty.call(inputComponents, name)) { - lib.logger.warn(`Plugin ${pluginName} is redefining config inputComponent ${name}`); + }]); + + const iterable = result.map( + v => [v[0], Object.entries(v[1])] as [string, Iterable<[string, WebPlugin.InputComponent]>] + ); + + return new Map(mergeWithWarning(iterable, ([key]) => key, "input component")); +} + +async function extensionComponentsFromHooks(control: Control) { + const extensionComponents = {} as WebPlugin.ExtensionComponents; + + const result = await control.hooks.extensionComponents.collectEntries(); + for (const [source, record] of result) { + for (const [slot, Component] of Object.entries(record) as [ + WebPlugin.PluginExtensionSlot, React.ComponentType, + ][]) { + if (!extensionComponents[slot]) { + extensionComponents[slot] = new Map(); } - inputComponents[name] = Component; + extensionComponents[slot].set(source, Component as any); } } - return inputComponents; + + return extensionComponents; +} + +async function loginFormsFromHooks(control: Control) { + const results = await control.hooks.loginForms.collectEntries(); + return mergeWithWarning(results, item => item.name, "login form"); +} + +async function pagesFromHooks(control: Control) { + const results = await control.hooks.pages.collectEntries(); + results.unshift(["core", pages]); + return mergeWithWarning(results, item => item.path, "page"); } export default async function bootstrap() { @@ -134,15 +210,24 @@ export default async function bootstrap() { level: "verbose", format: new WebConsoleFormat(), })); - let [pluginInfos, pluginSetKey] = await loadPluginInfos(); + const [pluginInfos, pluginSetKey] = await loadPluginInfos(); + const pluginInfoEntries = pluginInfos.map(p => [p.name, p] as const); lib.registerPluginMessages(pluginInfos); + lib.registerPluginPermissions(pluginInfos); lib.addPluginConfigFields(pluginInfos); let wsUrl = new URL(webRoot, document.location.href); let controlConnector = new ControlConnector(wsUrl.href, 120); - let control = new Control(controlConnector, new Map(pluginInfos.map(p => [p.name, p])), pluginSetKey); + let control = new Control(controlConnector, new Map(pluginInfoEntries), pluginSetKey); control.plugins = await loadPlugins(pluginInfos, control); - control.inputComponents = inputComponentsFromPlugins(control.plugins); + control.loadedPlugins = new Map(pluginInfoEntries.filter( + ([_, info]) => info.enabled && info.webEntrypoint && !info.error + )); + + control.inputComponents = await inputComponentsFromHooks(control); + control.extensionComponents = await extensionComponentsFromHooks(control); + control.loginForms = await loginFormsFromHooks(control); + control.pages = await pagesFromHooks(control); const root = createRoot(document.getElementById("root") as HTMLDivElement); root.render(); diff --git a/packages/web_ui/src/components/BaseConfigTree.tsx b/packages/web_ui/src/components/BaseConfigTree.tsx index 744ce51d..058487ce 100644 --- a/packages/web_ui/src/components/BaseConfigTree.tsx +++ b/packages/web_ui/src/components/BaseConfigTree.tsx @@ -33,11 +33,11 @@ function getInitialValues(config: lib.Config, props: BaseConfigTreeProps) { return initialValues; } -function renderInput(inputComponents: Record, def: lib.FieldDefinition, readonly: boolean) { - if (def.inputComponent && Object.prototype.hasOwnProperty.call(inputComponents, def.inputComponent)) { +function renderInput(inputComponents: Map, def: lib.FieldDefinition, readonly: boolean) { + if (def.inputComponent && inputComponents.has(def.inputComponent)) { // Field.Item will provide the value and onChange props to the component. type InputPartial = React.ComponentClass>; - const CustomInput = inputComponents[def.inputComponent] as unknown as InputPartial; + const CustomInput = inputComponents.get(def.inputComponent) as unknown as InputPartial; return ; } if (def.type === "boolean") { @@ -291,7 +291,7 @@ function computeTreeData( children: [], }; - if (def.type === "object" && !Object.keys(control.inputComponents).includes(def.inputComponent!)) { + if (def.type === "object" && !control.inputComponents.has(def.inputComponent!)) { let restartRequiredProps = new Set(def.restartRequiredProps || []); childNode.title = diff --git a/packages/web_ui/src/components/LoginForm.tsx b/packages/web_ui/src/components/LoginForm.tsx index 2fd64d3c..40ef4c54 100644 --- a/packages/web_ui/src/components/LoginForm.tsx +++ b/packages/web_ui/src/components/LoginForm.tsx @@ -5,6 +5,7 @@ import LockOutlined from "@ant-design/icons/LockOutlined"; import notify from "../util/notify"; import logo from "../images/logo.png"; import ControlContext from "./ControlContext"; +import { PluginLoginForm } from "../BaseWebPlugin"; const { Paragraph } = Typography; @@ -36,7 +37,7 @@ function TokenAuth(props: SetTokenProps) { } export default function LoginForm(props: SetTokenProps) { - let plugins = useContext(ControlContext).plugins; + const control = useContext(ControlContext); const [clusterName, setClusterName] = useState("Clusterio"); useEffect(() => { @@ -55,16 +56,14 @@ export default function LoginForm(props: SetTokenProps) { })(); }, []); - let loginForms = []; - for (let plugin of plugins.values()) { - loginForms.push(...plugin.loginForms); - } - - loginForms.push({ - name: "core.token", - title: "Token", - Component: TokenAuth, - }); + const loginForms: PluginLoginForm[] = [ + ...control.loginForms, + { + name: "core.token", + title: "Token", + Component: TokenAuth, + }, + ]; return diff --git a/packages/web_ui/src/components/NpmButton.tsx b/packages/web_ui/src/components/NpmButton.tsx index 1a5a83e2..e543cb43 100644 --- a/packages/web_ui/src/components/NpmButton.tsx +++ b/packages/web_ui/src/components/NpmButton.tsx @@ -27,6 +27,17 @@ function NpmRemoteUpdate({ setApplyAction, form, target }: NpmActionProps) { ; } +function NpmUpdateAll({ setApplyAction, form, target }: NpmActionProps) { + const control = useContext(ControlContext); + + setApplyAction(async () => { + await control.sendTo(target, new lib.UpdateAllRequest()); + }); + + return <> + ; +} + function NpmPluginUpdate({ setApplyAction, form, target }: NpmActionProps) { const control = useContext(ControlContext); const [plugins, setPlugins] = useState([]); @@ -128,6 +139,8 @@ export function NpmButton(props: { } }, [hostId]); + const coreUpdatePermission = `core.${props.target === "controller" ? "controller" : "host"}.update` as const; + function onValuesChange({ action } : { action?: string }) { if (action) { setFormAction(action); @@ -175,10 +188,17 @@ export function NpmButton(props: {
- {account.hasPermission(`core.${props.target === "controller" ? "controller" : "host"}.update`) + {account.hasPermission(coreUpdatePermission) ? Update Clusterio : undefined} + {account.hasPermission(coreUpdatePermission) && account.hasPermission("core.plugin.update") + ? + Update All + : undefined} {account.hasPermission("core.plugin.update") ? Update Plugin @@ -198,6 +218,8 @@ export function NpmButton(props: { } {formAction === "remote_update" ? : undefined} + {formAction === "update_all" + ? : undefined} {formAction === "plugin_update" ? : undefined} {formAction === "plugin_install" diff --git a/packages/web_ui/src/components/PluginExtra.tsx b/packages/web_ui/src/components/PluginExtra.tsx index 840dc2c4..f7ef261c 100644 --- a/packages/web_ui/src/components/PluginExtra.tsx +++ b/packages/web_ui/src/components/PluginExtra.tsx @@ -1,29 +1,17 @@ import React, { useContext } from "react"; -import * as lib from "@clusterio/lib"; - import ControlContext from "./ControlContext"; -import BaseWebPlugin from "../BaseWebPlugin"; +import { PluginExtensionSlot, ExtensionSlotProps } from "../BaseWebPlugin"; -type PluginExtraProps = { - component: string; - search?: string; - instance?: lib.InstanceDetails; - user?: lib.UserDetails; - host?: lib.HostDetails; - role?: lib.Role; -}; -export default function PluginExtra(props: PluginExtraProps) { - let control = useContext(ControlContext); - - let components = []; - for (let [name, plugin] of control.plugins) { - if (Object.prototype.hasOwnProperty.call(plugin.componentExtra, props.component)) { - type ComponentExtra = {[key: string]: React.ComponentType}; - let ComponentExtra = (plugin.componentExtra as ComponentExtra)[props.component]; - components.push(); - } - } - return components; +export default function PluginExtra< + C extends PluginExtensionSlot +> ( + props: { component: C } & ExtensionSlotProps[C] +) { + const control = useContext(ControlContext); + const components = control.extensionComponents[props.component]; + return components && [...components].map(([source, Component]) => ( + + )); } diff --git a/packages/web_ui/src/components/PluginViewPage.tsx b/packages/web_ui/src/components/PluginViewPage.tsx index 58eba31a..233f549e 100644 --- a/packages/web_ui/src/components/PluginViewPage.tsx +++ b/packages/web_ui/src/components/PluginViewPage.tsx @@ -26,7 +26,7 @@ export default function PluginViewPage() { })(); }, []); - const plugin = control.plugins.get(pluginName); + const loadedPlugin = control.loadedPlugins.get(pluginName); const pluginInfo = control.pluginInfos.get(pluginName); const pluginTitle = pluginInfo ? pluginInfo.title : pluginName; let nav = [{ name: "Plugins", path: "/plugins" }, { name: pluginTitle }]; @@ -46,11 +46,14 @@ export default function PluginViewPage() { } const pluginError = pluginMeta.web.error ?? pluginInfo?.error; - if (!plugin) { + const hasWebModule = Boolean(pluginMeta.web.main || pluginMeta.web.error); + if (!loadedPlugin) { return {pluginMeta.version} - No + + {hasWebModule ? "No" : "No web module"} + {pluginMeta.loaded ? "Yes" : "No"} {pluginMeta.enabled ? "Yes" : "No"} @@ -81,22 +84,22 @@ export default function PluginViewPage() { {pluginMeta.enabled ? "Yes" : "No"} - {plugin.info.description} - {plugin.package.homepage ? - {plugin.package.homepage} + {loadedPlugin.description} + {loadedPlugin.package.homepage ? + {loadedPlugin.package.homepage} : null} - {plugin.package.author ? - {plugin.package.author} + {loadedPlugin.package.author ? + {loadedPlugin.package.author} : null} - {plugin.package.license} + {loadedPlugin.package.license} - {pluginMeta.version !== plugin.package.version ? Disabled on controller; } + if (!plugin.meta.web.main && !plugin.meta.web.error) { + return plugin.meta.version; + } if (!plugin.meta.web.error && !control.pluginInfos.has(plugin.meta.name)) { return <> Reload page to load; } @@ -105,7 +106,15 @@ export default function PluginsPage() { { title: "Loaded", key: "loaded", - render: (_, plugin) => (plugin.package ? "Yes" : null), + render: (_, plugin) => { + if (plugin.package) { + return "Yes"; + } + if (!plugin.meta.web.main && !plugin.meta.web.error) { + return "No web module"; + } + return null; + }, sorter: (a, b) => Number(Boolean(a.package)) - Number(Boolean(b.package)), sortOrder: tableState.sortOrder("loaded"), responsive: ["sm"], diff --git a/packages/web_ui/src/components/SiteLayout.tsx b/packages/web_ui/src/components/SiteLayout.tsx index 7b22f008..0cbfa7c2 100644 --- a/packages/web_ui/src/components/SiteLayout.tsx +++ b/packages/web_ui/src/components/SiteLayout.tsx @@ -10,7 +10,6 @@ import ChangeLogModal from "./ChangeLogModal"; import AboutModal from "./AboutModal"; import Link from "./Link"; -import { pages } from "../pages"; import { saveJson } from "../util/save_file"; import { useAccount } from "../model/account"; import { DraggingContext } from "../model/is_dragging"; @@ -60,7 +59,6 @@ export default function SiteLayout() { const [drawerOpen, setDrawerOpen] = useState(false); const [collapsed, setCollapsed] = useState(false); let account = useAccount(); - let plugins = useContext(ControlContext).plugins; const control = useContext(ControlContext); const [dragging, setDragging] = useState(0); const [aboutOpen, setAboutOpen] = useState(false); @@ -110,13 +108,9 @@ export default function SiteLayout() { ], }; - let combinedPages = [...pages]; - for (let plugin of plugins.values()) { - combinedPages.push(...plugin.pages); - } - let menuItems: MenuItem[] = []; let menuGroups = new Map(); + const combinedPages = [...control.pages]; for (let { sidebarName, sidebarGroup, permission, path } of combinedPages) { if ( !sidebarName || permission && ( diff --git a/packages/web_ui/src/index.ts b/packages/web_ui/src/index.ts index 2de8cedf..aaf875b7 100644 --- a/packages/web_ui/src/index.ts +++ b/packages/web_ui/src/index.ts @@ -1,7 +1,7 @@ /// export { default as bootstrap } from "./bootstrap"; export { default as notify, notifyErrorHandler } from "./util/notify"; -export { default as BaseWebPlugin } from "./BaseWebPlugin"; +export * from "./BaseWebPlugin"; export * from "./util/websocket"; export * from "./model/account"; diff --git a/packages/web_ui/src/model/account.ts b/packages/web_ui/src/model/account.ts index dfce6832..fd18e5d1 100644 --- a/packages/web_ui/src/model/account.ts +++ b/packages/web_ui/src/model/account.ts @@ -22,13 +22,13 @@ export function useAccount(): UserAccount { }; }, [control]); - function checkPermissionExists(permission: string) { + function checkPermissionExists(permission: lib.PermissionName) { if (!lib.permissions.has(permission)) { throw new Error(`permission ${permission} does not exist`); } } - function hasPermission(permission: string): boolean { + function hasPermission(permission: lib.PermissionName): boolean { for (let role of roles) { if (role.permissions.includes("core.admin") || role.permissions.includes(permission)) { return true; @@ -40,14 +40,14 @@ export function useAccount(): UserAccount { return { name, roles, - hasPermission(permission: string): boolean | null { + hasPermission(permission: lib.PermissionName): boolean | null { if (!roles) { return null; } checkPermissionExists(permission); return hasPermission(permission); }, - hasAnyPermission(...permissions: string[]): boolean | null { + hasAnyPermission(...permissions: lib.PermissionName[]): boolean | null { if (!roles) { return null; } @@ -61,7 +61,7 @@ export function useAccount(): UserAccount { } return false; }, - hasAllPermission(...permissions: string[]): boolean | null { + hasAllPermission(...permissions: lib.PermissionName[]): boolean | null { if (!roles) { return null; } diff --git a/packages/web_ui/src/util/websocket.ts b/packages/web_ui/src/util/websocket.ts index b4afb44c..265b0c18 100644 --- a/packages/web_ui/src/util/websocket.ts +++ b/packages/web_ui/src/util/websocket.ts @@ -1,6 +1,6 @@ import * as lib from "@clusterio/lib"; import packageJson from "../../package.json"; -import BaseWebPlugin, { type InputComponent } from "../BaseWebPlugin"; +import * as WebPlugin from "../BaseWebPlugin"; const { logFilter, logger } = lib; @@ -51,9 +51,14 @@ export class Control extends lib.Link { /** Roles of the account this control link is connected as. */ accountRoles: lib.AccountRole[] | null = null; - /** Plugins loaded in the web interface */ - public plugins = new Map(); - public inputComponents: Record = {}; + public hooks = new WebPlugin.WebHooks(logger); + /** @deprecated Only holds plugins using the class export, use loadedPlugins and hooks instead */ + public plugins = new Map(); + public loadedPlugins = new Map(); + public inputComponents = new Map(); + public extensionComponents: Partial = {}; + public loginForms: WebPlugin.PluginLoginForm[] = []; + public pages: WebPlugin.PluginPage[] = []; /** Updates not handled by the subscription service */ accountUpdateHandlers: accountHandler[] = []; @@ -101,11 +106,7 @@ export class Control extends lib.Link { }); for (let event of ["connect", "drop", "resume", "close"] as const) { - this.connector.on(event, () => { - for (let plugin of this.plugins.values()) { - plugin.onControllerConnectionEvent(event); - } - }); + this.connector.on(event, this.hooks.controllerConnectionEvent.listener); } this.handle(lib.AccountUpdateEvent, this.handleAccountUpdateEvent.bind(this)); diff --git a/packages/web_ui/webpack.common.js b/packages/web_ui/webpack.common.js index 26545081..2ae834ab 100644 --- a/packages/web_ui/webpack.common.js +++ b/packages/web_ui/webpack.common.js @@ -141,6 +141,9 @@ module.exports = (env = {}, argv = {}) => ({ }, resolve: { extensions: [".js", ".ts", ".json", ".jsx", ".tsx"], + extensionAlias: { + ".js": [".ts", ".js"], + }, fallback: { "crypto": false, "fs": false, diff --git a/plugins/global_chat/control.ts b/plugins/global_chat/control.ts index 3b6534ba..ff4f5068 100644 --- a/plugins/global_chat/control.ts +++ b/plugins/global_chat/control.ts @@ -1,6 +1,6 @@ import { CommandTree, Command } from "@clusterio/lib"; import { BaseCtlPlugin } from "@clusterio/ctl"; -import { ChatEvent } from "./messages"; +import { ChatEvent } from "./messages.js"; const globalChatCommands = new CommandTree({ name: "global-chat", description: "Global Chat plugin commands", diff --git a/plugins/global_chat/index.ts b/plugins/global_chat/index.ts index a02d5cfd..dee12820 100644 --- a/plugins/global_chat/index.ts +++ b/plugins/global_chat/index.ts @@ -1,13 +1,13 @@ import type * as lib from "@clusterio/lib"; -import { ChatEvent } from "./messages"; +import { ChatEvent } from "./messages.js"; export const plugin: lib.PluginDeclaration = { name: "global_chat", title: "Global Chat", description: "Forwards chat between instances.", - instanceEntrypoint: "dist/node/instance", - ctlEntrypoint: "dist/node/control", + instanceEntrypoint: "dist/node/instance.js", + ctlEntrypoint: "dist/node/control.js", features: [ "ScriptCommands", diff --git a/plugins/global_chat/instance.ts b/plugins/global_chat/instance.ts index 2a3fa4e6..22cf1dda 100644 --- a/plugins/global_chat/instance.ts +++ b/plugins/global_chat/instance.ts @@ -1,6 +1,6 @@ import * as lib from "@clusterio/lib"; import { BaseInstancePlugin } from "@clusterio/host"; -import { ChatEvent } from "./messages"; +import { ChatEvent } from "./messages.js"; /** * Removes server-specific tags from messages. diff --git a/plugins/global_chat/package.json b/plugins/global_chat/package.json index c33bba72..6947e542 100644 --- a/plugins/global_chat/package.json +++ b/plugins/global_chat/package.json @@ -23,6 +23,7 @@ "access": "public" }, + "type": "module", "main": "dist/node/index.js", "scripts": { "prepare": "tsc --build && webpack-cli --mode production", @@ -35,8 +36,15 @@ "peerDependencies": { "@clusterio/ctl": "workspace:^", "@clusterio/host": "workspace:^", - "@clusterio/lib": "workspace:^", - "@clusterio/web_ui": "workspace:^" + "@clusterio/lib": "workspace:^" + }, + "peerDependenciesMeta": { + "@clusterio/ctl": { + "optional": true + }, + "@clusterio/host": { + "optional": true + } }, "dependencies": { "@sinclair/typebox": "catalog:" diff --git a/plugins/global_chat/test/plugin.js b/plugins/global_chat/test/plugin.js index 6436dc16..0b3b1a1e 100644 --- a/plugins/global_chat/test/plugin.js +++ b/plugins/global_chat/test/plugin.js @@ -1,13 +1,10 @@ -"use strict"; -const assert = require("assert").strict; -const lib = require("@clusterio/lib"); - -const mock = require("../../../test/mock"); -const lines = require("../../../test/lib/factorio/lines"); -const instance = require("../dist/node/instance"); -const info = require("../dist/node/index").plugin; -const { ChatEvent } = require("../dist/node/messages"); +import assert from "node:assert/strict"; +import * as mock from "../../../test/mock.js"; +import * as lines from "../../../test/lib/factorio/lines.js"; +import * as instance from "../dist/node/instance.js"; +import { plugin as info } from "../dist/node/index.js"; +import { ChatEvent } from "../dist/node/messages.js"; describe("global_chat plugin", function() { describe("removeTags()", function() { diff --git a/plugins/global_chat/webpack.config.js b/plugins/global_chat/webpack.config.cjs similarity index 100% rename from plugins/global_chat/webpack.config.js rename to plugins/global_chat/webpack.config.cjs diff --git a/plugins/inventory_sync/README.md b/plugins/inventory_sync/README.md index c39a8283..4f5fc4d9 100644 --- a/plugins/inventory_sync/README.md +++ b/plugins/inventory_sync/README.md @@ -32,8 +32,8 @@ The greatly simplified data flow is as follows: 3. If the player inventory was previously uploaded but is not the same then the player inventory is deleted and the player is turned into a spectator. 4. Otherwise the player inventory is kept. -5. Scenario script asks for the player inventory from the controller. -6. Controller sends it to the scenario in a stream of chunks. +5. Scenario script asks for the player inventory from the controller, including its current recipe notification state. +6. Controller sends it to the scenario in a stream of chunks, with only the recipe notifications that differ from the current state. 7. Scenario displays and updates a progress bar as each chunk is received. 8. Once all chunks have loaded the player's synced inventory is recreated from the data and the player can start playing. @@ -43,3 +43,24 @@ Should an error occur during this process the player is given the option to use Communication between the server and instance goes over stdout or rcon, depending on the size of the data. Communication between the instance and the controller goes over websockets. Overall, we are able to achieve a latency between 3 and infinite ticks from server join, depending on the size of the inventory. The major limiter is rcon transfer speeds with larger inventories, especially if they contain blueprints. + + +## Controller Configuration + +### inventory_sync.player_lock_timeout + +Time in seconds before the exclusive access an instance holds on a player's inventory expires after the instance stops or its host disconnects. +Until it expires the player is told the inventory is in use on that instance when joining another one. +The timeout is cancelled if the instance comes back before it runs out. + +Defaults to `60`. + + +## Instance Configuration + +### inventory_sync.rcon_chunk_size + +Size in characters of the chunks the serialized inventory is split into when sent to the game over RCON. +Smaller chunks take more commands to transfer an inventory but hold up the RCON command pipe for less time each. + +Defaults to `1000`. diff --git a/plugins/inventory_sync/controller.ts b/plugins/inventory_sync/controller.ts index 19df1451..6bd7fa10 100644 --- a/plugins/inventory_sync/controller.ts +++ b/plugins/inventory_sync/controller.ts @@ -1,10 +1,10 @@ import { BaseControllerPlugin, type InstanceRecord } from "@clusterio/controller"; -import type { IpcPlayerData } from "./messages"; +import type { IpcPlayerData } from "./messages.js"; import fs from "node:fs/promises"; import path from "path"; import * as lib from "@clusterio/lib"; -import * as msg from "./messages"; +import * as msg from "./messages.js"; async function loadDatabase(config: lib.ControllerConfig, logger: lib.Logger) { let itemsPath = path.resolve(config.get("controller.database_directory"), "inventories.json"); diff --git a/plugins/inventory_sync/index.ts b/plugins/inventory_sync/index.ts index f38ec669..17fddfc0 100644 --- a/plugins/inventory_sync/index.ts +++ b/plugins/inventory_sync/index.ts @@ -1,5 +1,5 @@ import * as lib from "@clusterio/lib"; -import * as messages from "./messages"; +import * as messages from "./messages.js"; declare module "@clusterio/lib" { export interface InstanceConfigFields { @@ -8,21 +8,17 @@ declare module "@clusterio/lib" { export interface ControllerConfigFields { "inventory_sync.player_lock_timeout": number; } + export interface Permissions { + "inventory_sync.inventory.view": never; + } } -lib.definePermission({ - name: "inventory_sync.inventory.view", - title: "View player inventories", - description: "View player inventories", - grantByDefault: true, -}); - export const plugin: lib.PluginDeclaration = { name: "inventory_sync", title: "Inventory sync", description: "Synchronizes players inventories between instances", - instanceEntrypoint: "dist/node/instance", + instanceEntrypoint: "dist/node/instance.js", instanceConfigFields: { "inventory_sync.rcon_chunk_size": { title: "Rcon inventory chunk size", @@ -33,7 +29,7 @@ export const plugin: lib.PluginDeclaration = { }, }, - controllerEntrypoint: "dist/node/controller", + controllerEntrypoint: "dist/node/controller.js", controllerConfigFields: { "inventory_sync.player_lock_timeout": { title: "Player Lock Timeout", @@ -57,6 +53,14 @@ export const plugin: lib.PluginDeclaration = { messages.DownloadRequest, messages.DatabaseStatsRequest, ], + permissions: [ + { + name: "inventory_sync.inventory.view", + title: "View player inventories", + description: "View player inventories", + grantByDefault: true, + }, + ], webEntrypoint: "./web", routes: ["/inventory"], }; diff --git a/plugins/inventory_sync/instance.ts b/plugins/inventory_sync/instance.ts index c831e8fd..831c7524 100644 --- a/plugins/inventory_sync/instance.ts +++ b/plugins/inventory_sync/instance.ts @@ -1,13 +1,20 @@ +import util from "util"; +import zlib from "zlib"; import * as lib from "@clusterio/lib"; import { BaseInstancePlugin } from "@clusterio/host"; import { AcquireRequest, AcquireResponse, ReleaseRequest, UploadRequest, DownloadRequest, DownloadResponse, IpcPlayerData, -} from "./messages"; +} from "./messages.js"; type IpcPlayerName = { player_name: string } +type IpcDownloadRequest = { + player_name: string, + recipe_notifications?: string, +} + type IpcAcquireResponse = { player_name: string, status: string, @@ -26,6 +33,49 @@ function chunkify(chunkSize: number, string: string): string[] { return string.match(new RegExp(`.{1,${chunkSize}}`, "g")) || []; } +const inflate = util.promisify(zlib.inflate); +const deflate = util.promisify(zlib.deflate); + +/** + * Decode a string produced by helpers.encode_string in Factorio + */ +async function decodeLuaString(encoded: string): Promise { + return (await inflate(Buffer.from(encoded, "base64"))).toString("utf8"); +} + +/** + * Encode a string so that helpers.decode_string in Factorio can read it + */ +async function encodeLuaString(text: string): Promise { + return (await deflate(Buffer.from(text, "utf8"))).toString("base64"); +} + +export type RecipeNotificationDelta = { + add?: string[], + remove?: string[], +} + +/** + * Compute the changes needed to turn the current cleared recipe list into the stored one + * @param stored - Cleared recipe names stored on the controller + * @param current - Cleared recipe names the instance currently has + * @returns names to add to and remove from the current list + */ +export function recipeNotificationDelta(stored: string[], current: string[]): RecipeNotificationDelta { + const storedSet = new Set(stored); + const currentSet = new Set(current); + const delta: RecipeNotificationDelta = {}; + const add = stored.filter(name => !currentSet.has(name)); + const remove = current.filter(name => !storedSet.has(name)); + if (add.length) { + delta.add = add; + } + if (remove.length) { + delta.remove = remove; + } + return delta; +} + export class InstancePlugin extends BaseInstancePlugin { playersToRelease!: Set; disconnecting!: boolean; @@ -55,7 +105,7 @@ export class InstancePlugin extends BaseInstancePlugin { ); this.instance.server.on( "ipc-inventory_sync_download", - (request: IpcPlayerName) => this.handleDownload(request).catch( + (request: IpcDownloadRequest) => this.handleDownload(request).catch( err => this.logger.error(`Error handling ipc-inventory_sync_download:\n${err.stack}`) ), ); @@ -163,7 +213,27 @@ export class InstancePlugin extends BaseInstancePlugin { ); } - async handleDownload(request: IpcPlayerName) { + /** + * Replace the stored recipe notifications with the difference from the + * current state on the instance to reduce the amount of data sent to Lua. + */ + async applyRecipeNotificationDelta(playerData: IpcPlayerData, current?: string) { + if (!playerData.recipe_notifications) { + return; + } + try { + const delta = recipeNotificationDelta( + JSON.parse(await decodeLuaString(playerData.recipe_notifications)), + current ? JSON.parse(await decodeLuaString(current)) : [], + ); + playerData.recipe_notifications = await encodeLuaString(JSON.stringify(delta)); + } catch (err: any) { + this.logger.warn(`Dropping invalid recipe notifications for ${playerData.name}:\n${err.stack}`); + delete playerData.recipe_notifications; + } + } + + async handleDownload(request: IpcDownloadRequest) { const playerName = request.player_name; this.logger.verbose(`Downloading ${playerName}`); @@ -177,6 +247,8 @@ export class InstancePlugin extends BaseInstancePlugin { return; } + await this.applyRecipeNotificationDelta(response.playerData, request.recipe_notifications); + const chunkSize = this.instance.config.get("inventory_sync.rcon_chunk_size"); const chunks = chunkify(chunkSize, JSON.stringify(response.playerData)); this.logger.verbose(`Sending inventory for ${playerName} in ${chunks.length} chunks`); diff --git a/plugins/inventory_sync/module/inventory_sync.lua b/plugins/inventory_sync/module/inventory_sync.lua index bf56a57e..c6804251 100644 --- a/plugins/inventory_sync/module/inventory_sync.lua +++ b/plugins/inventory_sync/module/inventory_sync.lua @@ -17,6 +17,7 @@ local progress_dialog = require("modules/inventory_sync/gui/progress_dialog") local dialog_failed_download = require("modules/inventory_sync/gui/dialog_failed_download") local v2_remote_controller = compat.version_ge("2.0.0") +local recipe_notifications_api = compat.version_ge("2.0.67") -- Returns true if the player is currently in a cutscene local function is_in_cutscene(player) @@ -441,8 +442,15 @@ function inventory_sync.initiate_inventory_download(player, player_record, gener local script_data = get_script_data() script_data.active_downloads[player.name] = record + -- The plugin only sends back what differs from the current notification state + local recipe_notifications + if recipe_notifications_api then + recipe_notifications = serialize.serialize_crafting_notifications(player) + end + clusterio_api.send_json("inventory_sync_download", { - player_name = player.name + player_name = player.name, + recipe_notifications = recipe_notifications, }) -- If this is a synced player turn them into a spectator while the diff --git a/plugins/inventory_sync/module/serialize.lua b/plugins/inventory_sync/module/serialize.lua index 6d62a6aa..0ef6705e 100644 --- a/plugins/inventory_sync/module/serialize.lua +++ b/plugins/inventory_sync/module/serialize.lua @@ -533,62 +533,55 @@ function serialize.serialize_crafting_notifications(player, failed) recipes[recipe.name] = nil end - -- Convert to a list of names - local recipe_names = failed or {} -- Includes previous recipes if any - local index = #recipe_names + 1 + -- Convert to a list of names, including previously failed recipes if any + local recipe_names = {} + for _, name in ipairs(failed or {}) do + recipe_names[#recipe_names + 1] = name + end for name in pairs(recipes) do - recipe_names[index] = name - index = index + 1 + recipe_names[#recipe_names + 1] = name end return helpers.encode_string(helpers.table_to_json(recipe_names)) end +--- @class CraftingNotificationDelta +--- @field add string[]? Recipes cleared elsewhere that still have a notification here +--- @field remove string[]? Recipes with a notification elsewhere that are cleared here + --- @param player LuaPlayer ---- @param serialized string +--- @param serialized string Encoded delta against the state sent with the download request --- @return string[]? failed Recipe names that do not exist function serialize.deserialize_crafting_notifications(player, serialized) - -- Decode the recipe name strings - local recipe_names = helpers.json_to_table(assert(helpers.decode_string(serialized))) - assert(type(recipe_names) == "table", "wrong type decoded from json_to_table") - local clear_notification = player.clear_recipe_notification - local add_notification = player.add_recipe_notification - local notifications = {} - local failed = {} + --- @type CraftingNotificationDelta + local delta = helpers.json_to_table(assert(helpers.decode_string(serialized))) + assert(type(delta) == "table", "wrong type decoded from json_to_table") - -- Assume all recipes need a notification + -- Recipes which can have a notification on this server + local enabled = {} for _, recipe in pairs(player.force.recipes) do if recipe.enabled and not recipe.hidden then - notifications[recipe.name] = true + enabled[recipe.name] = true end end - -- Remove recipes the player has cleared - for _, recipe_name in pairs(recipe_names) do - if notifications[recipe_name] then - notifications[recipe_name] = false + -- Clear notifications the player has cleared elsewhere, this means "add seen notification" + local failed = {} + for _, recipe_name in pairs(delta.add or {}) do + if enabled[recipe_name] then + player.clear_recipe_notification(recipe_name) else - -- Recipe does not exist on this server - failed[#failed+1] = recipe_name + failed[#failed + 1] = recipe_name -- Recipe does not exist on this server end end - -- Clear ones which are ones which are not required - for _, recipe in pairs(player.get_recipe_notifications()) do - if notifications[recipe.name] == false then - clear_notification(recipe.name) - end - notifications[recipe.name] = nil -- Prevents double add below - end - - -- Add any which the player did not have - for recipe_name, should_add in pairs(notifications) do - if should_add then - add_notification(recipe_name) + -- Add notifications the player has elsewhere, this means "remove seen notification" + for _, recipe_name in pairs(delta.remove or {}) do + if enabled[recipe_name] then + player.add_recipe_notification(recipe_name) end end - -- Return failed recipes if any return next(failed) and failed or nil end diff --git a/plugins/inventory_sync/package.json b/plugins/inventory_sync/package.json index 14c093a0..61476bf4 100644 --- a/plugins/inventory_sync/package.json +++ b/plugins/inventory_sync/package.json @@ -23,6 +23,7 @@ "access": "public" }, + "type": "module", "main": "dist/node/index.js", "scripts": { "prepare": "tsc --build && webpack-cli --mode production", @@ -35,8 +36,15 @@ "peerDependencies": { "@clusterio/controller": "workspace:^", "@clusterio/host": "workspace:^", - "@clusterio/lib": "workspace:^", - "@clusterio/web_ui": "workspace:^" + "@clusterio/lib": "workspace:^" + }, + "peerDependenciesMeta": { + "@clusterio/controller": { + "optional": true + }, + "@clusterio/host": { + "optional": true + } }, "dependencies": { "@sinclair/typebox": "catalog:" diff --git a/plugins/inventory_sync/test/instance.js b/plugins/inventory_sync/test/instance.js new file mode 100644 index 00000000..020f5979 --- /dev/null +++ b/plugins/inventory_sync/test/instance.js @@ -0,0 +1,130 @@ +import assert from "node:assert/strict"; +import util from "node:util"; +import zlib from "node:zlib"; +import * as lib from "@clusterio/lib"; + +import * as mock from "../../../test/mock.js"; +import { recipeNotificationDelta, InstancePlugin } from "../dist/node/instance.js"; +import { DownloadResponse } from "../dist/node/messages.js"; +import { plugin as info } from "../dist/node/index.js"; + +const inflate = util.promisify(zlib.inflate); +const deflate = util.promisify(zlib.deflate); + +// Matches helpers.encode_string in Factorio +async function encode(value) { + return (await deflate(Buffer.from(JSON.stringify(value)))).toString("base64"); +} +async function decode(encoded) { + return JSON.parse((await inflate(Buffer.from(encoded, "base64"))).toString()); +} + +describe("inventory_sync", function() { + describe("recipeNotificationDelta()", function() { + it("should be empty when the lists match", function() { + assert.deepEqual(recipeNotificationDelta(["a", "b"], ["b", "a"]), {}); + }); + it("should add stored names missing from current", function() { + assert.deepEqual(recipeNotificationDelta(["a", "b", "c"], ["a"]), { add: ["b", "c"] }); + }); + it("should remove current names missing from stored", function() { + assert.deepEqual(recipeNotificationDelta(["a"], ["a", "b"]), { remove: ["b"] }); + }); + it("should handle both directions at once", function() { + assert.deepEqual(recipeNotificationDelta(["a", "b"], ["b", "c"]), { add: ["a"], remove: ["c"] }); + }); + it("should keep duplicates in stored out of the current list", function() { + assert.deepEqual(recipeNotificationDelta(["a", "a"], ["a"]), {}); + }); + }); + + describe("InstancePlugin.applyRecipeNotificationDelta()", function() { + const warnings = []; + const plugin = { logger: { warn: msg => warnings.push(msg) } }; + const apply = InstancePlugin.prototype.applyRecipeNotificationDelta.bind(plugin); + + it("should replace the stored list with an encoded delta", async function() { + const playerData = { name: "test", recipe_notifications: await encode(["a", "b"]) }; + await apply(playerData, await encode(["b", "c"])); + assert.deepEqual(await decode(playerData.recipe_notifications), { add: ["a"], remove: ["c"] }); + }); + it("should send an empty delta when nothing changed", async function() { + const playerData = { name: "test", recipe_notifications: await encode(["a"]) }; + await apply(playerData, await encode(["a"])); + assert.deepEqual(await decode(playerData.recipe_notifications), {}); + }); + it("should add everything without a current list", async function() { + const playerData = { name: "test", recipe_notifications: await encode(["a"]) }; + await apply(playerData, undefined); + assert.deepEqual(await decode(playerData.recipe_notifications), { add: ["a"] }); + }); + it("should do nothing without stored notifications", async function() { + const playerData = { name: "test" }; + await apply(playerData, await encode(["a"])); + assert.deepEqual(playerData, { name: "test" }); + }); + it("should drop the field on invalid input", async function() { + const playerData = { name: "test", recipe_notifications: "not base64 zlib" }; + await apply(playerData, await encode(["a"])); + assert.equal(playerData.recipe_notifications, undefined); + assert.equal(warnings.length, 1); + }); + }); + + describe("InstancePlugin.handleDownload()", function() { + let instancePlugin; + before(async function() { + try { + lib.registerPluginMessages([info]); + } catch (err) { + // Already registered by the full test suite + } + instancePlugin = await mock.createInstancePlugin(InstancePlugin, info); + instancePlugin.instance.mockConfigEntries.set("inventory_sync.rcon_chunk_size", 100000); + }); + beforeEach(function() { + instancePlugin.instance.server.reset(); + }); + + function respondWith(playerData) { + instancePlugin.instance.connector.once("send", message => { + instancePlugin.instance.connector.emit("message", new lib.MessageResponse( + 1, message.dst, message.src, new DownloadResponse(playerData) + )); + }); + } + function downloadCommand(playerData) { + const json = lib.escapeString(JSON.stringify(playerData)); + return `/sc inventory_sync.download_inventory('test','${json}',1,1)`; + } + + it("should send an empty download when the controller has no data", async function() { + respondWith(undefined); + await instancePlugin.handleDownload({ player_name: "test" }); + assert.deepEqual( + instancePlugin.instance.server.rconCommands, + ["/sc inventory_sync.download_inventory('test',nil,0,0)"] + ); + }); + it("should send everything as a delta when no snapshot was given", async function() { + respondWith({ generation: 1, name: "test", recipe_notifications: await encode(["a"]) }); + await instancePlugin.handleDownload({ player_name: "test" }); + const expected = { generation: 1, name: "test", recipe_notifications: await encode({ add: ["a"] }) }; + assert.deepEqual(instancePlugin.instance.server.rconCommands, [downloadCommand(expected)]); + }); + it("should send a delta when a snapshot was given", async function() { + respondWith({ generation: 1, name: "test", recipe_notifications: await encode(["a", "b"]) }); + await instancePlugin.handleDownload({ player_name: "test", recipe_notifications: await encode(["b"]) }); + const expected = { generation: 1, name: "test", recipe_notifications: await encode({ add: ["a"] }) }; + assert.deepEqual(instancePlugin.instance.server.rconCommands, [downloadCommand(expected)]); + }); + it("should be invoked by the download ipc", async function() { + respondWith(undefined); + instancePlugin.instance.server.emit("ipc-inventory_sync_download", { player_name: "test" }); + for (let i = 0; i < 100 && !instancePlugin.instance.server.rconCommands.length; i++) { + await new Promise(resolve => setImmediate(resolve)); + } + assert.equal(instancePlugin.instance.server.rconCommands.length, 1); + }); + }); +}); diff --git a/plugins/inventory_sync/tsconfig.browser.json b/plugins/inventory_sync/tsconfig.browser.json index 325fd71d..ba2d8c39 100644 --- a/plugins/inventory_sync/tsconfig.browser.json +++ b/plugins/inventory_sync/tsconfig.browser.json @@ -4,5 +4,5 @@ { "path": "../../packages/lib/tsconfig.browser.json" }, { "path": "../../packages/web_ui/tsconfig.browser.json" }, ], - "include": [ "web/**/*.tsx", "web/**/*.ts", "messages.ts", "package.json" ], + "include": [ "web/**/*.tsx", "web/**/*.ts", "index.ts", "messages.ts", "package.json" ], } diff --git a/plugins/inventory_sync/web/index.tsx b/plugins/inventory_sync/web/index.tsx index f17184e3..21aef86b 100644 --- a/plugins/inventory_sync/web/index.tsx +++ b/plugins/inventory_sync/web/index.tsx @@ -1,7 +1,7 @@ import React, { useContext, useEffect, useState } from "react"; import { BaseWebPlugin, PageLayout, PageHeader, ControlContext } from "@clusterio/web_ui"; -import { DatabaseStatsRequest, DatabaseStatsResponse } from "../messages"; +import { DatabaseStatsRequest, DatabaseStatsResponse } from "../messages.js"; import "./style.css"; diff --git a/plugins/inventory_sync/webpack.config.js b/plugins/inventory_sync/webpack.config.cjs similarity index 100% rename from plugins/inventory_sync/webpack.config.js rename to plugins/inventory_sync/webpack.config.cjs diff --git a/plugins/player_auth/README.md b/plugins/player_auth/README.md index c95e4866..d03b0be8 100644 --- a/plugins/player_auth/README.md +++ b/plugins/player_auth/README.md @@ -29,3 +29,10 @@ Time in seconds the first code generated stays valid. The login must be completed in less than this time starting from when the login dialog is opened in-game. Defaults to `120`. + +#### player_auth.show_connect_address + +Show the address to connect to for each server in the server list on the login page of the web interface, made up of host.public_address and the game port of the instance. +This list is visible to anyone who can reach the web interface, so leave it off if you don't want unauthenticated users to see where the servers are. + +Defaults to `false`. diff --git a/plugins/player_auth/controller.ts b/plugins/player_auth/controller.ts index 02e9c25a..19dcce81 100644 --- a/plugins/player_auth/controller.ts +++ b/plugins/player_auth/controller.ts @@ -4,11 +4,10 @@ import util from "util"; import jwt from "jsonwebtoken"; import { Static } from "@sinclair/typebox"; -import * as lib from "@clusterio/lib"; import { BaseControllerPlugin } from "@clusterio/controller"; -const { basicType } = lib; +import { basicType, RequestError } from "@clusterio/lib"; -import { FetchPlayerCodeRequest, PlayerAuthServer, SetVerifyCodeRequest } from "./messages"; +import { FetchPlayerCodeRequest, PlayerAuthServer, SetVerifyCodeRequest } from "./messages.js"; async function generateCode(length: number): Promise { @@ -207,7 +206,7 @@ export class ControllerPlugin extends BaseControllerPlugin { let entry = this.players.get(player); if (!entry || entry.expiresMs < Date.now()) { - throw new lib.RequestError("invalid player"); + throw new RequestError("invalid player"); } entry.verifyCode = verifyCode; diff --git a/plugins/player_auth/index.ts b/plugins/player_auth/index.ts index f8d860b1..f4f5d6aa 100644 --- a/plugins/player_auth/index.ts +++ b/plugins/player_auth/index.ts @@ -1,5 +1,5 @@ import * as lib from "@clusterio/lib"; -import * as messages from "./messages"; +import * as messages from "./messages.js"; declare module "@clusterio/lib" { export interface InstanceConfigFields { @@ -16,8 +16,8 @@ export const plugin: lib.PluginDeclaration = { name: "player_auth", title: "Player Auth", description: "Provides authentication to the cluster via logging into a Factorio server.", - controllerEntrypoint: "dist/node/controller", - instanceEntrypoint: "dist/node/instance", + controllerEntrypoint: "dist/node/controller.js", + instanceEntrypoint: "dist/node/instance.js", webEntrypoint: "./web", controllerConfigFields: { "player_auth.code_length": { diff --git a/plugins/player_auth/instance.ts b/plugins/player_auth/instance.ts index 780dd1a3..58be8518 100644 --- a/plugins/player_auth/instance.ts +++ b/plugins/player_auth/instance.ts @@ -1,6 +1,6 @@ import { BaseInstancePlugin } from "@clusterio/host"; -import { FetchPlayerCodeRequest, SetVerifyCodeRequest } from "./messages"; +import { FetchPlayerCodeRequest, SetVerifyCodeRequest } from "./messages.js"; type IpcPlayerAuth = { type: "open_dialog", diff --git a/plugins/player_auth/package.json b/plugins/player_auth/package.json index e674f239..a2535d16 100644 --- a/plugins/player_auth/package.json +++ b/plugins/player_auth/package.json @@ -23,6 +23,7 @@ "access": "public" }, + "type": "module", "main": "dist/node/index.js", "scripts": { "prepare": "tsc --build && webpack-cli --mode production", @@ -35,8 +36,15 @@ "peerDependencies": { "@clusterio/controller": "workspace:^", "@clusterio/host": "workspace:^", - "@clusterio/lib": "workspace:^", - "@clusterio/web_ui": "workspace:^" + "@clusterio/lib": "workspace:^" + }, + "peerDependenciesMeta": { + "@clusterio/controller": { + "optional": true + }, + "@clusterio/host": { + "optional": true + } }, "dependencies": { "@sinclair/typebox": "catalog:", diff --git a/plugins/player_auth/test/plugin.js b/plugins/player_auth/test/plugin.js index 35d21e54..590af809 100644 --- a/plugins/player_auth/test/plugin.js +++ b/plugins/player_auth/test/plugin.js @@ -1,15 +1,14 @@ -"use strict"; -const assert = require("assert").strict; -const jwt = require("jsonwebtoken"); +import assert from "node:assert/strict"; +import jwt from "jsonwebtoken"; -const mock = require("../../../test/mock"); +import * as mock from "../../../test/mock.js"; -const controller = require("../dist/node/controller"); -const instance = require("../dist/node/instance"); -const info = require("../dist/node/index").plugin; -const { FetchPlayerCodeRequest, SetVerifyCodeRequest, PlayerAuthServer } = require("../dist/node/messages"); -const { testRoundTripJsonSerialisable, testMatrix } = require("../../../test/common"); -const lib = require("@clusterio/lib"); +import * as controller from "../dist/node/controller.js"; +import * as instance from "../dist/node/instance.js"; +import { plugin as info } from "../dist/node/index.js"; +import { FetchPlayerCodeRequest, SetVerifyCodeRequest, PlayerAuthServer } from "../dist/node/messages.js"; +import { testRoundTripJsonSerialisable, testMatrix } from "../../../test/common.js"; +import * as lib from "@clusterio/lib"; function postJSON(url, body) { diff --git a/plugins/player_auth/web/index.tsx b/plugins/player_auth/web/index.tsx index 1c5cd20a..17cb7531 100644 --- a/plugins/player_auth/web/index.tsx +++ b/plugins/player_auth/web/index.tsx @@ -3,7 +3,7 @@ import React, { useEffect, useState } from "react"; import { BaseWebPlugin, notifyErrorHandler } from "@clusterio/web_ui"; import { Alert, Button, Form, Input, Modal, Space, Spin, Typography } from "antd"; -import { PlayerAuthServer } from "../messages"; +import { PlayerAuthServer } from "../messages.js"; import "./style.css"; const { Paragraph, Text } = Typography; diff --git a/plugins/player_auth/webpack.config.js b/plugins/player_auth/webpack.config.cjs similarity index 100% rename from plugins/player_auth/webpack.config.js rename to plugins/player_auth/webpack.config.cjs diff --git a/plugins/research_sync/controller.ts b/plugins/research_sync/controller.ts index 498e2a45..954e5f70 100644 --- a/plugins/research_sync/controller.ts +++ b/plugins/research_sync/controller.ts @@ -4,7 +4,7 @@ import { BaseControllerPlugin } from "@clusterio/controller"; import { Static } from "@sinclair/typebox"; import * as lib from "@clusterio/lib"; -const { RateLimiter } = lib; +import { RateLimiter } from "@clusterio/lib"; import { ContributionEvent, @@ -13,7 +13,7 @@ import { TechnologySync, SyncTechnologiesRequest, TechnologyProgress, -} from "./messages"; +} from "./messages.js"; type Technology = { level: number, diff --git a/plugins/research_sync/index.ts b/plugins/research_sync/index.ts index 85afabdc..33a0967a 100644 --- a/plugins/research_sync/index.ts +++ b/plugins/research_sync/index.ts @@ -1,13 +1,13 @@ import type * as lib from "@clusterio/lib"; -import * as messages from "./messages"; +import * as messages from "./messages.js"; export const plugin: lib.PluginDeclaration = { name: "research_sync", title: "Research Sync", description: "Synchronises technology research progress between instances.", - instanceEntrypoint: "dist/node/instance", - controllerEntrypoint: "dist/node/controller", + instanceEntrypoint: "dist/node/instance.js", + controllerEntrypoint: "dist/node/controller.js", features: [ "SavePatching", diff --git a/plugins/research_sync/instance.ts b/plugins/research_sync/instance.ts index 5ce28907..5e254d86 100644 --- a/plugins/research_sync/instance.ts +++ b/plugins/research_sync/instance.ts @@ -6,7 +6,7 @@ import { FinishedEvent, TechnologySync, SyncTechnologiesRequest, -} from "./messages"; +} from "./messages.js"; // ./module/sync.lua type IpcContribution = { diff --git a/plugins/research_sync/package.json b/plugins/research_sync/package.json index b21ebf5f..e491db89 100644 --- a/plugins/research_sync/package.json +++ b/plugins/research_sync/package.json @@ -23,6 +23,7 @@ "access": "public" }, + "type": "module", "main": "dist/node/index.js", "scripts": { "prepare": "tsc --build && webpack-cli --mode production", @@ -35,8 +36,15 @@ "peerDependencies": { "@clusterio/controller": "workspace:^", "@clusterio/host": "workspace:^", - "@clusterio/lib": "workspace:^", - "@clusterio/web_ui": "workspace:^" + "@clusterio/lib": "workspace:^" + }, + "peerDependenciesMeta": { + "@clusterio/controller": { + "optional": true + }, + "@clusterio/host": { + "optional": true + } }, "dependencies": { "@sinclair/typebox": "catalog:" diff --git a/plugins/research_sync/test/missing.js b/plugins/research_sync/test/missing.js index ae16e8f1..4d585e2b 100644 --- a/plugins/research_sync/test/missing.js +++ b/plugins/research_sync/test/missing.js @@ -1,6 +1,5 @@ // These are libraries that does not have any units test, they are // required here in order to track coverage for them. -"use strict"; -require("../dist/node/instance"); -require("../dist/node/controller"); +import "../dist/node/instance.js"; +import "../dist/node/controller.js"; diff --git a/plugins/research_sync/webpack.config.js b/plugins/research_sync/webpack.config.cjs similarity index 100% rename from plugins/research_sync/webpack.config.js rename to plugins/research_sync/webpack.config.cjs diff --git a/plugins/statistics_exporter/index.ts b/plugins/statistics_exporter/index.ts index 6e769973..f9a38150 100644 --- a/plugins/statistics_exporter/index.ts +++ b/plugins/statistics_exporter/index.ts @@ -12,7 +12,7 @@ export const plugin: lib.PluginDeclaration = { description: "Provides in-game item/fluid production, builds, kills, and pollution "+ "statistics to the cluster's Prometheus endpoint.", - instanceEntrypoint: "dist/node/instance", + instanceEntrypoint: "dist/node/instance.js", instanceConfigFields: { "statistics_exporter.command_timeout": { title: "Command Timeout", diff --git a/plugins/statistics_exporter/instance.ts b/plugins/statistics_exporter/instance.ts index c76bf1f0..82382ada 100644 --- a/plugins/statistics_exporter/instance.ts +++ b/plugins/statistics_exporter/instance.ts @@ -1,7 +1,7 @@ import * as lib from "@clusterio/lib"; import { BaseInstancePlugin } from "@clusterio/host"; -const { Gauge } = lib; +import { Gauge } from "@clusterio/lib"; type IpcStats = { game_tick: number, diff --git a/plugins/statistics_exporter/package.json b/plugins/statistics_exporter/package.json index 68c2a4a0..05276508 100644 --- a/plugins/statistics_exporter/package.json +++ b/plugins/statistics_exporter/package.json @@ -23,6 +23,7 @@ "access": "public" }, + "type": "module", "main": "dist/node/index.js", "scripts": { "prepare": "tsc --build && webpack-cli --mode production", @@ -34,8 +35,15 @@ "peerDependencies": { "@clusterio/host": "workspace:^", - "@clusterio/lib": "workspace:^", - "@clusterio/web_ui": "workspace:^" + "@clusterio/lib": "workspace:^" + }, + "peerDependenciesMeta": { + "@clusterio/controller": { + "optional": true + }, + "@clusterio/host": { + "optional": true + } }, "devDependencies": { "@clusterio/web_ui": "workspace:^", diff --git a/plugins/statistics_exporter/test/plugin.js b/plugins/statistics_exporter/test/plugin.js index 6c0393e1..49f1439e 100644 --- a/plugins/statistics_exporter/test/plugin.js +++ b/plugins/statistics_exporter/test/plugin.js @@ -1,9 +1,8 @@ -"use strict"; -const assert = require("assert").strict; +import assert from "node:assert/strict"; -const mock = require("../../../test/mock"); -const instance = require("../dist/node/instance"); -const info = require("../dist/node/index").plugin; +import * as mock from "../../../test/mock.js"; +import * as instance from "../dist/node/instance.js"; +import { plugin as info } from "../dist/node/index.js"; describe("statistics_exporter plugin", function() { diff --git a/plugins/statistics_exporter/webpack.config.js b/plugins/statistics_exporter/webpack.config.cjs similarity index 100% rename from plugins/statistics_exporter/webpack.config.js rename to plugins/statistics_exporter/webpack.config.cjs diff --git a/plugins/subspace_storage/README.md b/plugins/subspace_storage/README.md index 6f1ed199..5815cab3 100644 --- a/plugins/subspace_storage/README.md +++ b/plugins/subspace_storage/README.md @@ -11,3 +11,32 @@ Run the following commands in the folder Clusterio is installed to: npx clusteriocontroller plugin add @clusterio/plugin-subspace_storage Substitute clusteriocontroller with clusteriohost or clusterioctl if this a dedicate host or ctl installation respectively. + + +## Controller Configuration + +### subspace_storage.division_method + +How to divide items between instances when there isn't enough in storage to give everyone what they ask for. +One of `simple`, `dole` or `neural_dole`. +With `simple` requests are served in full on a first come first served basis until the storage runs out. +With `dole` a per item division factor is kept that halves the amount handed out each time a request can't be met and slowly recovers when requests succeed, which spreads the remaining items between instances at the cost of draining the last of an item slowly. +`neural_dole` does the same with a small neural network estimating a fair share from the recent request history. + +Defaults to `simple`. + + +### subspace_storage.log_item_transfers + +Log every item transfer in and out of the storage to the controller console at verbose level. + +Defaults to `false`. + + +## Instance Configuration + +### subspace_storage.log_item_transfers + +Log every item transfer in and out of the storage to the host console at verbose level. + +Defaults to `false`. diff --git a/plugins/subspace_storage/controller.ts b/plugins/subspace_storage/controller.ts index 4630d1a5..32f990cc 100644 --- a/plugins/subspace_storage/controller.ts +++ b/plugins/subspace_storage/controller.ts @@ -4,10 +4,10 @@ import fs from "node:fs/promises"; import path from "path"; import * as lib from "@clusterio/lib"; -const { Counter, Gauge } = lib; +import { Counter, Gauge } from "@clusterio/lib"; -import * as routes from "./routes"; -import * as dole from "./dole"; +import * as routes from "./routes.js"; +import * as dole from "./dole.js"; import { Item, @@ -16,7 +16,7 @@ import { GetStorageRequest, UpdateStorageEvent, SetStorageSubscriptionRequest, -} from "./messages"; +} from "./messages.js"; const exportCounter = new Counter( diff --git a/plugins/subspace_storage/dole.ts b/plugins/subspace_storage/dole.ts index 4060f424..f01f20e9 100644 --- a/plugins/subspace_storage/dole.ts +++ b/plugins/subspace_storage/dole.ts @@ -1,7 +1,7 @@ -import * as doleNN from "./dole_nn_base"; +import * as doleNN from "./dole_nn_base.js"; import * as lib from "@clusterio/lib"; -const { Gauge } = lib; +import { Gauge } from "@clusterio/lib"; const prometheusNNDoleGauge = new Gauge( diff --git a/plugins/subspace_storage/index.ts b/plugins/subspace_storage/index.ts index 300c8895..bdbecf03 100644 --- a/plugins/subspace_storage/index.ts +++ b/plugins/subspace_storage/index.ts @@ -1,5 +1,5 @@ import * as lib from "@clusterio/lib"; -import * as messages from "./messages"; +import * as messages from "./messages.js"; declare module "@clusterio/lib" { export interface ControllerConfigFields { @@ -9,20 +9,16 @@ declare module "@clusterio/lib" { export interface InstanceConfigFields { "subspace_storage.log_item_transfers": boolean; } + export interface Permissions { + "subspace_storage.storage.view": never; + } } -lib.definePermission({ - name: "subspace_storage.storage.view", - title: "View Subspace Storage", - description: "View the items and fluids stored in the shared subspace.", - grantByDefault: true, -}); - export const plugin: lib.PluginDeclaration = { name: "subspace_storage", title: "Subspace Storage", description: "Provides shared storage across instances for the Subspace Storage mod", - instanceEntrypoint: "dist/node/instance", + instanceEntrypoint: "dist/node/instance.js", instanceConfigFields: { "subspace_storage.log_item_transfers": { title: "Log Item Transfers", @@ -32,7 +28,7 @@ export const plugin: lib.PluginDeclaration = { }, }, - controllerEntrypoint: "dist/node/controller", + controllerEntrypoint: "dist/node/controller.js", controllerConfigFields: { "subspace_storage.division_method": { title: "Division Method", @@ -60,6 +56,14 @@ export const plugin: lib.PluginDeclaration = { messages.UpdateStorageEvent, messages.SetStorageSubscriptionRequest, ], + permissions: [ + { + name: "subspace_storage.storage.view", + title: "View Subspace Storage", + description: "View the items and fluids stored in the shared subspace.", + grantByDefault: true, + }, + ], webEntrypoint: "./web", routes: ["/storage"], }; diff --git a/plugins/subspace_storage/instance.ts b/plugins/subspace_storage/instance.ts index 4de15afb..87ab957f 100644 --- a/plugins/subspace_storage/instance.ts +++ b/plugins/subspace_storage/instance.ts @@ -7,7 +7,7 @@ import { GetStorageRequest, UpdateStorageEvent, Item, -} from "./messages"; +} from "./messages.js"; type IpcItems = [name: string, count: number, quality: string][]; diff --git a/plugins/subspace_storage/package.json b/plugins/subspace_storage/package.json index 3b77dec0..2ddaa3f9 100644 --- a/plugins/subspace_storage/package.json +++ b/plugins/subspace_storage/package.json @@ -23,6 +23,7 @@ "access": "public" }, + "type": "module", "main": "dist/node/index.js", "scripts": { "prepare": "tsc --build && webpack-cli --mode production", @@ -35,8 +36,15 @@ "peerDependencies": { "@clusterio/controller": "workspace:^", "@clusterio/host": "workspace:^", - "@clusterio/lib": "workspace:^", - "@clusterio/web_ui": "workspace:^" + "@clusterio/lib": "workspace:^" + }, + "peerDependenciesMeta": { + "@clusterio/controller": { + "optional": true + }, + "@clusterio/host": { + "optional": true + } }, "dependencies": { "@sinclair/typebox": "catalog:" diff --git a/plugins/subspace_storage/test/missing.js b/plugins/subspace_storage/test/missing.js index ab3009d7..e01532ac 100644 --- a/plugins/subspace_storage/test/missing.js +++ b/plugins/subspace_storage/test/missing.js @@ -1,8 +1,7 @@ // These are libraries that does not have any units test, they are // required here in order to track coverage for them. -"use strict"; -require("../dist/node/instance"); -require("../dist/node/controller"); -require("../dist/node/dole"); -require("../dist/node/dole_nn_base"); +import "../dist/node/instance.js"; +import "../dist/node/controller.js"; +import "../dist/node/dole.js"; +import "../dist/node/dole_nn_base.js"; diff --git a/plugins/subspace_storage/test/routes.js b/plugins/subspace_storage/test/routes.js index fc4aac5b..3d8b0afd 100644 --- a/plugins/subspace_storage/test/routes.js +++ b/plugins/subspace_storage/test/routes.js @@ -1,8 +1,7 @@ -"use strict"; -const assert = require("assert").strict; +import assert from "node:assert/strict"; -const routes = require("../dist/node/routes"); -const { get } = require("../../../test/integration"); +import * as routes from "../dist/node/routes.js"; +import { get } from "../../../test/integration/index.js"; describe("subspace_storage endpoints", function() { diff --git a/plugins/subspace_storage/tsconfig.browser.json b/plugins/subspace_storage/tsconfig.browser.json index 325fd71d..ba2d8c39 100644 --- a/plugins/subspace_storage/tsconfig.browser.json +++ b/plugins/subspace_storage/tsconfig.browser.json @@ -4,5 +4,5 @@ { "path": "../../packages/lib/tsconfig.browser.json" }, { "path": "../../packages/web_ui/tsconfig.browser.json" }, ], - "include": [ "web/**/*.tsx", "web/**/*.ts", "messages.ts", "package.json" ], + "include": [ "web/**/*.tsx", "web/**/*.ts", "index.ts", "messages.ts", "package.json" ], } diff --git a/plugins/subspace_storage/web/index.tsx b/plugins/subspace_storage/web/index.tsx index 2ead807c..2b91ca61 100644 --- a/plugins/subspace_storage/web/index.tsx +++ b/plugins/subspace_storage/web/index.tsx @@ -6,7 +6,7 @@ import { useExportLocale, useExportPrototypeMetadata, useDefaultModPack, FactorioIcon, useTableQueryState, useColumnSearch, } from "@clusterio/web_ui"; -import { GetStorageRequest, Item, SetStorageSubscriptionRequest, UpdateStorageEvent } from "../messages"; +import { GetStorageRequest, Item, SetStorageSubscriptionRequest, UpdateStorageEvent } from "../messages.js"; import "./style.css"; diff --git a/plugins/subspace_storage/webpack.config.js b/plugins/subspace_storage/webpack.config.cjs similarity index 100% rename from plugins/subspace_storage/webpack.config.js rename to plugins/subspace_storage/webpack.config.cjs diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index fefcb0b9..949418cc 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -11,7 +11,7 @@ catalog: '@types/express': ^4.17.17 '@types/finalhandler': ^1.2.0 '@types/jsonwebtoken': ^9.0.9 - '@types/node': ^20.17.19 + '@types/node': ^22.20.1 '@types/react': ^18.3.18 '@types/set-blocking': ^2.0.0 '@types/ws': ^8.5.14 @@ -26,9 +26,9 @@ catalog: react-router-dom: ^6.29.0 set-blocking: ^2.0.0 typescript: ^5.7.3 - webpack: ^5.98.0 - webpack-cli: ^5.1.4 - webpack-merge: ^5.9.0 + webpack: ^5.110.3 + webpack-cli: ^7.2.3 + webpack-merge: 5.9.0 winston: ^3.17.0 winston-daily-rotate-file: ^4.7.1 winston-transport: ^4.9.0 @@ -38,4 +38,7 @@ catalogMode: strict linkWorkspacePackages: true +onlyBuiltDependencies: + - '@swc/core' + shellEmulator: true diff --git a/publish.js b/publish.js index 80c81a7c..90aaabaf 100644 --- a/publish.js +++ b/publish.js @@ -2,10 +2,9 @@ * Publish any mods that need to be published */ -"use strict"; -const FormData = require("form-data"); -const fs = require("node:fs/promises"); -const yargs = require("yargs"); +import FormData from "form-data"; +import fs from "node:fs/promises"; +import yargs from "yargs"; let DRY = false; @@ -129,6 +128,6 @@ async function main() { } // Run main if started from command line -if (module === require.main) { +if (import.meta.main) { main(); } diff --git a/scripts/version-compat-test.js b/scripts/version-compat-test.js index d4055900..1e7c9250 100644 --- a/scripts/version-compat-test.js +++ b/scripts/version-compat-test.js @@ -19,16 +19,16 @@ * The repository must be built (pnpm run build) before running with a local side. */ "use strict"; -const child_process = require("child_process"); -const events = require("events"); -const fs = require("fs/promises"); -const path = require("path"); -const readline = require("readline"); -const util = require("util"); +import child_process from "node:child_process"; +import events from "node:events"; +import fs from "node:fs/promises"; +import path from "node:path"; +import readline from "node:readline"; +import util from "node:util"; const execFile = util.promisify(child_process.execFile); -const repoRoot = path.join(__dirname, ".."); +const repoRoot = path.join(import.meta.dirname, ".."); const workDir = path.join(repoRoot, "temp", "compat-test"); const controllerDir = path.join(workDir, "controller"); const hostDir = path.join(workDir, "host"); diff --git a/test/common.js b/test/common.js index eb75a3f2..b35116d9 100644 --- a/test/common.js +++ b/test/common.js @@ -1,6 +1,5 @@ -"use strict"; -const assert = require("assert").strict; -const { compile } = require("@clusterio/lib"); +import assert from "node:assert/strict"; +import { compile } from "@clusterio/lib"; /** * Generate a flat array of tests from a matrix of inputs. @@ -10,7 +9,7 @@ const { compile } = require("@clusterio/lib"); * @returns {Array<{ [K in keyof T]: T[K][number] }>} * An array of tuples, each containing one value from each input array. */ -function testMatrix(...arrays) { +export function testMatrix(...arrays) { return arrays.reduce((acc, curr) => acc.flatMap(a => curr.map(b => [...a, b])), [[]]); } @@ -21,7 +20,7 @@ function testMatrix(...arrays) { * @param {{new(...args: T): object}} Class - The class which has toJSON and fromJSON methods. * @param {T[]} tests - The tests inputs to pass to the class constructor. */ -function testRoundTripJsonSerialisable(Class, tests) { +export function testRoundTripJsonSerialisable(Class, tests) { const validate = compile(Class.jsonSchema); for (const test of tests) { const original = new Class(...test); @@ -34,8 +33,3 @@ function testRoundTripJsonSerialisable(Class, tests) { } } } - -module.exports = { - testMatrix, - testRoundTripJsonSerialisable, -}; diff --git a/test/controller/BaseControllerPlugin.js b/test/controller/BaseControllerPlugin.js index b4dda949..6f1a31d5 100644 --- a/test/controller/BaseControllerPlugin.js +++ b/test/controller/BaseControllerPlugin.js @@ -1,22 +1,59 @@ -"use strict"; -const mock = require("../mock"); -const controller = require("@clusterio/controller"); +import assert from "node:assert/strict"; + +import * as mock from "../mock.js"; +import * as controller from "@clusterio/controller"; describe("controller/src/BaseControllerPlugin", function() { describe("class BaseControllerPlugin", function() { - let controllerPlugin; - it("should be constructible", async function() { - controllerPlugin = new controller.BaseControllerPlugin({}, {}, {}, new mock.MockLogger()); - await controllerPlugin.init(); + const info = { name: "test" }; + let mockController; + beforeEach(function() { + mockController = new mock.MockController(); }); - it("should define defaults for hooks", async function() { - await controllerPlugin.onInstanceStatusChanged({}, "running", "initialized"); - await controllerPlugin.onControllerConfigFieldChanged({}, "foo", null); - await controllerPlugin.onInstanceConfigFieldChanged({}, {}, "foo", null); - await controllerPlugin.onMetrics(); - await controllerPlugin.onShutdown(); - controllerPlugin.onHostConnectionEvent({}, "connect"); - await controllerPlugin.onPrepareHostDisconnect({}); + + it("should be constructible", async function() { + const plugin = new controller.BaseControllerPlugin(info, mockController, {}, new mock.MockLogger()); + await plugin.init(); + assert.equal(mockController.hooks.size, 0); + }); + it("should be constructible from a load context", async function() { + const logger = new mock.MockLogger(); + const plugin = controller.BaseControllerPlugin.fromContext({ + plugin: info, controller: mockController, metrics: {}, logger, + }); + assert.equal(plugin.info, info); + assert.equal(plugin.controller, mockController); + assert.equal(plugin.logger, logger); + }); + it("should attach overridden hooks", async function() { + let calls = []; + class ControllerPlugin extends controller.BaseControllerPlugin { + async onSaveData() { calls.push(["save", this]); } + async onInstanceStatusChanged(instance, prev) { calls.push(["status", instance, prev]); } + onHostConnectionEvent(connection, event) { calls.push(["host", connection, event]); } + } + const plugin = new ControllerPlugin(info, mockController, {}, new mock.MockLogger()); + assert.deepEqual([...mockController.hooks.attached], ["test"]); + assert.equal(mockController.hooks.shutdown.size, 0); + + await mockController.hooks.save.invoke(); + await mockController.hooks.instanceStatusChanged.invoke("instance", "running"); + await mockController.hooks.hostConnectionEvent.invoke("connection", "connect"); + assert.deepEqual(calls, [ + ["save", plugin], + ["status", "instance", "running"], + ["host", "connection", "connect"], + ]); + }); + it("should detach hooks", async function() { + class ControllerPlugin extends controller.BaseControllerPlugin { + async onSaveData() { } + async onShutdown() { } + } + const plugin = new ControllerPlugin(info, mockController, {}, new mock.MockLogger()); + assert.equal(mockController.hooks.size, 1); + plugin.detachHooks(); + assert.equal(mockController.hooks.size, 0); }); }); }); diff --git a/test/controller/ControlConnection.test.js b/test/controller/ControlConnection.test.js index 90483a08..2ad0db9d 100644 --- a/test/controller/ControlConnection.test.js +++ b/test/controller/ControlConnection.test.js @@ -1,6 +1,5 @@ -"use strict"; -const assert = require("assert").strict; -const { ControlConnection } = require("@clusterio/controller"); +import assert from "node:assert/strict"; +import { ControlConnection } from "@clusterio/controller"; describe("controller/src/ControlConnection", function() { describe(".handleControllerRestartRequest()", function() { diff --git a/test/controller/Controller.js b/test/controller/Controller.js index 35e6db9c..d2f014ca 100644 --- a/test/controller/Controller.js +++ b/test/controller/Controller.js @@ -1,16 +1,16 @@ -"use strict"; -const path = require("node:path"); -const assert = require("assert").strict; -const { Controller, HostRecord, InstanceRecord, UserRecord } = require("@clusterio/controller"); -const { EventEmitter } = require("stream"); -const events = require("node:events"); -const http = require("node:http"); -const express = require("express"); +import path from "node:path"; +import assert from "node:assert/strict"; +import { Controller, HostRecord, InstanceRecord, UserRecord } from "@clusterio/controller"; +import events, { EventEmitter } from "node:events"; +import http from "node:http"; +import express from "express"; +import { fileURLToPath } from "node:url"; -const { +import { ControllerConfig, Address, RequestError, InstanceConfig, SystemInfo, Role, ModPack, -} = require("@clusterio/lib"); + addPluginConfigFields, +} from "@clusterio/lib"; class MockEvent {} @@ -32,10 +32,12 @@ class MockInstanceConfig extends EventEmitter { } describe("controller/src/Controller", function() { - describe("class Controller", function() { + describe("class Controller", async function() { /** @type {Controller} */ let controller, mockInstanceConfig; - const controllerVersion = require("@clusterio/controller/package.json").version; + const controllerVersion = ( + await import("@clusterio/controller/package.json", { with: { type: "json" }}) + ).default.version; before(async function() { const controllerConfig = new ControllerConfig("controller", { "controller.version": controllerVersion }); controller = new Controller({}, [], controllerConfig); @@ -234,14 +236,17 @@ describe("controller/src/Controller", function() { }); }); describe(".checkRestartRequired()", function() { + before(function() { + addPluginConfigFields([{ name: "restart_test" }]); + }); beforeEach(function() { // Would not be needed if controller setup was beforeEach - controller.config.restartRequired = false; controller.config.set("controller.version", controllerVersion); - controller.config.set("global_chat.load_plugin", true); + controller.config.set("restart_test.load_plugin", true); + controller.config.restartRequired = false; controller.pluginInfos[0] = { - name: "global_chat", - requirePath: path.dirname(require.resolve("@clusterio/controller/package.json")), + name: "restart_test", + packagePath: fileURLToPath(import.meta.resolve("@clusterio/controller/package.json")), version: controllerVersion, }; }); @@ -256,7 +261,7 @@ describe("controller/src/Controller", function() { controller.pluginInfos[0].version = "0.0.0"; controller.pluginInfos[0].webEntrypoint = true; controller.pluginInfos[0].controllerEntrypoint = true; - controller.config.set("global_chat.load_plugin", false); + controller.config.set("restart_test.load_plugin", false); controller.config.restartRequired = false; // Setting load_plugin requires a restart const result = await controller.checkRestartRequired(); assert.equal(result, false); diff --git a/test/controller/ControllerUser.js b/test/controller/ControllerUser.js index 12f789c3..8897e474 100644 --- a/test/controller/ControllerUser.js +++ b/test/controller/ControllerUser.js @@ -1,7 +1,6 @@ -"use strict"; -const assert = require("assert").strict; -const lib = require("@clusterio/lib"); -const { User } = require("@clusterio/controller"); +import assert from "node:assert/strict"; +import * as lib from "@clusterio/lib"; +import { User } from "@clusterio/controller"; describe("controller/src/User", function() { describe("class User", function() { diff --git a/test/controller/HostRecord.test.js b/test/controller/HostRecord.test.js index 60ca74de..687497e4 100644 --- a/test/controller/HostRecord.test.js +++ b/test/controller/HostRecord.test.js @@ -1,9 +1,8 @@ -"use strict"; -const assert = require("assert").strict; -const { HostDetails } = require("@clusterio/lib"); -const { HostRecord } = require("@clusterio/controller"); +import assert from "node:assert/strict"; +import { HostDetails } from "@clusterio/lib"; +import { HostRecord } from "@clusterio/controller"; -const { testMatrix, testRoundTripJsonSerialisable } = require("../common"); +import { testMatrix, testRoundTripJsonSerialisable } from "../common.js"; describe("controller/HostRecord", function () { const samplePlugins = new Map([ diff --git a/test/controller/HttpCloser.js b/test/controller/HttpCloser.js index d7bcadd4..f072d051 100644 --- a/test/controller/HttpCloser.js +++ b/test/controller/HttpCloser.js @@ -1,12 +1,11 @@ -"use strict"; -const assert = require("assert").strict; -const fs = require("node:fs/promises"); -const http = require("http"); -const https = require("https"); -const util = require("util"); -const events = require("node:events"); +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import http from "node:http"; +import https from "node:https"; +import util from "node:util"; +import events from "node:events"; -const HttpCloser = require("@clusterio/controller/dist/node/src/HttpCloser").default; +import HttpCloser from "@clusterio/controller/dist/node/src/HttpCloser.js"; // Time to wait during async operations to ensure they happened in order. const tick = 20; diff --git a/test/controller/InstanceManager.test.js b/test/controller/InstanceManager.test.js index 312cb402..59e05063 100644 --- a/test/controller/InstanceManager.test.js +++ b/test/controller/InstanceManager.test.js @@ -1,8 +1,7 @@ -"use strict"; -const assert = require("assert").strict; -const lib = require("@clusterio/lib"); +import assert from "node:assert/strict"; +import * as lib from "@clusterio/lib"; -const { Controller, InstanceRecord, InstanceManager } = require("@clusterio/controller"); +import { Controller, InstanceRecord, InstanceManager } from "@clusterio/controller"; describe("controller/InstanceManager", function () { /** @type {Controller} */ diff --git a/test/controller/InstanceRecord.test.js b/test/controller/InstanceRecord.test.js index c3c169c5..7b496bad 100644 --- a/test/controller/InstanceRecord.test.js +++ b/test/controller/InstanceRecord.test.js @@ -1,9 +1,8 @@ -"use strict"; -const assert = require("assert").strict; -const lib = require("@clusterio/lib"); -const { InstanceRecord } = require("@clusterio/controller"); +import assert from "node:assert/strict"; +import * as lib from "@clusterio/lib"; +import { InstanceRecord } from "@clusterio/controller"; -const { testMatrix, testRoundTripJsonSerialisable } = require("../common"); +import { testMatrix, testRoundTripJsonSerialisable } from "../common.js"; describe("controller/InstanceRecord", function () { /** @type {lib.InstanceConfig} */ diff --git a/test/controller/User.test.js b/test/controller/User.test.js index cf605280..a04bbdc0 100644 --- a/test/controller/User.test.js +++ b/test/controller/User.test.js @@ -1,7 +1,6 @@ -"use strict"; -const assert = require("assert").strict; -const { Role, SubscribableDatastore } = require("@clusterio/lib"); -const { User } = require("@clusterio/controller"); +import assert from "node:assert/strict"; +import { Role, SubscribableDatastore } from "@clusterio/lib"; +import { User } from "@clusterio/controller"; describe("controller/User", function () { /** @type {SubscribableDatastore} */ diff --git a/test/controller/UserManager.test.js b/test/controller/UserManager.test.js index 8312c3b7..5005875e 100644 --- a/test/controller/UserManager.test.js +++ b/test/controller/UserManager.test.js @@ -1,10 +1,9 @@ -"use strict"; -const path = require("path"); -const fs = require("node:fs/promises"); -const assert = require("assert").strict; -const jwt = require("jsonwebtoken"); -const { Role, SubscribableDatastore, ControllerConfig } = require("@clusterio/lib"); -const { UserRecord, UserManager } = require("@clusterio/controller"); +import path from "node:path"; +import fs from "node:fs/promises"; +import assert from "node:assert/strict"; +import jwt from "jsonwebtoken"; +import { Role, SubscribableDatastore, ControllerConfig } from "@clusterio/lib"; +import { UserRecord, UserManager } from "@clusterio/controller"; const TEMP_DIR = path.join("temp", "test", "UserManager"); diff --git a/test/controller/UserRecord.test.js b/test/controller/UserRecord.test.js index fe7c6ccb..c4e2eee9 100644 --- a/test/controller/UserRecord.test.js +++ b/test/controller/UserRecord.test.js @@ -1,9 +1,8 @@ -"use strict"; -const assert = require("assert").strict; -const { UserDetails, PlayerStats } = require("@clusterio/lib"); -const { UserRecord } = require("@clusterio/controller"); +import assert from "node:assert/strict"; +import { UserDetails, PlayerStats } from "@clusterio/lib"; +import { UserRecord } from "@clusterio/controller"; -const { testMatrix, testRoundTripJsonSerialisable } = require("../common"); +import { testMatrix, testRoundTripJsonSerialisable } from "../common.js"; describe("controller/UserRecord", function () { it("should be round trip serialisable", function () { diff --git a/test/controller/WsServer.js b/test/controller/WsServer.js index 7501ec59..3b97fe40 100644 --- a/test/controller/WsServer.js +++ b/test/controller/WsServer.js @@ -1,7 +1,6 @@ -"use strict"; -const assert = require("assert").strict; -const { Controller, WsServer } = require("@clusterio/controller"); -const { ControllerConfig } = require("@clusterio/lib"); +import assert from "node:assert/strict"; +import { Controller, WsServer } from "@clusterio/controller"; +import { ControllerConfig } from "@clusterio/lib"; describe("controller/src/WsServer", function() { describe("class WsServer", function() { diff --git a/test/controller/routes.js b/test/controller/routes.js index 5087e97b..7f122047 100644 --- a/test/controller/routes.js +++ b/test/controller/routes.js @@ -1,12 +1,11 @@ -"use strict"; -const assert = require("assert").strict; -const events = require("events"); -const FormData = require("form-data"); -const http = require("http"); +import assert from "node:assert/strict"; +import events from "node:events"; +import FormData from "form-data"; +import http from "node:http"; -const { wait } = require("@clusterio/lib"); -const routes = require("@clusterio/controller/dist/node/src/routes"); -const mock = require("../mock"); +import { wait } from "@clusterio/lib"; +import * as routes from "@clusterio/controller/dist/node/src/routes.js"; +import * as mock from "../mock.js"; describe("controller/src/routes", function() { let controller; @@ -86,7 +85,6 @@ describe("controller/src/routes", function() { let endpoint; beforeEach(function() { endpoint = `http://localhost:${port}/api/plugins`; - controller.plugins = new Map([["foo", {}]]); controller.mockConfigEntries.set("foo.load_plugin", true); }); it("should strip static/ from the plugin bundle path", async function() { @@ -94,6 +92,7 @@ describe("controller/src/routes", function() { name: "foo", version: "1.0.0", npmPackage: "foo", manifest: { "foo.js": "static/foo.abc.js" }, }]; + controller.loadedPlugins = new Set(controller.pluginInfos); let response = await fetch(endpoint); assert.equal(response.status, 200); assert.deepEqual(await response.json(), [{ @@ -103,10 +102,11 @@ describe("controller/src/routes", function() { }); it("should report missing manifest and entrypoint", async function() { controller.pluginInfos = [ - { name: "foo", version: "1.0.0", npmPackage: "foo" }, + { name: "foo", version: "1.0.0", npmPackage: "foo", webEntrypoint: "dist/web/index.js" }, { name: "bar", version: "1.0.0", npmPackage: "bar", manifest: {} }, { name: "baz", version: "1.0.0", npmPackage: "baz", manifest: { "baz.js": "remoteEntry.js" } }, ]; + controller.loadedPlugins = new Set([controller.pluginInfos[0]]); let response = await fetch(endpoint); assert.equal(response.status, 200); let data = await response.json(); @@ -116,6 +116,19 @@ describe("controller/src/routes", function() { assert.equal(data[1].loaded, false); assert.equal(data[1].enabled, false); }); + it("should not report a missing manifest for plugins without a web build", async function() { + controller.pluginInfos = [ + { name: "foo", version: "1.0.0", npmPackage: "foo", hostEntrypoint: "dist/node/host.js" }, + { name: "bar", version: "1.0.0", npmPackage: "bar", controllerEntrypoint: "dist/node/controller.js" }, + { name: "baz", version: "1.0.0", npmPackage: "baz", instanceConfigFields: {} }, + ]; + let response = await fetch(endpoint); + assert.equal(response.status, 200); + let data = await response.json(); + assert.deepEqual(data[0].web, {}); + assert.equal(data[1].web.error, "Missing dist/web/manifest.json"); + assert.equal(data[2].web.error, "Missing dist/web/manifest.json"); + }); }); describe("/api/cluster-name", function() { let endpoint; diff --git a/test/create/echo_args.js b/test/create/echo_args.js index 1319b1cf..ecc1f2a1 100644 --- a/test/create/echo_args.js +++ b/test/create/echo_args.js @@ -1,4 +1,3 @@ -"use strict"; -if (module === require.main) { +if (import.meta.main) { process.stdout.write(JSON.stringify(process.argv.slice(2))); } diff --git a/test/create/escape_arg.js b/test/create/escape_arg.js index 593b1a3f..e9fcdf5f 100644 --- a/test/create/escape_arg.js +++ b/test/create/escape_arg.js @@ -1,17 +1,16 @@ -"use strict"; -const child_process = require("child_process"); -const util = require("util"); -const assert = require("assert").strict; -const path = require("path"); +import child_process from "node:child_process"; +import util from "node:util"; +import assert from "node:assert/strict"; +import path from "node:path"; -const { escapeArg } = require("../../packages/create/escape_arg"); +import { escapeArg } from "../../packages/create/escape_arg.js"; const execFile = util.promisify(child_process.execFile); async function exec(file, args) { const command = [file, ...args.map(escapeArg)].join(" "); const { stdout, stderr } = await execFile( command, - { shell: true, cwd: __dirname, env: { ...process.env, pct: "%"} } + { shell: true, cwd: import.meta.dirname, env: { ...process.env, pct: "%"} } ); return { stdout: JSON.parse(stdout), stderr }; } diff --git a/test/file/test_plugin/control.js b/test/file/test_plugin/control.js index 25a80a0c..6f903c05 100644 --- a/test/file/test_plugin/control.js +++ b/test/file/test_plugin/control.js @@ -1,6 +1,5 @@ -"use strict"; -const { Command, CommandTree } = require("@clusterio/lib"); -const { BaseCtlPlugin } = require("@clusterio/ctl"); +import { Command, CommandTree } from "@clusterio/lib"; +import { BaseCtlPlugin } from "@clusterio/ctl"; const testPluginCommands = new CommandTree({ name: "test-plugin", description: "Test plugin commands", @@ -15,12 +14,8 @@ testPluginCommands.add(new Command({ }, })); -class CtlPlugin extends BaseCtlPlugin { +export class CtlPlugin extends BaseCtlPlugin { async addCommands(rootCommand) { rootCommand.add(testPluginCommands); } } - -module.exports = { - CtlPlugin, -}; diff --git a/test/file/test_plugin/controller.js b/test/file/test_plugin/controller.js index 01988f39..2764aed2 100644 --- a/test/file/test_plugin/controller.js +++ b/test/file/test_plugin/controller.js @@ -1,9 +1,8 @@ -"use strict"; -const { BaseControllerPlugin } = require("@clusterio/controller"); +import { BaseControllerPlugin } from "@clusterio/controller"; -const { ControllerEcho, HostEcho } = require("./messages"); +import { ControllerEcho, HostEcho } from "./messages.js"; -class ControllerPlugin extends BaseControllerPlugin { +export class ControllerPlugin extends BaseControllerPlugin { async init() { this.logger.info("test_plugin controller loaded"); this.controller.handle(ControllerEcho, this.handleControllerEcho.bind(this)); @@ -15,7 +14,3 @@ class ControllerPlugin extends BaseControllerPlugin { return request.text; } } - -module.exports = { - ControllerPlugin, -}; diff --git a/test/file/test_plugin/host.js b/test/file/test_plugin/host.js index ca6a3bad..1e48da39 100644 --- a/test/file/test_plugin/host.js +++ b/test/file/test_plugin/host.js @@ -1,26 +1,16 @@ -"use strict"; -const { BaseHostPlugin } = require("@clusterio/host"); +import { HostEcho, HostEchoReceived } from "./messages.js"; -const { HostEcho, HostEchoReceived } = require("./messages"); +// Uses the default export entrypoint, the controller and ctl parts of this +// plugin use the deprecated class export so both paths are exercised. +export default async function(context) { + const { host, logger } = context; + logger.info("test_plugin host loaded"); + const receivedEchoes = new Set(); -class HostPlugin extends BaseHostPlugin { - async init() { - this.logger.info("test_plugin host loaded"); - this.receivedEchoes = new Set(); - this.host.handle(HostEcho, this.handleHostEcho.bind(this)); - this.host.handle(HostEchoReceived, this.handleHostEchoReceived.bind(this)); - } + host.handle(HostEcho, async (event) => { + logger.info(`test_plugin host echo ${event.text}`); + receivedEchoes.add(event.text); + }); - async handleHostEcho(event) { - this.logger.info(`test_plugin host echo ${event.text}`); - this.receivedEchoes.add(event.text); - } - - async handleHostEchoReceived(request) { - return this.receivedEchoes.has(request.text); - } + host.handle(HostEchoReceived, async (request) => receivedEchoes.has(request.text)); } - -module.exports = { - HostPlugin, -}; diff --git a/test/file/test_plugin/index.js b/test/file/test_plugin/index.js index 7938c45b..de9f64e2 100644 --- a/test/file/test_plugin/index.js +++ b/test/file/test_plugin/index.js @@ -1,24 +1,21 @@ -"use strict"; // Plugin exercising the plugin system in the integration tests, see #342. // Written as plain JavaScript so that it does not need to be built. -const { ControllerEcho, HostEcho, HostEchoReceived } = require("./messages"); +import { ControllerEcho, HostEcho, HostEchoReceived } from "./messages.js"; -module.exports = { - plugin: { - name: "test_plugin", - title: "Test Plugin", - description: "Plugin used to test the plugin system.", +export const plugin = { + name: "test_plugin", + title: "Test Plugin", + description: "Plugin used to test the plugin system.", - controllerEntrypoint: "controller", - hostEntrypoint: "host", - instanceEntrypoint: "instance", - ctlEntrypoint: "control", + controllerEntrypoint: "controller.js", + hostEntrypoint: "host.js", + instanceEntrypoint: "instance.js", + ctlEntrypoint: "control.js", - messages: [ - ControllerEcho, - HostEcho, - HostEchoReceived, - ], - }, + messages: [ + ControllerEcho, + HostEcho, + HostEchoReceived, + ], }; diff --git a/test/file/test_plugin/instance.js b/test/file/test_plugin/instance.js index 5296c026..3f467777 100644 --- a/test/file/test_plugin/instance.js +++ b/test/file/test_plugin/instance.js @@ -1,12 +1,8 @@ -"use strict"; -const { BaseInstancePlugin } = require("@clusterio/host"); +export default async function(context) { + const { instance, plugin, logger } = context; + logger.info("test_plugin instance loaded"); -class InstancePlugin extends BaseInstancePlugin { - async init() { - this.logger.info("test_plugin instance loaded"); - } + instance.hooks.start.attach(plugin.name, async () => { + logger.info("test_plugin instance started"); + }); } - -module.exports = { - InstancePlugin, -}; diff --git a/test/file/test_plugin/messages.js b/test/file/test_plugin/messages.js index be98c2c4..59baf5af 100644 --- a/test/file/test_plugin/messages.js +++ b/test/file/test_plugin/messages.js @@ -1,9 +1,8 @@ -"use strict"; -const { JsonBoolean, JsonString } = require("@clusterio/lib"); +import { JsonBoolean, JsonString } from "@clusterio/lib"; // Sent from ctl to the controller, which answers with the text it was given // and broadcasts a HostEcho to the hosts. -class ControllerEcho { +export class ControllerEcho { static type = "request"; static src = "control"; static dst = "controller"; @@ -21,7 +20,7 @@ class ControllerEcho { } // Broadcast from the controller to the hosts by broadcastEventToHosts. -class HostEcho { +export class HostEcho { static type = "event"; static src = "controller"; static dst = "host"; @@ -39,7 +38,7 @@ class HostEcho { // Sent from ctl to a host, which answers with whether it received the given // text via a HostEcho. -class HostEchoReceived { +export class HostEchoReceived { static type = "request"; static src = "control"; static dst = "host"; @@ -55,9 +54,3 @@ class HostEchoReceived { toJSON() { return this.text; } static fromJSON(json) { return new this(json); } } - -module.exports = { - ControllerEcho, - HostEcho, - HostEchoReceived, -}; diff --git a/test/file/test_plugin/package.json b/test/file/test_plugin/package.json index 16079ed7..d3a552d8 100644 --- a/test/file/test_plugin/package.json +++ b/test/file/test_plugin/package.json @@ -3,6 +3,7 @@ "version": "0.0.1", "description": "Plugin used to test the plugin system, see #342", "private": true, + "type": "module", "main": "index.js", "keywords": [ "clusterio-plugin" diff --git a/test/host.js b/test/host.js index 4308c189..4fafc52e 100644 --- a/test/host.js +++ b/test/host.js @@ -1,12 +1,11 @@ -"use strict"; -const assert = require("assert").strict; -const path = require("path"); +import assert from "node:assert/strict"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; -const lib = require("@clusterio/lib"); -const Host = require("@clusterio/host/dist/node/src/Host").default; -const Instance = require("@clusterio/host/dist/node/src/Instance").default; -const { _discoverInstances } = require("@clusterio/host/dist/node/src/Host"); -const { HostConnector } = require("@clusterio/host/dist/node/host"); +import * as lib from "@clusterio/lib"; +import Host, { _discoverInstances } from "@clusterio/host/dist/node/src/Host.js"; +import Instance from "@clusterio/host/dist/node/src/Instance.js"; +import { HostConnector } from "@clusterio/host/dist/node/host.js"; describe("Host testing", function() { describe("discoverInstances()", function() { @@ -116,14 +115,19 @@ describe("Host testing", function() { ]); }); }); - describe(".checkRestartRequired()", function() { + describe(".checkRestartRequired()", async function() { let host; - const hostVersion = require("@clusterio/host/package.json").version; + const hostVersion = ( + await import("@clusterio/host/package.json", { with: { type: "json" }}) + ).default.version; + before(function() { + lib.addPluginConfigFields([{ name: "restart_test", hostEntrypoint: "host.js" }]); + }); beforeEach(function() { // This can be used more generally, but i did not want to interfere with handleSyncUserListsEvent const pluginInfos = [{ - name: "global_chat", - requirePath: path.dirname(require.resolve("@clusterio/host/package.json")), + name: "restart_test", + packagePath: fileURLToPath(import.meta.resolve("@clusterio/host/package.json")), version: hostVersion, }]; const hostConfig = new lib.HostConfig("host", { "host.version": hostVersion }); @@ -141,7 +145,7 @@ describe("Host testing", function() { host.pluginInfos[0].version = "0.0.0"; host.pluginInfos[0].hostEntrypoint = true; host.pluginInfos[0].instanceEntrypoint = true; - host.config.set("global_chat.load_plugin", false); + host.config.set("restart_test.load_plugin", false); host.config.restartRequired = false; // Setting load_plugin requires a restart const result = await host.checkRestartRequired(); assert.equal(result, false); @@ -174,9 +178,11 @@ describe("Host testing", function() { assert.equal(host.config.restartRequired, true); }); }); - describe(".checkRestartDowngrade()", function() { + describe(".checkRestartDowngrade()", async function() { let host; - const hostVersion = require("@clusterio/host/package.json").version; + const hostVersion = ( + await import("@clusterio/host/package.json", { with: { type: "json" }}) + ).default.version; beforeEach(function() { const hostConfig = new lib.HostConfig("host", { "host.version": hostVersion }); const hostConnector = new HostConnector(hostConfig, []); diff --git a/test/host/BaseHostPlugin.js b/test/host/BaseHostPlugin.js index a2e15739..e0afed6e 100644 --- a/test/host/BaseHostPlugin.js +++ b/test/host/BaseHostPlugin.js @@ -1,22 +1,50 @@ -"use strict"; -const assert = require("assert").strict; -const mock = require("../mock"); +import assert from "node:assert/strict"; +import * as mock from "../mock.js"; -const host = require("@clusterio/host"); +import * as host from "@clusterio/host"; describe("host/src/BaseHostPlugin", function() { describe("class BaseHostPlugin", function() { - let hostPlugin; - it("should be constructible", async function() { - hostPlugin = new host.BaseHostPlugin({}, new mock.MockHost(), new mock.MockLogger()); - await hostPlugin.init(); + const info = { name: "test" }; + let mockHost; + beforeEach(function() { + mockHost = new mock.MockHost(); }); - it("should define defaults for hooks", async function() { - await hostPlugin.onHostConfigFieldChanged(); - await hostPlugin.onMetrics(); - await hostPlugin.onShutdown(); - hostPlugin.onControllerConnectionEvent("connect"); - await hostPlugin.onPrepareControllerDisconnect(); + + it("should be constructible", async function() { + const plugin = new host.BaseHostPlugin(info, mockHost, new mock.MockLogger()); + await plugin.init(); + assert.equal(mockHost.hooks.size, 0); + }); + it("should be constructible from a load context", async function() { + const logger = new mock.MockLogger(); + const plugin = host.BaseHostPlugin.fromContext({ plugin: info, host: mockHost, logger }); + assert.equal(plugin.info, info); + assert.equal(plugin.host, mockHost); + assert.equal(plugin.logger, logger); + }); + it("should attach overridden hooks", async function() { + let calls = []; + class HostPlugin extends host.BaseHostPlugin { + async onHostConfigFieldChanged(field, curr, prev) { calls.push(["config", field, curr, prev]); } + async onShutdown() { calls.push(["shutdown", this]); } + } + const plugin = new HostPlugin(info, mockHost, new mock.MockLogger()); + assert.deepEqual([...mockHost.hooks.attached], ["test"]); + assert.equal(mockHost.hooks.metrics.size, 0); + + await mockHost.hooks.hostConfigFieldChanged.invoke("host.name", "new", "old"); + await mockHost.hooks.shutdown.invoke(); + assert.deepEqual(calls, [["config", "host.name", "new", "old"], ["shutdown", plugin]]); + }); + it("should detach hooks", async function() { + class HostPlugin extends host.BaseHostPlugin { + async onShutdown() { } + } + const plugin = new HostPlugin(info, mockHost, new mock.MockLogger()); + assert.equal(mockHost.hooks.size, 1); + plugin.detachHooks(); + assert.equal(mockHost.hooks.size, 0); }); }); }); diff --git a/test/host/BaseInstancePlugin.js b/test/host/BaseInstancePlugin.js index 20e077a2..10808b40 100644 --- a/test/host/BaseInstancePlugin.js +++ b/test/host/BaseInstancePlugin.js @@ -1,24 +1,55 @@ -"use strict"; -const assert = require("assert").strict; -const mock = require("../mock"); +import assert from "node:assert/strict"; +import * as mock from "../mock.js"; -const host = require("@clusterio/host"); +import * as host from "@clusterio/host"; describe("host/src/BaseInstancePlugin", function() { describe("class BaseInstancePlugin", function() { + const info = { name: "test" }; let instancePlugin; it("should be constructible", async function() { - instancePlugin = new host.BaseInstancePlugin({}, new mock.MockInstance(), {}); + instancePlugin = new host.BaseInstancePlugin( + info, new mock.MockInstance(), new mock.MockHost(), new mock.MockLogger() + ); await instancePlugin.init(); + assert.equal(instancePlugin.instance.hooks.size, 0); }); - it("should define defaults for hooks", async function() { - await instancePlugin.onMetrics(); - await instancePlugin.onStart(); - await instancePlugin.onStop(); - instancePlugin.onExit(); - await instancePlugin.onOutput({}); - instancePlugin.onControllerConnectionEvent("connect"); - await instancePlugin.onPrepareControllerDisconnect(); + it("should be constructible from a load context", async function() { + const instance = new mock.MockInstance(); + const mockHost = new mock.MockHost(); + const logger = new mock.MockLogger(); + const plugin = host.BaseInstancePlugin.fromContext({ plugin: info, instance, host: mockHost, logger }); + assert.equal(plugin.info, info); + assert.equal(plugin.instance, instance); + assert.equal(plugin.host, mockHost); + assert.equal(plugin.logger, logger); + }); + it("should attach overridden hooks", async function() { + let calls = []; + class InstancePlugin extends host.BaseInstancePlugin { + async onStart() { calls.push(["start", this]); } + onExit() { calls.push(["exit"]); } + async onOutput(parsed, line) { calls.push(["output", parsed, line]); } + } + const instance = new mock.MockInstance(); + const plugin = new InstancePlugin(info, instance, new mock.MockHost(), new mock.MockLogger()); + assert.deepEqual([...instance.hooks.attached], ["test"]); + assert.equal(instance.hooks.stop.size, 0); + + await instance.hooks.start.invoke(); + await instance.hooks.exit.invoke(); + await instance.hooks.output.invoke({ type: "generic" }, "line"); + assert.deepEqual(calls, [["start", plugin], ["exit"], ["output", { type: "generic" }, "line"]]); + }); + it("should detach hooks", async function() { + class InstancePlugin extends host.BaseInstancePlugin { + async onStart() { } + } + const instance = new mock.MockInstance(); + const plugin = new InstancePlugin(info, instance, new mock.MockHost(), new mock.MockLogger()); + assert.equal(instance.hooks.size, 1); + plugin.detachHooks(); + assert.equal(instance.hooks.size, 0); }); describe("sendRcon", function() { it("should send commands out of order", async function() { diff --git a/test/host/Instance.js b/test/host/Instance.js index 8dd66f49..73e11c1a 100644 --- a/test/host/Instance.js +++ b/test/host/Instance.js @@ -1,11 +1,10 @@ -"use strict"; -const assert = require("assert").strict; -const path = require("path"); +import assert from "node:assert/strict"; +import path from "node:path"; -const lib = require("@clusterio/lib"); -const { PlayerStats, wait } = lib; -const Instance = require("@clusterio/host/dist/node/src/Instance").default; -const { MockConnector, MockServer } = require("../mock"); +import * as lib from "@clusterio/lib"; +import { PlayerStats, wait } from "@clusterio/lib"; +import Instance from "@clusterio/host/dist/node/src/Instance.js"; +import { MockConnector, MockServer } from "../mock.js"; const addr = lib.Address.fromShorthand; @@ -39,6 +38,34 @@ describe("class Instance", function() { }); }); + describe(".checkModPackVersion()", function() { + function modPack(factorioVersion) { + return lib.ModPack.fromJSON({ name: "pack", factorio_version: factorioVersion }); + } + + it("should accept a mod pack for an older or equal Factorio version", function() { + instance.server.version = "1.1.91"; + instance.checkModPackVersion(modPack("1.1")); + instance.checkModPackVersion(modPack("1.1.90")); + instance.checkModPackVersion(modPack("1.1.91")); + }); + + it("should reject a mod pack for a newer Factorio version", function() { + instance.server.version = "1.1.91"; + assert.throws( + () => instance.checkModPackVersion(modPack("1.1.110")), + new lib.RequestError( + "Mod pack pack is for Factorio 1.1.110 which is newer than the Factorio 1.1.91 this instance runs" + ) + ); + assert.throws(() => instance.checkModPackVersion(modPack("2.0")), lib.RequestError); + }); + + it("should skip the check when the server version is unknown", function() { + instance.checkModPackVersion(modPack("2.0")); + }); + }); + describe("._recordPlayerJoin()", function() { it("should add player to playersOnline", function() { instance._recordPlayerJoin("player"); diff --git a/test/host/create_scenario.js b/test/host/create_scenario.js index 659ae9d0..2324c198 100644 --- a/test/host/create_scenario.js +++ b/test/host/create_scenario.js @@ -1,14 +1,13 @@ -"use strict"; -const assert = require("assert").strict; -const fs = require("node:fs/promises"); -const JSZip = require("jszip"); -const path = require("path"); +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import JSZip from "jszip"; +import path from "node:path"; -const lib = require("@clusterio/lib"); -const { +import * as lib from "@clusterio/lib"; +import { createScenario, createScenarioCommand, handleCreateScenarioCommand, -} = require("@clusterio/host/dist/node/src/create_scenario"); -const { SaveModule, PatchInfo } = require("@clusterio/host/dist/node/src/patch"); +} from "@clusterio/host/dist/node/src/create_scenario.js"; +import { SaveModule, PatchInfo } from "@clusterio/host/dist/node/src/patch.js"; describe("host/create_scenario", function() { @@ -71,6 +70,19 @@ describe("host/create_scenario", function() { checkOutput(await readDir(output)); }); + it("should write a zip file when asked to", async function() { + const output = path.join(tempDir, "zipped.zip"); + await createScenario(path.join(tempDir, "scenario"), output, [module], true); + const zip = await JSZip.loadAsync(await fs.readFile(output)); + const files = new Map(); + for (const file of Object.values(zip.files)) { + if (!file.dir) { + files.set(file.name.replace(/^scenario\//, ""), await file.async("string")); + } + } + checkOutput(files); + }); + it("should not modify the input scenario", async function() { const files = await readDir(path.join(tempDir, "scenario")); assert.deepEqual(files, scenarioFiles); @@ -103,7 +115,7 @@ describe("host/create_scenario", function() { }; createScenarioCommand(yargs); assert.deepEqual(positionals, ["output"]); - assert.deepEqual(options, ["scenario", "factorio-version", "plugins"]); + assert.deepEqual(options, ["zip", "scenario", "factorio-version", "plugins"]); }); }); diff --git a/test/host/export.js b/test/host/export.js index c806c316..a9131584 100644 --- a/test/host/export.js +++ b/test/host/export.js @@ -1,9 +1,8 @@ -"use strict"; -const assert = require("assert").strict; -const path = require("path"); +import assert from "node:assert/strict"; +import path from "node:path"; -const { FactorioServer } = require("@clusterio/host/dist/node/src/server"); -const { _exportLocale } = require("@clusterio/host/dist/node/src/export"); +import { FactorioServer } from "@clusterio/host/dist/node/src/server.js"; +import { _exportLocale } from "@clusterio/host/dist/node/src/export.js"; describe("host/src/export", function() { diff --git a/test/host/patch.js b/test/host/patch.js index 41a07bd3..8262e5b6 100644 --- a/test/host/patch.js +++ b/test/host/patch.js @@ -1,11 +1,10 @@ -"use strict"; -const assert = require("assert").strict; -const fs = require("node:fs/promises"); -const JSZip = require("jszip"); -const path = require("path"); +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import JSZip from "jszip"; +import path from "node:path"; -const lib = require("@clusterio/lib"); -const patch = require("@clusterio/host/dist/node/src/patch"); +import * as lib from "@clusterio/lib"; +import * as patch from "@clusterio/host/dist/node/src/patch.js"; describe("host/patch", function() { @@ -253,24 +252,14 @@ describe("host/patch", function() { await fs.writeFile(path.join(pluginDir, "package.json"), "{}"); await fs.writeFile(path.join(pluginDir, "module", "module.json"), "{}"); await assert.rejects( - patch.loadModules([{ name: "clusterio", version: "1.0.0", requirePath: path.resolve(pluginDir) }]), + patch.loadModules([{ + name: "clusterio", version: "1.0.0", packagePath: path.resolve(pluginDir, "package.json"), + }]), new Error("Module with name clusterio already exists in a plugin") ); }); }); - describe("class SaveModule", function() { - describe("static fromPlugin()", function() { - it("should load the module of the plugin", async function() { - const [info] = await lib.loadPluginInfos( - new Map([["research_sync", path.resolve("plugins/research_sync")]]) - ); - const pluginModule = await patch.SaveModule.fromPlugin({ info }); - assert.equal(pluginModule.info.name, "research_sync"); - }); - }); - }); - describe("patch()", function() { it("should throw on unknown scenario", async function() { let zip = new JSZip(); diff --git a/test/host/server.js b/test/host/server.js index 2321d527..7590cff2 100644 --- a/test/host/server.js +++ b/test/host/server.js @@ -1,14 +1,12 @@ -"use strict"; -const assert = require("assert").strict; -const events = require("events"); -const fs = require("node:fs/promises"); -const path = require("path"); +import assert from "node:assert/strict"; +import events from "node:events"; +import fs from "node:fs/promises"; +import path from "node:path"; -const hostServer = require("@clusterio/host/dist/node/src/server"); -const lib = require("@clusterio/lib"); -const { wait } = lib; -const { testLines } = require("../lib/factorio/lines"); -const { slowTest, externalTest } = require("../integration"); +import * as hostServer from "@clusterio/host/dist/node/src/server.js"; +import { Link, wait } from "@clusterio/lib"; +import { testLines } from "../lib/factorio/lines.js"; +import { slowTest, externalTest } from "../integration/index.js"; describe("host/server", function() { @@ -326,7 +324,7 @@ describe("host/server", function() { static jsonSchema = { type: "number" }; static fromJSON(json) { return new this(json); } } - let eventFromJSON = lib.Link.eventFromJSON(NumberEvent, "NumberEvent"); + let eventFromJSON = Link.eventFromJSON(NumberEvent, "NumberEvent"); ipcServer.handle("bad_event", async () => { eventFromJSON("not a number"); }); ipcServer.emit("ipc-bad_event", {}); await new Promise(resolve => setImmediate(resolve)); diff --git a/test/integration/clusterio.js b/test/integration/clusterio.js index af7a1462..cafbac4d 100644 --- a/test/integration/clusterio.js +++ b/test/integration/clusterio.js @@ -1,22 +1,21 @@ -"use strict"; -const assert = require("assert").strict; -const fs = require("node:fs/promises"); -const jwt = require("jsonwebtoken"); -const path = require("path"); -const events = require("events"); +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import jwt from "jsonwebtoken"; +import path from "node:path"; +import events from "node:events"; -const lib = require("@clusterio/lib"); -const libBuildMod = require("@clusterio/lib/build_mod"); -const { wait } = lib; +import * as lib from "@clusterio/lib"; +import { wait } from "@clusterio/lib"; +import * as libBuildMod from "@clusterio/lib/build_mod.js"; -const testStrings = require("../lib/factorio/test_strings"); -const { +import * as testStrings from "../lib/factorio/test_strings.js"; +import { TestControl, TestControlConnector, url, controlToken, slowTest, execCtl, execCtlProcess, execController, execHost, sendRcon, getControl, spawnNode, instancesDir, factorioDir, databaseDir, controllerConfigPath, requiresFactorio, hasFactorio, -} = require("./index"); -const { ControllerEcho, HostEchoReceived } = require("../file/test_plugin/messages"); +} from "./index.js"; +import { ControllerEcho, HostEchoReceived } from "../file/test_plugin/messages.js"; /** @returns {Promise>} */ @@ -535,6 +534,9 @@ describe("Integration of Clusterio", function() { it("runs", async function() { await execCtl("controller update"); }); + it("accepts --all", async function() { + await execCtl("controller update --all"); + }); it("accepts --restart", async function() { // We cannot restart the controller, so we check for controller error instead await assert.rejects( @@ -617,6 +619,9 @@ describe("Integration of Clusterio", function() { it("runs", async function() { await execCtl("host update 4"); }); + it("accepts --all", async function() { + await execCtl("host update 4 --all"); + }); it("accepts --restart", async function() { // We cannot restart the host, so we check for host error instead await assert.rejects( diff --git a/test/integration/create.js b/test/integration/create.js index 74784c11..1375fa29 100644 --- a/test/integration/create.js +++ b/test/integration/create.js @@ -1,11 +1,10 @@ -"use strict"; -const assert = require("assert").strict; -const fs = require("node:fs/promises"); -const jwt = require("jsonwebtoken"); -const path = require("path"); -const events = require("events"); -const child_process = require("child_process"); -const util = require("util"); +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import jwt from "jsonwebtoken"; +import path from "node:path"; +import events from "node:events"; +import child_process from "node:child_process"; +import util from "node:util"; const cwd = path.join("temp", "test_integration"); diff --git a/test/integration/export.js b/test/integration/export.js index 45169e09..6555801a 100644 --- a/test/integration/export.js +++ b/test/integration/export.js @@ -1,11 +1,10 @@ -"use strict"; -const assert = require("assert").strict; -const path = require("path"); +import assert from "node:assert/strict"; +import path from "node:path"; -const { _exportLocale } = require("@clusterio/host/dist/node/src/export"); -const { FactorioServer } = require("@clusterio/host/dist/node/src/server"); +import { _exportLocale } from "@clusterio/host/dist/node/src/export.js"; +import { FactorioServer } from "@clusterio/host/dist/node/src/server.js"; -const { factorioDir, requiresFactorio } = require("./index"); +import { factorioDir, requiresFactorio } from "./index.js"; describe("Integration of lib/factorio/export", function() { diff --git a/test/integration/index.js b/test/integration/index.js index 2b81631a..c2816412 100644 --- a/test/integration/index.js +++ b/test/integration/index.js @@ -1,27 +1,26 @@ /* eslint-disable no-console */ -"use strict"; -const path = require("path"); -const fs = require("node:fs/promises"); -const { readFileSync } = require("node:fs"); -const child_process = require("child_process"); -const jwt = require("jsonwebtoken"); -const util = require("util"); -const events = require("events"); +import path from "node:path"; +import fs from "node:fs/promises"; +import { readFileSync } from "node:fs"; +import child_process from "node:child_process"; +import jwt from "jsonwebtoken"; +import util from "node:util"; +import events from "node:events"; -const lib = require("@clusterio/lib"); -const { LineSplitter, ConsoleTransport, logger } = lib; +import * as lib from "@clusterio/lib"; +import { LineSplitter, ConsoleTransport, logger } from "@clusterio/lib"; -const { selectTargetCommand, initialize: initializeCtl } = require("@clusterio/ctl"); -const { _listFactorioVersions } = require("@clusterio/host/dist/node/src/server"); +import { selectTargetCommand, initialize as initializeCtl } from "@clusterio/ctl"; +import { _listFactorioVersions } from "@clusterio/host/dist/node/src/server.js"; // Make sure permissions from plugins are loaded -require("../../plugins/global_chat/dist/node/index"); -require("../../plugins/player_auth/dist/node/index"); -require("../../plugins/research_sync/dist/node/index"); -require("../../plugins/statistics_exporter/dist/node/index"); -require("../../plugins/subspace_storage/dist/node/index"); +import "../../plugins/global_chat/dist/node/index.js"; +import "../../plugins/player_auth/dist/node/index.js"; +import "../../plugins/research_sync/dist/node/index.js"; +import "../../plugins/statistics_exporter/dist/node/index.js"; +import "../../plugins/subspace_storage/dist/node/index.js"; -class TestControl extends lib.Link { +export class TestControl extends lib.Link { constructor(connector, subscribe = true) { super(connector); this.hostUpdates = []; @@ -93,7 +92,7 @@ class TestControl extends lib.Link { } } -class TestControlConnector extends lib.WebSocketClientConnector { +export class TestControlConnector extends lib.WebSocketClientConnector { register() { this.sendHandshake( new lib.MessageRegisterControl( @@ -105,7 +104,7 @@ class TestControlConnector extends lib.WebSocketClientConnector { } } -class TestHostConnector extends lib.WebSocketClientConnector { +export class TestHostConnector extends lib.WebSocketClientConnector { register() { this.sendHandshake( new lib.MessageRegisterHost( @@ -119,7 +118,7 @@ class TestHostConnector extends lib.WebSocketClientConnector { } // Mark that this test takes a lot of time, or depends on a test that takes a lot of time. -function slowTest(test) { +export function slowTest(test) { if (process.env.FAST_TEST) { test.skip(); @@ -129,7 +128,7 @@ function slowTest(test) { } // Mark that this test depends on an external API and may be slow or flaky. -function externalTest(test) { +export function externalTest(test) { if (process.env.NO_EXTERNAL_TEST) { test.skip(); } @@ -138,7 +137,7 @@ function externalTest(test) { } // Mark that this test or suite of tests requires a factorio install to run. -function requiresFactorio(testOrSuite) { +export function requiresFactorio(testOrSuite) { if (testOrSuite.skip) { if (!haveFactorioInstall) { testOrSuite.skip(); @@ -152,11 +151,11 @@ function requiresFactorio(testOrSuite) { } } -function hasFactorio() { +export function hasFactorio() { return haveFactorioInstall; } -async function get(urlPath) { +export async function get(urlPath) { const url = new URL("https://localhost:4443"); url.pathname = urlPath; let res = await fetch(url); @@ -191,16 +190,16 @@ let haveFactorioInstall; const baseHostConfig = loadJSON("config-host.json"); -let url = "https://localhost:4443/"; -let controlToken = jwt.sign({ aud: "user", user: "test" }, Buffer.from("TestSecretDoNotUse", "base64")); -let instancesDir = path.join("temp", "test", "instances"); -let modsDir = path.join("temp", "test", "mods"); -let databaseDir = path.join("temp", "test", "database"); -let factorioDir = getFactorioDir(baseHostConfig); +export let url = "https://localhost:4443/"; +export let controlToken = jwt.sign({ aud: "user", user: "test" }, Buffer.from("TestSecretDoNotUse", "base64")); +export let instancesDir = path.join("temp", "test", "instances"); +export let modsDir = path.join("temp", "test", "mods"); +export let databaseDir = path.join("temp", "test", "database"); +export let factorioDir = getFactorioDir(baseHostConfig); let pluginListPath = path.join("temp", "test", "plugin-list.json"); -let controllerConfigPath = path.join("temp", "test", "config-controller.json"); -let hostConfigPath = path.join("temp", "test", "config-host.json"); -let controlConfigPath = path.join("temp", "test", "config-control.json"); +export let controllerConfigPath = path.join("temp", "test", "config-controller.json"); +export let hostConfigPath = path.join("temp", "test", "config-host.json"); +export let controlConfigPath = path.join("temp", "test", "config-control.json"); function childOptions(options) { return { @@ -210,33 +209,33 @@ function childOptions(options) { }; } -function exec(command, options = {}) { +export function exec(command, options = {}) { // Uncomment to show commands run in tests // console.log(command); return util.promisify(child_process.exec)(command, childOptions(options)); } -function execController(...args) { +export function execController(...args) { args[0] = `node --enable-source-maps ../../packages/controller ${args[0]}`; return exec(...args); } -function execHost(...args) { +export function execHost(...args) { args[0] = `node --enable-source-maps ../../packages/host ${args[0]}`; return exec(...args); } -function execCtlProcess(...args) { +export function execCtlProcess(...args) { args[0] = `node --enable-source-maps ../../packages/ctl ${args[0]}`; return exec(...args); } let inExecCtl = false; -async function execCtl(command) { +export async function execCtl(command) { inExecCtl = true; process.chdir("temp/test"); try { - const initArgs = await initializeCtl(command, control.plugins, true); + const initArgs = await initializeCtl(command, control.hooks, true); if (!initArgs.shouldRun) { return; } @@ -257,16 +256,16 @@ afterEach(function() { } }); -async function sendRcon(instanceId, command) { +export async function sendRcon(instanceId, command) { return await control.sendTo({ instanceId }, new lib.InstanceSendRconRequest(command)); } -function getControl() { +export function getControl() { return control; } /** @returns {Promise} */ -function spawn(name, cmd, waitFor, options = {}) { +export function spawn(name, cmd, waitFor, options = {}) { const silent = process.env.SILENT_TEST; const bootstrap = !controllerProcess || !hostProcess; @@ -303,7 +302,7 @@ function spawn(name, cmd, waitFor, options = {}) { }); } -function spawnNode(name, cmd, waitFor, options) { +export function spawnNode(name, cmd, waitFor, options) { return spawn(name, `node --enable-source-maps ${cmd}`, waitFor, options); } @@ -395,7 +394,7 @@ before(async function() { const initArgs = await initializeCtl(`--plugin-list ${pluginListPath} control-config list`, undefined, true); control.config = initArgs.controlConfig; - control.plugins = initArgs.ctlPlugins; + control.hooks = initArgs.ctlHooks; const testPack = lib.ModPack.fromJSON({}); testPack.id = 12; @@ -437,34 +436,3 @@ process.on("exit", () => { if (hostProcess) { hostProcess.kill(); } if (controllerProcess) { controllerProcess.kill(); } }); - - -module.exports = { - TestControl, - TestControlConnector, - TestHostConnector, - execController, - execCtlProcess, - slowTest, - externalTest, - requiresFactorio, - get, - exec, - execCtl, - execHost, - sendRcon, - getControl, - spawn, - spawnNode, - - url, - controlToken, - instancesDir, - modsDir, - databaseDir, - factorioDir, - hasFactorio, - controllerConfigPath, - hostConfigPath, - controlConfigPath, -}; diff --git a/test/integration/instance.js b/test/integration/instance.js index 56f150b7..8bcdec7d 100644 --- a/test/integration/instance.js +++ b/test/integration/instance.js @@ -1,14 +1,13 @@ -"use strict"; -const path = require("path"); -const assert = require("assert").strict; -const lib = require("@clusterio/lib"); -const fs = require("node:fs/promises"); +import path from "node:path"; +import assert from "node:assert/strict"; +import * as lib from "@clusterio/lib"; +import fs from "node:fs/promises"; -const { testMatrix } = require("../common"); -const { +import { testMatrix } from "../common.js"; +import { slowTest, exec, execCtl, execCtlProcess, sendRcon, getControl, requiresFactorio, hasFactorio, instancesDir, -} = require("./index"); +} from "./index.js"; const instId = 48; const instName = "Integration"; diff --git a/test/integration/link.js b/test/integration/link.js index 01483b86..d45a28ca 100644 --- a/test/integration/link.js +++ b/test/integration/link.js @@ -1,11 +1,9 @@ -"use strict"; -const assert = require("assert").strict; -const events = require("events"); -const fs = require("node:fs/promises"); -const jwt = require("jsonwebtoken"); +import assert from "node:assert/strict"; +import events from "node:events"; +import jwt from "jsonwebtoken"; -const { TestControlConnector, TestHostConnector, TestControl, get, exec, url, slowTest } = require("./index"); -const { ConnectionClosed, ProtocolError, PolicyViolation, AuthenticationFailed } = require("@clusterio/lib"); +import { TestControlConnector, TestHostConnector, TestControl, get, exec, url, slowTest } from "./index.js"; +import { ConnectionClosed, ProtocolError, PolicyViolation, AuthenticationFailed } from "@clusterio/lib"; let token = jwt.sign({ aud: "user", user: "test" }, Buffer.from("TestSecretDoNotUse", "base64")); let tokenHost = jwt.sign({ aud: "host", host: 0 }, Buffer.from("TestSecretDoNotUse", "base64")); diff --git a/test/integration/patch.js b/test/integration/patch.js index a4323e79..d222ff6d 100644 --- a/test/integration/patch.js +++ b/test/integration/patch.js @@ -1,17 +1,16 @@ -"use strict"; -const assert = require("assert").strict; -const fs = require("node:fs/promises"); -const JSZip = require("jszip"); -const path = require("path"); +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import JSZip from "jszip"; +import path from "node:path"; -const { patch, SaveModule } = require("@clusterio/host/dist/node/src/patch"); -const lib = require("@clusterio/lib"); +import { patch, SaveModule } from "@clusterio/host/dist/node/src/patch.js"; +import * as lib from "@clusterio/lib"; -const { slowTest } = require("./index"); +import { slowTest } from "./index.js"; // The server integration test is required to run before this one -require("./server"); +import "./server.js"; describe("Integration of lib/factorio/patch", function() { describe("patch()", function() { diff --git a/test/integration/routing.js b/test/integration/routing.js index 2f40745a..3aa2a9c9 100644 --- a/test/integration/routing.js +++ b/test/integration/routing.js @@ -1,12 +1,11 @@ -"use strict"; -const assert = require("assert").strict; +import assert from "node:assert/strict"; -const lib = require("@clusterio/lib"); -const { Host, Instance, InstanceConnection } = require("@clusterio/host"); -const { ControlConnection, Controller, HostConnection, InstanceRecord } = require("@clusterio/controller"); +import * as lib from "@clusterio/lib"; +import { Host, Instance, InstanceConnection } from "@clusterio/host"; +import { ControlConnection, Controller, HostConnection, InstanceRecord } from "@clusterio/controller"; // Although there are no imports, the file has side-effects such as "before" which can effect tests -require("./index"); +import "./index.js"; const addr = lib.Address.fromShorthand; diff --git a/test/integration/server.js b/test/integration/server.js index 8c8a711d..f0e08c30 100644 --- a/test/integration/server.js +++ b/test/integration/server.js @@ -1,15 +1,14 @@ -"use strict"; -const assert = require("assert").strict; -const events = require("events"); -const fs = require("node:fs/promises"); -const path = require("path"); +import assert from "node:assert/strict"; +import events from "node:events"; +import fs from "node:fs/promises"; +import path from "node:path"; -const lib = require("@clusterio/lib"); -const { InstanceManager } = require("@clusterio/controller"); -const { FactorioServer, _getFactorioVersion } = require("@clusterio/host/dist/node/src/server"); -const { logger } = lib; +import * as lib from "@clusterio/lib"; +import { InstanceManager } from "@clusterio/controller"; +import { FactorioServer, _getFactorioVersion } from "@clusterio/host/dist/node/src/server.js"; +import { logger } from "@clusterio/lib"; -const { slowTest, factorioDir, requiresFactorio } = require("./index"); +import { slowTest, factorioDir, requiresFactorio } from "./index.js"; describe("Integration of host/src/server", function() { diff --git a/test/lib/AsyncHook.js b/test/lib/AsyncHook.js new file mode 100644 index 00000000..e6163e60 --- /dev/null +++ b/test/lib/AsyncHook.js @@ -0,0 +1,132 @@ +import assert from "node:assert/strict"; + +import * as mock from "../mock.js"; +import * as lib from "@clusterio/lib"; + + +describe("lib/AsyncHook", function() { + describe("class AsyncHook", function() { + let logger; + let hook; + beforeEach(function() { + logger = new mock.MockLogger(); + logger.errors = []; + logger.error = msg => logger.errors.push(msg); + hook = new lib.AsyncHook(logger); + }); + + it("should track attached handlers", function() { + assert.equal(hook.size, 0); + hook.attach("alpha", () => {}); + hook.attach("beta", () => {}); + assert.equal(hook.size, 2); + assert.deepEqual([...hook.attached], ["alpha", "beta"]); + }); + it("should reject duplicate handler names", function() { + hook.attach("alpha", () => {}); + assert.throws( + () => hook.attach("alpha", () => {}), + new Error("Handler with name alpha is already attached") + ); + }); + it("should detach handlers", async function() { + let called = false; + hook.attach("alpha", () => { called = true; }); + hook.detach("alpha"); + hook.detach("missing"); + await hook.invoke(); + assert.equal(called, false); + assert.equal(hook.size, 0); + }); + it("should clear all handlers", function() { + hook.attach("alpha", () => {}); + hook.attach("beta", () => {}); + hook.clear(); + assert.equal(hook.size, 0); + }); + it("should invoke all handlers with the given arguments", async function() { + let calls = []; + hook.attach("alpha", (a, b) => { calls.push(["alpha", a, b]); }); + hook.attach("beta", async (a, b) => { calls.push(["beta", a, b]); }); + await hook.invoke(1, "two"); + assert.deepEqual(calls, [["alpha", 1, "two"], ["beta", 1, "two"]]); + }); + it("should log and ignore errors thrown by handlers", async function() { + let betaCalled = false; + hook.attach("alpha", () => { throw new Error("sync"); }); + hook.attach("beta", async () => { betaCalled = true; }); + hook.attach("gamma", async () => { throw new Error("async"); }); + await hook.invoke(); + assert(betaCalled, "beta was not called"); + assert.equal(logger.errors.length, 2); + assert(logger.errors[0].startsWith('Ignoring error in hook "alpha":'), logger.errors[0]); + assert(logger.errors[1].startsWith('Ignoring error in hook "gamma":'), logger.errors[1]); + }); + it("should time out handlers that never resolve", async function() { + hook = new lib.AsyncHook(logger, 10); + hook.attach("stuck", () => new Promise(() => {})); + hook.attach("fast", () => 1); + assert.deepEqual(await hook.collect(), [1]); + assert.equal(logger.errors.length, 1); + assert(logger.errors[0].includes("Hook stuck timed out after 10ms"), logger.errors[0]); + }); + it("should collect results from handlers", async function() { + hook.attach("alpha", arg => arg); + hook.attach("beta", () => undefined); + hook.attach("gamma", async arg => arg * 2); + hook.attach("delta", () => { throw new Error("Test"); }); + assert.deepEqual(await hook.collect(21), [21, 42]); + }); + it("should collect results with their source", async function() { + hook.attach("alpha", arg => arg); + hook.attach("beta", () => undefined); + hook.attach("gamma", async arg => arg * 2); + assert.deepEqual(await hook.collectEntries(21), [["alpha", 21], ["gamma", 42]]); + }); + it("should provide a stable listener bound to the hook", async function() { + let calls = []; + hook.attach("alpha", arg => { calls.push(arg); }); + const { listener } = hook; + assert.equal(listener, hook.listener); + await listener("value"); + assert.deepEqual(calls, ["value"]); + }); + }); + + describe("class AsyncHookCollection", function() { + class TestHooks extends lib.AsyncHookCollection { + constructor(logger) { + super(logger); + this.first = this.newHook(); + this.second = this.newHook(); + } + } + + let hooks; + beforeEach(function() { + hooks = new TestHooks(new mock.MockLogger()); + }); + + it("should count each name once across hooks", function() { + hooks.first.attach("alpha", () => {}); + hooks.second.attach("alpha", () => {}); + hooks.second.attach("beta", () => {}); + assert.equal(hooks.size, 2); + assert.deepEqual([...hooks.attached], ["alpha", "beta"]); + }); + it("should detach a name from all hooks", function() { + hooks.first.attach("alpha", () => {}); + hooks.second.attach("alpha", () => {}); + hooks.second.attach("beta", () => {}); + hooks.detachAll("alpha"); + assert.equal(hooks.first.size, 0); + assert.deepEqual([...hooks.second.attached], ["beta"]); + }); + it("should clear all hooks", function() { + hooks.first.attach("alpha", () => {}); + hooks.second.attach("beta", () => {}); + hooks.clearAll(); + assert.equal(hooks.size, 0); + }); + }); +}); diff --git a/test/lib/ExponentialBackoff.js b/test/lib/ExponentialBackoff.js index 5949deb2..431024ad 100644 --- a/test/lib/ExponentialBackoff.js +++ b/test/lib/ExponentialBackoff.js @@ -1,7 +1,6 @@ -"use strict"; -const assert = require("assert").strict; +import assert from "node:assert/strict"; -const { ExponentialBackoff } = require("@clusterio/lib"); +import { ExponentialBackoff } from "@clusterio/lib"; describe("lib/ExponentialBackoff", function() { describe("class ExponentialBackoff", function() { diff --git a/test/lib/LockFile.test.js b/test/lib/LockFile.test.js index f0903132..a4e25f4e 100644 --- a/test/lib/LockFile.test.js +++ b/test/lib/LockFile.test.js @@ -1,10 +1,9 @@ /* eslint-disable node/no-sync */ -"use strict"; -const assert = require("assert").strict; -const fs = require("node:fs/promises"); -const path = require("path"); +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import path from "node:path"; -const { LockFile } = require("@clusterio/lib"); +import { LockFile } from "@clusterio/lib"; const baseDir = path.join("temp", "test", "lock_file"); diff --git a/test/lib/ModStore.test.js b/test/lib/ModStore.test.js index b329d185..32e88e5e 100644 --- a/test/lib/ModStore.test.js +++ b/test/lib/ModStore.test.js @@ -1,15 +1,14 @@ -"use strict"; -const assert = require("assert").strict; -const path = require("path"); -const fs = require("node:fs/promises"); -const stream = require("node:stream"); -const JSZip = require("jszip"); // Added for creating mock zips +import assert from "node:assert/strict"; +import path from "node:path"; +import fs from "node:fs/promises"; +import stream from "node:stream"; +import JSZip from "jszip"; // Added for creating mock zips // Capture native fetch before any potential mocks are applied const nativeFetch = global.fetch; -const { ModStore, ModInfo, ModVersionEquality } = require("@clusterio/lib"); // Adjust path based on compiled output -const { externalTest } = require("../integration"); +import { ModStore, ModInfo, ModVersionEquality } from "@clusterio/lib"; // Adjust path based on compiled output +import { externalTest } from "../integration/index.js"; const MODS_DIR = path.join("temp", "test", "mod_store", "mods"); const CACHE_FILE = path.join(MODS_DIR, "mod-info-cache.json"); diff --git a/test/lib/RateLimiter.js b/test/lib/RateLimiter.js index f778d869..bc2611d0 100644 --- a/test/lib/RateLimiter.js +++ b/test/lib/RateLimiter.js @@ -1,7 +1,6 @@ -"use strict"; -const assert = require("assert").strict; +import assert from "node:assert/strict"; -const { RateLimiter, wait } = require("@clusterio/lib"); +import { RateLimiter, wait } from "@clusterio/lib"; describe("lib/RateLimiter", function() { diff --git a/test/lib/ValueCache.test.js b/test/lib/ValueCache.test.js index b75094c3..e0ee6890 100644 --- a/test/lib/ValueCache.test.js +++ b/test/lib/ValueCache.test.js @@ -1,5 +1,5 @@ -const assert = require("assert").strict; -const { ValueCache, wait } = require("@clusterio/lib"); +import assert from "node:assert/strict"; +import { ValueCache, wait } from "@clusterio/lib"; describe("ValueCache", function () { describe("constructor", function () { diff --git a/test/lib/check_singleton_import.test.js b/test/lib/check_singleton_import.test.js index c4af4938..4df9f2ba 100644 --- a/test/lib/check_singleton_import.test.js +++ b/test/lib/check_singleton_import.test.js @@ -1,12 +1,12 @@ -const assert = require("assert").strict; -const { checkSingletonImport } = require("@clusterio/lib/dist/node/src/check_singleton_import"); +import assert from "node:assert/strict"; +import { checkSingletonImport } from "@clusterio/lib/dist/node/src/check_singleton_import.js"; // Import lib so it sets the global indicating lib has been imported. -require("@clusterio/lib"); +import "@clusterio/lib"; describe("lib/check_singleton_import", function() { it("Should throw an installation error if called twice", function() { assert.throws( - () => checkSingletonImport(__filename), + () => checkSingletonImport(import.meta.filename), { message: /Attempt to import duplicate copy of @clusterio\/lib/, code: "InstallationError", diff --git a/test/lib/command.js b/test/lib/command.js index 86e299c6..6919fffd 100644 --- a/test/lib/command.js +++ b/test/lib/command.js @@ -1,8 +1,7 @@ -"use strict"; -const assert = require("assert").strict; +import assert from "node:assert/strict"; -const lib = require("@clusterio/lib"); -const mock = require("../mock"); +import * as lib from "@clusterio/lib"; +import * as mock from "../mock.js"; const addr = lib.Address.fromShorthand; @@ -16,39 +15,68 @@ describe("lib/command", function() { let testControl = new mock.MockControl(controlConnector); let testController = new lib.Link(controllerConnector); testController.handle( - lib.HostListRequest, () => [new lib.HostDetails("0.1", "Test Host", 11, false)] + lib.HostListRequest, () => [ + new lib.HostDetails("0.1", "Test Host", 11, false), + new lib.HostDetails("0.1", "22", 12, false), + new lib.HostDetails("0.1", "12", 13, false), + ] ); testController.handle( - lib.InstanceDetailsListRequest, () => [new lib.InstanceDetails("Test Instance", 57, 4, undefined, "stopped")] + lib.InstanceDetailsListRequest, () => [ + new lib.InstanceDetails("Test Instance", 57, 4, undefined, "stopped"), + new lib.InstanceDetails("10", 58, 4, undefined, "stopped"), + new lib.InstanceDetails("58", 59, 4, undefined, "stopped"), + ] ); - testController.handle(lib.RoleListRequest, () => [testRole.toJSON()]); + let numericRole = lib.Role.fromJSON({ id: 29, name: "77", description: "Test", permissions: [] }); + testController.handle(lib.RoleListRequest, () => [testRole.toJSON(), numericRole.toJSON()]); describe("resolveHost", function() { - it("should pass an integer like string back", async function() { - assert.equal(await lib.resolveHost(null, "123"), 123); + it("should resolve an integer like string as an id", async function() { + assert.equal(await lib.resolveHost(testControl, "11"), 11); }); it("should resolve a host name with the controller", async function() { assert.equal(await lib.resolveHost(testControl, "Test Host"), 11); }); + it("should resolve an integer like name if no host has that id", async function() { + assert.equal(await lib.resolveHost(testControl, "22"), 12); + }); + it("should prefer the id over an integer like name", async function() { + assert.equal(await lib.resolveHost(testControl, "12"), 12); + }); it("should throw if host is not found", async function() { await assert.rejects( lib.resolveHost(testControl, "invalid"), new lib.CommandError("No host named invalid") ); + await assert.rejects( + lib.resolveHost(testControl, "123"), + new lib.CommandError("No host named 123") + ); }); }); describe("resolveInstance", function() { - it("should pass an integer like string back", async function() { - assert.equal(await lib.resolveInstance(null, "123"), 123); + it("should resolve an integer like string as an id", async function() { + assert.equal(await lib.resolveInstance(testControl, "57"), 57); }); it("should resolve an instance name with the controller", async function() { assert.equal(await lib.resolveInstance(testControl, "Test Instance"), 57); }); + it("should resolve an integer like name if no instance has that id", async function() { + assert.equal(await lib.resolveInstance(testControl, "10"), 58); + }); + it("should prefer the id over an integer like name", async function() { + assert.equal(await lib.resolveInstance(testControl, "58"), 58); + }); it("should throw if instance is not found", async function() { await assert.rejects( lib.resolveInstance(testControl, "invalid"), new lib.CommandError("No instance named invalid") ); + await assert.rejects( + lib.resolveInstance(testControl, "123"), + new lib.CommandError("No instance named 123") + ); }); }); describe("retrieveRole", function() { @@ -58,6 +86,10 @@ describe("lib/command", function() { it("should retrieve a role by name from a string", async function() { assert.deepEqual(await lib.retrieveRole(testControl, "Test Role"), testRole); }); + it("should retrieve a role by an integer like name if no role has that id", async function() { + assert.deepEqual(await lib.retrieveRole(testControl, "28"), testRole); + assert.deepEqual(await lib.retrieveRole(testControl, "77"), numericRole); + }); it("should throw if role is not found", async function() { await assert.rejects( lib.retrieveRole(testControl, "invalid"), diff --git a/test/lib/config/classes.js b/test/lib/config/classes.js index 40e8cbb4..c953ea13 100644 --- a/test/lib/config/classes.js +++ b/test/lib/config/classes.js @@ -1,8 +1,7 @@ -"use strict"; -const lib = require("@clusterio/lib"); -const fs = require("node:fs/promises"); -const path = require("path"); -const assert = require("assert").strict; +import * as lib from "@clusterio/lib"; +import fs from "node:fs/promises"; +import path from "node:path"; +import assert from "node:assert/strict"; const CA = lib.ConfigAccess; @@ -396,6 +395,15 @@ describe("lib/config/classes", function() { const json = JSON.parse(await fs.readFile(filepath, "utf8")); assert.deepEqual(json, testInstance.toJSON()); }); + it("should not be readable by other users", async function() { + if (process.platform === "win32") { + this.skip(); + } + const testInstance = new TestConfig("local", { "alpha.foo": "a" }, filepath); + testInstance.set("alpha.foo", "b"); // Sets the dirty flag + await testInstance.save(); + assert.equal((await fs.stat(filepath)).mode & 0o777, 0o600); + }); it("should clear the dirty flag after saving", async function() { const testInstance = new TestConfig("local", { "alpha.foo": "a" }, filepath); testInstance.set("alpha.foo", "b"); // Sets the dirty flag diff --git a/test/lib/config/migrations.js b/test/lib/config/migrations.js index cb88c6d7..26dc6b03 100644 --- a/test/lib/config/migrations.js +++ b/test/lib/config/migrations.js @@ -1,6 +1,5 @@ -"use strict"; -const lib = require("@clusterio/lib"); -const assert = require("assert").strict; +import * as lib from "@clusterio/lib"; +import assert from "node:assert/strict"; // Each migration test should indicate which version introduced the migration diff --git a/test/lib/config/validators.test.js b/test/lib/config/validators.test.js index a8cdf907..be8b867d 100644 --- a/test/lib/config/validators.test.js +++ b/test/lib/config/validators.test.js @@ -1,8 +1,7 @@ -"use strict"; -const assert = require("assert").strict; -const lib = require("@clusterio/lib"); +import assert from "node:assert/strict"; +import * as lib from "@clusterio/lib"; const validators = lib.Config.validators; -const { ControllerConfig, HostConfig, InstanceConfig, ControlConfig } = lib; +import { ControllerConfig, HostConfig, InstanceConfig, ControlConfig } from "@clusterio/lib"; describe("lib/config/validators", function() { describe("all", function() { diff --git a/test/lib/data/HostDetails.test.js b/test/lib/data/HostDetails.test.js index 5c9405b3..3084718b 100644 --- a/test/lib/data/HostDetails.test.js +++ b/test/lib/data/HostDetails.test.js @@ -1,8 +1,7 @@ -"use strict"; -const assert = require("assert").strict; -const { HostDetails } = require("@clusterio/lib"); +import assert from "node:assert/strict"; +import { HostDetails } from "@clusterio/lib"; -const { testMatrix, testRoundTripJsonSerialisable } = require("../../common"); +import { testMatrix, testRoundTripJsonSerialisable } from "../../common.js"; describe("lib/data/HostDetails", function () { diff --git a/test/lib/data/InstanceDetails.test.js b/test/lib/data/InstanceDetails.test.js index cb949c18..95058653 100644 --- a/test/lib/data/InstanceDetails.test.js +++ b/test/lib/data/InstanceDetails.test.js @@ -1,8 +1,7 @@ -"use strict"; -const assert = require("assert").strict; -const { InstanceDetails } = require("@clusterio/lib"); +import assert from "node:assert/strict"; +import { InstanceDetails } from "@clusterio/lib"; -const { testMatrix, testRoundTripJsonSerialisable } = require("../../common"); +import { testMatrix, testRoundTripJsonSerialisable } from "../../common.js"; describe("lib/data/InstanceDetails", function () { diff --git a/test/lib/data/ModInfo.js b/test/lib/data/ModInfo.js index a90a5e4d..68926e4a 100644 --- a/test/lib/data/ModInfo.js +++ b/test/lib/data/ModInfo.js @@ -1,13 +1,12 @@ -"use strict"; -const assert = require("assert").strict; -const fs = require("node:fs/promises"); -const path = require("path"); +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import path from "node:path"; -const lib = require("@clusterio/lib"); -const libBuildMod = require("@clusterio/lib/build_mod"); -const { ModInfo, ModDependency } = lib; +import * as lib from "@clusterio/lib"; +import * as libBuildMod from "@clusterio/lib/build_mod.js"; +import { ModInfo, ModDependency } from "@clusterio/lib"; -const { testMatrix, testRoundTripJsonSerialisable } = require("../../common"); +import { testMatrix, testRoundTripJsonSerialisable } from "../../common.js"; describe("lib/data/ModInfo", function() { describe("class ModDependency", function() { diff --git a/test/lib/data/ModPack.js b/test/lib/data/ModPack.js index b813374b..ec21817a 100644 --- a/test/lib/data/ModPack.js +++ b/test/lib/data/ModPack.js @@ -1,9 +1,8 @@ -"use strict"; -const assert = require("assert").strict; -const zlib = require("zlib"); +import assert from "node:assert/strict"; +import zlib from "node:zlib"; -const lib = require("@clusterio/lib"); -const { ModInfo, ModPack, getInstalledModUpdates, applyModRecordAdvisories } = lib; +import * as lib from "@clusterio/lib"; +import { ModInfo, ModPack, getInstalledModUpdates, applyModRecordAdvisories } from "@clusterio/lib"; describe("lib/data/ModPack", function() { diff --git a/test/lib/data/Role.test.js b/test/lib/data/Role.test.js index c0f0d06c..bc908152 100644 --- a/test/lib/data/Role.test.js +++ b/test/lib/data/Role.test.js @@ -1,6 +1,5 @@ -"use strict"; -const assert = require("assert").strict; -const lib = require("@clusterio/lib"); +import assert from "node:assert/strict"; +import * as lib from "@clusterio/lib"; describe("lib/data/Role", function() { let defaultPermissions; diff --git a/test/lib/data/SystemInfo.js b/test/lib/data/SystemInfo.js index 7ee0b74f..d94cc406 100644 --- a/test/lib/data/SystemInfo.js +++ b/test/lib/data/SystemInfo.js @@ -1,8 +1,6 @@ -"use strict"; -const assert = require("assert").strict; +import assert from "node:assert/strict"; -const lib = require("@clusterio/lib"); -const { SystemInfo } = lib; +import { SystemInfo } from "@clusterio/lib"; describe("lib/data/SystemInfo", function() { diff --git a/test/lib/data/UserDetails.test.js b/test/lib/data/UserDetails.test.js index e155c4df..fa9b45ba 100644 --- a/test/lib/data/UserDetails.test.js +++ b/test/lib/data/UserDetails.test.js @@ -1,8 +1,7 @@ -"use strict"; -const assert = require("assert").strict; -const { UserDetails, PlayerStats } = require("@clusterio/lib"); +import assert from "node:assert/strict"; +import { UserDetails, PlayerStats } from "@clusterio/lib"; -const { testMatrix, testRoundTripJsonSerialisable } = require("../../common"); +import { testMatrix, testRoundTripJsonSerialisable } from "../../common.js"; describe("lib/data/UserDetails", function () { it("should be round trip serialisable", function () { diff --git a/test/lib/data/messages_core.js b/test/lib/data/messages_core.js index 2e06c673..461f913d 100644 --- a/test/lib/data/messages_core.js +++ b/test/lib/data/messages_core.js @@ -1,7 +1,5 @@ -"use strict"; -const assert = require("assert").strict; -const lib = require("@clusterio/lib"); -const { Address } = lib; +import assert from "node:assert/strict"; +import { Address } from "@clusterio/lib"; describe("lib/data/messages_core", function() { describe("class Address", function() { diff --git a/test/lib/data/version.test.js b/test/lib/data/version.test.js index 92bba3e9..ed43ca96 100644 --- a/test/lib/data/version.test.js +++ b/test/lib/data/version.test.js @@ -1,8 +1,7 @@ -"use strict"; -const assert = require("assert").strict; -const lib = require("@clusterio/lib"); +import assert from "node:assert/strict"; +import * as lib from "@clusterio/lib"; -const { testMatrix, testRoundTripJsonSerialisable } = require("../../common"); +import { testMatrix, testRoundTripJsonSerialisable } from "../../common.js"; describe("lib/data/version", function() { describe("isVersionEquality()", function() { diff --git a/test/lib/database.js b/test/lib/database.js index 4f01c942..b134eab5 100644 --- a/test/lib/database.js +++ b/test/lib/database.js @@ -1,9 +1,8 @@ -"use strict"; -const assert = require("assert").strict; -const fs = require("node:fs/promises"); -const path = require("path"); +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import path from "node:path"; -const lib = require("@clusterio/lib"); +import * as lib from "@clusterio/lib"; describe("lib/database", function() { describe("mapToObject()", function() { diff --git a/test/lib/datastore.js b/test/lib/datastore.js index 40a97ead..b4dc5cee 100644 --- a/test/lib/datastore.js +++ b/test/lib/datastore.js @@ -1,9 +1,8 @@ -"use strict"; -const events = require("events"); -const assert = require("assert").strict; -const lib = require("@clusterio/lib"); -const fs = require("node:fs/promises"); -const path = require("path"); +import events from "node:events"; +import assert from "node:assert/strict"; +import * as lib from "@clusterio/lib"; +import fs from "node:fs/promises"; +import path from "node:path"; class MockDatastoreProvider extends lib.DatastoreProvider { constructor() { diff --git a/test/lib/errors.js b/test/lib/errors.js index 5692d0a2..1cb4d296 100644 --- a/test/lib/errors.js +++ b/test/lib/errors.js @@ -1,7 +1,6 @@ -"use strict"; -const assert = require("assert").strict; +import assert from "node:assert/strict"; -const lib = require("@clusterio/lib"); +import * as lib from "@clusterio/lib"; describe("lib/errors", function() { describe("class InvalidMessage", function() { diff --git a/test/lib/external/FactorioVersions.test.js b/test/lib/external/FactorioVersions.test.js index b92d03a7..f63554d9 100644 --- a/test/lib/external/FactorioVersions.test.js +++ b/test/lib/external/FactorioVersions.test.js @@ -1,8 +1,8 @@ -const assert = require("assert").strict; -const { readFile } = require("node:fs/promises"); +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; -const { fetchFactorioVersions } = require("@clusterio/lib"); -const { slowTest, externalTest } = require("../../integration"); +import { fetchFactorioVersions } from "@clusterio/lib"; +import { slowTest, externalTest } from "../../integration/index.js"; describe("FactorioVersions", function() { describe("fetchFactorioVersions", function() { diff --git a/test/lib/external/LatestReleases.test.js b/test/lib/external/LatestReleases.test.js index 4fb79eff..686b6b52 100644 --- a/test/lib/external/LatestReleases.test.js +++ b/test/lib/external/LatestReleases.test.js @@ -1,7 +1,7 @@ -const assert = require("assert").strict; +import assert from "node:assert/strict"; -const { fetchLatestReleases, resolveReleaseChannel } = require("@clusterio/lib"); -const { slowTest, externalTest } = require("../../integration"); +import { fetchLatestReleases, resolveReleaseChannel } from "@clusterio/lib"; +import { slowTest, externalTest } from "../../integration/index.js"; describe("LatestReleases", function() { describe("fetchLatestReleases", function() { diff --git a/test/lib/factorio/exchange_string.js b/test/lib/factorio/exchange_string.js index eaea8458..9ea118dc 100644 --- a/test/lib/factorio/exchange_string.js +++ b/test/lib/factorio/exchange_string.js @@ -1,11 +1,10 @@ -"use strict"; -const assert = require("assert").strict; -const zlib = require("zlib"); +import assert from "node:assert/strict"; +import zlib from "node:zlib"; -const lib = require("@clusterio/lib"); +import * as lib from "@clusterio/lib"; -const testStrings = require("./test_strings"); -const testSettings = require("./test_settings"); +import * as testStrings from "./test_strings.js"; +import * as testSettings from "./test_settings.js"; // Simple linear congruential random number generator diff --git a/test/lib/factorio/lines.js b/test/lib/factorio/lines.js index f520c742..498150d6 100644 --- a/test/lib/factorio/lines.js +++ b/test/lib/factorio/lines.js @@ -1,7 +1,5 @@ // Lines for testing the factorio output - -"use strict"; -let testLines = new Map([ +export const testLines = new Map([ [ " 1.306 Info RemoteCommandProcessor.cpp:131: Starting RCON interface at port 4000", { @@ -219,7 +217,3 @@ let testLines = new Map([ }, ], ]); - -module.exports = { - testLines, -}; diff --git a/test/lib/factorio/test_settings.js b/test/lib/factorio/test_settings.js index fea7eb77..c1d397f4 100644 --- a/test/lib/factorio/test_settings.js +++ b/test/lib/factorio/test_settings.js @@ -1,6 +1,5 @@ -"use strict"; -const default_map_gen_settings = { +export const default_map_gen_settings = { autoplace_controls: { "coal": { frequency: 1, size: 1, richness: 1 }, "copper-ore": { frequency: 1, size: 1, richness: 1 }, @@ -37,7 +36,7 @@ const default_map_gen_settings = { water: 1, }; -const default_map_settings = { +export const default_map_settings = { pollution: { enabled: true, diffusion_ratio: 0.02, @@ -140,7 +139,7 @@ const default_map_settings = { }, }; -const modified_map_gen_settings = { +export const modified_map_gen_settings = { autoplace_controls: { "coal": { frequency: 1, size: 1, richness: 1 }, "copper-ore": { frequency: 0.5, size: 1, richness: 1 }, @@ -196,7 +195,7 @@ const modified_map_gen_settings = { water: 0.3333333432674408, }; -const modified_map_settings = { +export const modified_map_settings = { pollution: { enabled: true, diffusion_ratio: 0.02, @@ -299,7 +298,7 @@ const modified_map_settings = { }, }; -const modified_v2_map_gen_settings = { +export const modified_v2_map_gen_settings = { autoplace_controls: { "coal": { frequency: 1, size: 1, richness: 1 }, "copper-ore": { frequency: 0.5, size: 1, richness: 1 }, @@ -362,7 +361,7 @@ const modified_v2_map_gen_settings = { territory_settings: null, }; -const modified_v2_map_settings = { +export const modified_v2_map_settings = { pollution: { enabled: true, diffusion_ratio: 0.02, @@ -467,7 +466,7 @@ const modified_v2_map_settings = { }, }; -const modified_v2_1_map_gen_settings = { +export const modified_v2_1_map_gen_settings = { "autoplace_controls": { "coal": { "frequency": 1, "size": 1, "richness": 1 }, "copper-ore": { "frequency": 0.5, "size": 1, "richness": 1 }, @@ -516,7 +515,7 @@ const modified_v2_1_map_gen_settings = { "territory_settings": null, }; -const modified_v2_1_map_settings = { +export const modified_v2_1_map_settings = { "pollution": { "enabled": true, "diffusion_ratio": 0.02, @@ -615,7 +614,7 @@ const modified_v2_1_map_settings = { }, }; -const modified_v2_1_13_map_settings = { +export const modified_v2_1_13_map_settings = { ...modified_v2_1_map_settings, "enemy_expansion": { ...modified_v2_1_map_settings.enemy_expansion, @@ -623,7 +622,7 @@ const modified_v2_1_13_map_settings = { }, }; -const modified_space_age_map_gen_settings = { +export const modified_space_age_map_gen_settings = { autoplace_controls: { "aquilo_crude_oil": { frequency: 1, size: 1, richness: 1 }, "calcite": { frequency: 1, size: 1, richness: 1 }, @@ -701,16 +700,3 @@ const modified_space_age_map_gen_settings = { no_enemies_mode: false, territory_settings: null, }; - -module.exports = { - default_map_gen_settings, - default_map_settings, - modified_map_gen_settings, - modified_map_settings, - modified_v2_map_gen_settings, - modified_v2_map_settings, - modified_v2_1_map_gen_settings, - modified_v2_1_map_settings, - modified_v2_1_13_map_settings, - modified_space_age_map_gen_settings, -}; diff --git a/test/lib/factorio/test_strings.js b/test/lib/factorio/test_strings.js index eebad109..331993e9 100644 --- a/test/lib/factorio/test_strings.js +++ b/test/lib/factorio/test_strings.js @@ -1,7 +1,6 @@ -"use strict"; // Exchange strings for testing with -const default_ = ` +export default ` >>>eNpjZGBkUGQAgwZ7EOZgSc5PzIHxQJgrOb+gILVIN78oFVmYM 7moNCVVNz8TVXFqXmpupW5SYjGKYo7Movw8dBNYi0vy81BFSopSU 4uRRbhLixLzMktz0fUyMF5imubZ0CLHAML/6xkU/v8HYSDrAdAvI @@ -14,7 +13,7 @@ MPlQMQJEAG2EO4yRgjTod+B0UEeJiuJUALUb8SA7IYUhA9Pwqw9j GQ/mkMwIwLZH2giKg5YooELZGEKnHjBDHcNMDwvsMN4DvMdGJlBD JCqL0AxCA8kAzMKQgs4gIObmQEBgGmjrO/lHgDxa6G4<<<`; -const modified = ` +export const modified = ` >>>eNp1UzFs00AUvZ8mbRIEypCBSlCCyFAhOQqBKULxlQUxMCCxgnCc S2rh2On5LAgMZOjAgISEurQLXSmCBTGwRWKhEkgIJraiMgBiaGmFOiC FO5/PsUz6pfv+9/6/99+/kwEBOo2k6c83NhrZtOkaNkIDXa286fZ6hG @@ -31,7 +30,7 @@ jnh3gmXQRElVwYyxI8x4FMqOzsu4edrKK6hNZ5wU7V9G+ufEFL+7yHi cySQMp7wDHnRsBW571ORGn6fH2fUDj/FwV0iUbXPMbmTP7akkt8ChiL /BPceEs3j1M3iz71/ObT9tA==<<<`; -const modified_v2 = ` +export const modified_v2 = ` >>>eNp1Uz9oU0EY/y5N2iRWzRCFitSAGURIjNEpSN7VRRwcBFeJl5dL +ujLe+3de9XoYIYODoogXdrFrlZ0EQe3gIsFBdHJrVIHBYdWi3QQ4l3 u3csz1Q++7333+31/75IYAJwRCpNx0yU2QNfQmjbd+XnKCi6jgg/hlM @@ -49,7 +48,7 @@ Ln6IET6h2alhiMgvQ3SGxnDDDd32daT/yCD5fQ8R3WMEyeN/PENaNmy E5utYOI24z/cT+oQf48FdgozaFZg6qX+1KqW+GYyy4qNZgFNY/0IAHj RI5Q/27QvL<<<`; -const modified_v2_1 = ` +export const modified_v2_1 = ` >>>eNp1U89rE0EUftM0NYlVc4iCIjWHHkRICNFTkezoRTx4ELxKnGwm unSz087uVlsP5uDBg1CQXvSiVyt6EY9CwIu9if4DlXqwt1aK9CDEeZm dyZrgg/f49nu/37BTQCAPGYDZaVcwH6DnGC24YmmJy4qQHAAsnXdl3O @@ -65,7 +64,7 @@ JYkZplFakZG9jCkjt5gyQ4nHFv3qqTPmsjWiYeiVKOkP57RB9xB0Dvk X9skPmJ46b3GGNscC4FCtiwbc3PTPrUX46YL/qSDp8BMOqAJI8C+u/S peyl8e7GC3Ce2le3AIs019Y//gU3lOV1<<<`; -const modified_space_age = ` +export const modified_space_age = ` >>>eNp1VEFoE0EUnUkb27TWBomFSqkBexAhMVZPRbJbL+LBg+BV1slm kgzd7LYzs9HYgz304EEQpJf2Yq9W9CIevBW8WFAQPXmr1IOCh1aL9CD UmZ2dzW4aB2by972Z9//7MyQFALggJpjIogWfOJ5lU7+KLY84ACwZeg @@ -86,7 +85,7 @@ Gx0+5gy4RUpPimXd3JJg0hSVAZVaD42oXlGs+OdLeL8NIjXUO043NJp 38bydxUydeQi4j66kCmzxzUMyYTVaPneF1Uj+vlxQH+ZT82gl0Du2he Y+lJ/JEpK/WZNmBM/mgXgnKlfCACLzljjH+s/hYo=<<<`; -const modified_v2_1_13 = ` +export const modified_v2_1_13 = ` >>>eNp1U89rE0EUftMmNYm15hAFRWoOPYiQEKKgFMmOXsSDB0HwJHGy mejSzU47u1utHszBgwdBkF70olcrehHvAS/2JvUfqNSDvbVSpAehzsv sTNYEH7zHt9/7/YadAAIzSmE64wrmA/QcowVXLC5yWRGSA4Cl866M27 @@ -101,12 +100,3 @@ JYkZpmFakZGd1AkjtxhSQjYziizPKvHmmTPmUDWuYoiVKOgP57RB9yS 0Dvo1NMEfJJWw1i+YLmuywM1C1jwbPKaGnjffEMETl1yE9Qxu/9C7rp u3nVP+RQebGLpzeY4SxwbkUKGDDtjU/J9P3/nrIfNHXdPAWgFF7JHkZ 0L+YLmXPjcc3XoAz1D69BVjk1sULxb83nuUA<<<`; - -module.exports = { - default: default_, - modified, - modified_v2, - modified_v2_1, - modified_v2_1_13, - modified_space_age, -}; diff --git a/test/lib/file_ops.js b/test/lib/file_ops.js index 581cff94..2f4993e7 100644 --- a/test/lib/file_ops.js +++ b/test/lib/file_ops.js @@ -1,11 +1,10 @@ -"use strict"; -const assert = require("assert").strict; -const fs = require("node:fs/promises"); -const http = require("node:http"); -const path = require("path"); -const util = require("node:util"); +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import http from "node:http"; +import path from "node:path"; +import util from "node:util"; -const lib = require("@clusterio/lib"); +import * as lib from "@clusterio/lib"; describe("lib/file_ops", function() { let baseDir = path.join("temp", "test", "file_ops"); @@ -101,6 +100,16 @@ describe("lib/file_ops", function() { await assert.rejects(fs.access(target.replace(".txt", ".tmp.txt")), "temporary was left behind"); assert.equal(await fs.readFile(target, "utf8"), "current"); }); + it("should apply mode to a leftover temporary file", async function() { + if (process.platform === "win32") { + this.skip(); + } + let target = path.join(baseDir, "safe", "mode.txt"); + await fs.writeFile(target.replace(".txt", ".tmp.txt"), "stale", { mode: 0o644 }); + await lib.safeOutputFile(target, "private", { mode: 0o600 }); + assert.equal((await fs.stat(target)).mode & 0o777, 0o600); + assert.equal(await fs.readFile(target, "utf8"), "private"); + }); it("should handle creating file in current working directory", async function() { let target = "temporary-file-made-to-test-cwd.txt"; try { diff --git a/test/lib/hash.js b/test/lib/hash.js index 726c45eb..6ee9dc74 100644 --- a/test/lib/hash.js +++ b/test/lib/hash.js @@ -1,9 +1,8 @@ -"use strict"; -const assert = require("assert").strict; -const fs = require("node:fs/promises"); -const path = require("path"); +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import path from "node:path"; -const lib = require("@clusterio/lib"); +import * as lib from "@clusterio/lib"; describe("lib/hash", function() { diff --git a/test/lib/helpers.js b/test/lib/helpers.js index 27486882..2517ab6a 100644 --- a/test/lib/helpers.js +++ b/test/lib/helpers.js @@ -1,8 +1,7 @@ -"use strict"; -const assert = require("assert").strict; +import assert from "node:assert/strict"; const hrtime = process.hrtime.bigint; -const lib = require("@clusterio/lib"); +import * as lib from "@clusterio/lib"; describe("lib/helpers", function() { diff --git a/test/lib/ini.js b/test/lib/ini.js index b600df7c..7c1e57e0 100644 --- a/test/lib/ini.js +++ b/test/lib/ini.js @@ -1,7 +1,6 @@ -"use strict"; -const assert = require("assert").strict; -const fs = require("node:fs/promises"); -const lib = require("@clusterio/lib"); +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import * as lib from "@clusterio/lib"; describe("lib/ini", function() { describe("parse()", function() { diff --git a/test/lib/link/connector.js b/test/lib/link/connector.js index bbfbf39c..851b9e78 100644 --- a/test/lib/link/connector.js +++ b/test/lib/link/connector.js @@ -1,9 +1,8 @@ -"use strict"; -const assert = require("assert").strict; -const events = require("events"); +import assert from "node:assert/strict"; +import events from "node:events"; -const mock = require("../../mock"); -const lib = require("@clusterio/lib"); +import * as mock from "../../mock.js"; +import * as lib from "@clusterio/lib"; describe("lib/link/connectors", function() { diff --git a/test/lib/link/link.js b/test/lib/link/link.js index 112ae492..b6bec464 100644 --- a/test/lib/link/link.js +++ b/test/lib/link/link.js @@ -1,9 +1,8 @@ -"use strict"; -const assert = require("assert").strict; -const events = require("events"); +import assert from "node:assert/strict"; +import events from "node:events"; -const lib = require("@clusterio/lib"); -const mock = require("../../mock"); +import * as lib from "@clusterio/lib"; +import * as mock from "../../mock.js"; const addr = lib.Address.fromShorthand; diff --git a/test/lib/logging_utils.js b/test/lib/logging_utils.js index 919714e7..ba159555 100644 --- a/test/lib/logging_utils.js +++ b/test/lib/logging_utils.js @@ -1,10 +1,9 @@ -"use strict"; -const assert = require("assert").strict; -const fs = require("node:fs/promises"); -const path = require("path"); +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import path from "node:path"; -const { testLines } = require("./factorio/lines"); -const lib = require("@clusterio/lib"); +import { testLines } from "./factorio/lines.js"; +import * as lib from "@clusterio/lib"; describe("lib/logging_utils.js", function() { diff --git a/test/lib/lua_tools.js b/test/lib/lua_tools.js index 8b70b274..64c49e80 100644 --- a/test/lib/lua_tools.js +++ b/test/lib/lua_tools.js @@ -1,7 +1,6 @@ -"use strict"; -const assert = require("assert").strict; +import assert from "node:assert/strict"; -const lib = require("@clusterio/lib"); +import * as lib from "@clusterio/lib"; describe("lib/lua_tools", function() { diff --git a/test/lib/plugin.js b/test/lib/plugin.js index 30587b30..176a3bf6 100644 --- a/test/lib/plugin.js +++ b/test/lib/plugin.js @@ -1,37 +1,27 @@ -"use strict"; -const assert = require("assert").strict; +import assert from "node:assert/strict"; -const mock = require("../mock"); -const lib = require("@clusterio/lib"); +import * as lib from "@clusterio/lib"; describe("lib/plugin", function() { - describe("invokeHook()", function() { - let betaTestCalled = false; - let plugins = new Map([ - ["alpha", { - test: async function() { }, - pass: async function(arg) { return arg; }, - error: async function() { throw new Error("Test"); }, - logger: new mock.MockLogger(), - }], - ["beta", { - test: async function() { betaTestCalled = true; }, - pass: async function() { }, - error: async function() { }, - logger: new mock.MockLogger(), - }], - ]); - it("should invoke the hook on the plugin", async function() { - await lib.invokeHook(plugins, "test"); - assert(betaTestCalled, "Hook was not called"); + describe("pluginNeedsWebBuild()", function() { + it("should be false for plugins with only host, instance or ctl code", function() { + assert.equal(lib.pluginNeedsWebBuild({ name: "foo", title: "Foo" }), false); + assert.equal(lib.pluginNeedsWebBuild({ + name: "foo", title: "Foo", hostEntrypoint: "host.js", instanceEntrypoint: "instance.js", + ctlEntrypoint: "ctl.js", messages: [], + }), false); }); - it("should pass and return args", async function() { - let result = await lib.invokeHook(plugins, "pass", 1234); - assert.deepEqual(result, [1234]); - }); - it("should ignore errors", async function() { - await lib.invokeHook(plugins, "error"); + it("should be true for plugins with web or controller code or config fields", function() { + assert.equal(lib.pluginNeedsWebBuild({ name: "foo", title: "Foo", webEntrypoint: "web.js" }), true); + assert.equal(lib.pluginNeedsWebBuild({ + name: "foo", title: "Foo", controllerEntrypoint: "controller.js", + }), true); + for (const field of [ + "controllerConfigFields", "hostConfigFields", "instanceConfigFields", "controlConfigFields", + ]) { + assert.equal(lib.pluginNeedsWebBuild({ name: "foo", title: "Foo", [field]: {} }), true, field); + } }); }); }); diff --git a/test/lib/plugin_loader.js b/test/lib/plugin_loader.js index 02476e96..412638ff 100644 --- a/test/lib/plugin_loader.js +++ b/test/lib/plugin_loader.js @@ -1,10 +1,11 @@ -"use strict"; -const assert = require("assert").strict; -const fs = require("node:fs/promises"); -const path = require("path"); +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; -const lib = require("@clusterio/lib"); -const { BaseControllerPlugin } = require("@clusterio/controller"); +import * as mock from "../mock.js"; +import * as lib from "@clusterio/lib"; +import { BaseControllerPlugin } from "@clusterio/controller"; describe("lib/plugin_loader", function() { @@ -19,7 +20,7 @@ describe("lib/plugin_loader", function() { await fs.mkdir(pluginPath, { recursive: true }); await fs.writeFile( path.join(pluginPath, "index.js"), - `module.exports.plugin = { name: "${infoName}" };` + `export const plugin = { name: "${infoName}" };` ); await fs.writeFile( path.join(pluginPath, "package.json"), @@ -47,6 +48,7 @@ describe("lib/plugin_loader", function() { name: "test", version: "0.0.1", npmPackage: undefined, + packagePath: path.resolve(path.join(testPlugin, "package.json")), requirePath: path.resolve(testPlugin), webStaticPath: path.resolve(path.join(testPlugin, "dist", "web", "static")), }] @@ -55,7 +57,7 @@ describe("lib/plugin_loader", function() { it("should reject on broken plugin", async function() { let brokenMessage; try { - require(path.resolve(brokenPlugin)); + await import(pathToFileURL(path.resolve(path.join(brokenPlugin, "index.js")))); } catch (err) { brokenMessage = err.message; } @@ -71,51 +73,104 @@ describe("lib/plugin_loader", function() { ); }); }); - describe("loadPluginClass()", function() { - let baseDir = path.join("temp", "test", "plugin"); - let missingClass = path.join(baseDir, "missing_class_plugin"); - let wrongParentClass = path.join(baseDir, "wrong_parent_class_plugin"); + describe("loadPlugin()", function() { + const baseDir = path.join("temp", "test", "plugin"); + const functionPlugin = path.resolve(baseDir, "function_plugin"); + const classPlugin = path.resolve(baseDir, "class_plugin"); + const throwingClassPlugin = path.resolve(baseDir, "throwing_class_plugin"); + const missingClassPlugin = path.resolve(baseDir, "missing_class_plugin"); + const wrongParentClassPlugin = path.resolve(baseDir, "wrong_parent_class_plugin"); + let controller; + let logger; + let context; + before(async function() { async function writeEntrypoint(pluginPath, content) { await fs.mkdir(pluginPath, { recursive: true }); await fs.writeFile(path.join(pluginPath, "controller.js"), content); } - await writeEntrypoint(missingClass, ""); - await writeEntrypoint( - wrongParentClass, "class ControllerPlugin {}\n module.exports = { ControllerPlugin };\n" + await writeEntrypoint(functionPlugin, ` + export default async function(context) { + context.controller.loadedWith = context; + } + `); + await writeEntrypoint(classPlugin, ` + import { BaseControllerPlugin } from "@clusterio/controller"; + export class ControllerPlugin extends BaseControllerPlugin { + async init() { this.controller.loadedWith = this; } + async onSaveData() { } + } + `); + await writeEntrypoint(throwingClassPlugin, ` + import { BaseControllerPlugin } from "@clusterio/controller"; + export class ControllerPlugin extends BaseControllerPlugin { + async init() { throw new Error("init failed"); } + async onSaveData() { } + } + `); + await writeEntrypoint(missingClassPlugin, ""); + await writeEntrypoint(wrongParentClassPlugin, "export class ControllerPlugin {};\n"); + }); + + beforeEach(function() { + controller = new mock.MockController(); + logger = new mock.MockLogger(); + logger.warnings = []; + logger.warn = msg => logger.warnings.push(msg); + context = { controller, metrics: {}, logger }; + }); + + function info(requirePath, entrypoint = "controller.js") { + return { name: "test", requirePath, controllerEntrypoint: entrypoint }; + } + + it("should do nothing when the entrypoint is not set", async function() { + await lib.loadPlugin( + { name: "test", requirePath: missingClassPlugin }, "controller", context, + "ControllerPlugin", BaseControllerPlugin, + ); + assert.equal(controller.loadedWith, undefined); + }); + it("should call the default export with the load context", async function() { + const pluginInfo = info(functionPlugin); + await lib.loadPlugin(pluginInfo, "controller", context, "ControllerPlugin", BaseControllerPlugin); + assert.equal(controller.loadedWith.controller, controller); + assert.equal(controller.loadedWith.plugin, pluginInfo); + assert.equal(controller.loadedWith.logger, logger); + assert.deepEqual(logger.warnings, []); + }); + it("should load a deprecated class export", async function() { + const pluginInfo = info(classPlugin); + await lib.loadPlugin(pluginInfo, "controller", context, "ControllerPlugin", BaseControllerPlugin); + assert(controller.loadedWith instanceof BaseControllerPlugin); + assert.equal(controller.loadedWith.info, pluginInfo); + assert.deepEqual([...controller.hooks.save.attached], ["test"]); + assert.deepEqual(logger.warnings, ["Plugin test is using deprecated class export"]); + }); + it("should detach hooks if init throws", async function() { + await assert.rejects( + lib.loadPlugin( + info(throwingClassPlugin), "controller", context, "ControllerPlugin", BaseControllerPlugin + ), + new Error("init failed") + ); + assert.equal(controller.hooks.size, 0); + }); + it("should throw if neither a function nor a class is exported", async function() { + await assert.rejects( + lib.loadPlugin( + info(missingClassPlugin), "controller", context, "ControllerPlugin", BaseControllerPlugin + ), + new Error("Plugin test must export either a default function or ControllerPlugin class") ); }); - it("should throw if class is missing from entrypoint", async function() { - const requirePath = path.resolve(missingClass); + it("should throw if the class is not a subclass of the base class", async function() { await assert.rejects( - lib.loadPluginClass( - "test", - path.posix.join(requirePath, "controller"), - "ControllerPlugin", - BaseControllerPlugin, + lib.loadPlugin( + info(wrongParentClassPlugin), "controller", context, "ControllerPlugin", BaseControllerPlugin ), - { - message: - `PluginError: Expected ${path.posix.join(requirePath, "controller")} ` + - "to export a class named ControllerPlugin", - } - ); - }); - it("should throw if class is not a subclass of BaseControllerPlugin", async function() { - const requirePath = path.resolve(wrongParentClass); - await assert.rejects( - lib.loadPluginClass( - "test", - path.posix.join(requirePath, "controller"), - "ControllerPlugin", - BaseControllerPlugin, - ), - { - message: - "PluginError: Expected ControllerPlugin exported from " + - `${path.posix.join(requirePath, "controller")} to be a subclass of BaseControllerPlugin`, - } + new Error("Expected ControllerPlugin exported from test to extend BaseControllerPlugin") ); }); }); @@ -151,7 +206,7 @@ describe("lib/plugin_loader", function() { await fs.mkdir(pluginPath, { recursive: true }); await fs.writeFile( path.join(pluginPath, "index.js"), - `module.exports.plugin = { name: "${name}" };` + `export const plugin = { name: "${name}" };` ); await fs.writeFile( path.join(pluginPath, "package.json"), @@ -194,7 +249,7 @@ describe("lib/plugin_loader", function() { await fs.mkdir(path.join(deepPluginPath, "dist")); await fs.writeFile( path.join(deepPluginPath, "dist", "index.js"), - 'module.exports.plugin = { name: "deep" };' + 'export const plugin = { name: "deep" };' ); // Create an npm module that is not a plugin diff --git a/test/lib/prometheus.js b/test/lib/prometheus.js index 8ef24147..0dcc4d93 100644 --- a/test/lib/prometheus.js +++ b/test/lib/prometheus.js @@ -1,7 +1,6 @@ -"use strict"; -const assert = require("assert").strict; +import assert from "node:assert/strict"; -const prometheus = require("@clusterio/lib"); +import * as prometheus from "@clusterio/lib"; describe("lib/prometheus", function() { diff --git a/test/lib/rce_ops.js b/test/lib/rce_ops.js index c5b272b7..8ba32661 100644 --- a/test/lib/rce_ops.js +++ b/test/lib/rce_ops.js @@ -1,6 +1,5 @@ -"use strict"; -const assert = require("assert").strict; -const lib = require("@clusterio/lib"); +import assert from "node:assert/strict"; +import * as lib from "@clusterio/lib"; describe("rce_ops", function() { describe("updatePackage", function() { @@ -29,6 +28,20 @@ describe("rce_ops", function() { }); }); + describe("handleUpdateAll", function() { + it("updates the core package and all npm plugins in one command", async function() { + const _audit = lib.logger.audit; + const messages = []; + lib.logger.audit = (msg) => { messages.push(msg); }; + try { + await lib.handleUpdateAll("core", [{ npmPackage: "foo" }, { name: "local" }, { npmPackage: "bar" }]); + } finally { + lib.logger.audit = _audit; + } + assert.deepEqual(messages, ["RCE | npm update --save core foo bar"]); + }); + }); + describe("handlePluginInstall", function() { const _fetch = global.fetch; before(function() { diff --git a/test/lib/stream.js b/test/lib/stream.js index 144e7c06..09b8ee43 100644 --- a/test/lib/stream.js +++ b/test/lib/stream.js @@ -1,11 +1,10 @@ -"use strict"; -const assert = require("assert").strict; -const fs = require("node:fs/promises"); -const path = require("path"); -const stream = require("stream"); -const util = require("util"); +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import path from "node:path"; +import stream from "node:stream"; +import util from "node:util"; -const lib = require("@clusterio/lib"); +import * as lib from "@clusterio/lib"; const finished = util.promisify(stream.finished); diff --git a/test/lib/subscriptions.test.js b/test/lib/subscriptions.test.js index bd556487..97e7f943 100644 --- a/test/lib/subscriptions.test.js +++ b/test/lib/subscriptions.test.js @@ -1,9 +1,8 @@ -"use strict"; -const events = require("events"); -const assert = require("assert").strict; -const lib = require("@clusterio/lib"); -const { MockController, MockConnector, MockControl } = require("../mock"); -const { testMatrix, testRoundTripJsonSerialisable } = require("../common"); +import events from "node:events"; +import assert from "node:assert/strict"; +import * as lib from "@clusterio/lib"; +import { MockController, MockConnector, MockControl } from "../mock.js"; +import { testMatrix, testRoundTripJsonSerialisable } from "../common.js"; const addr = lib.Address.fromShorthand; diff --git a/test/lib/users.js b/test/lib/users.js index bb636191..e8bd2479 100644 --- a/test/lib/users.js +++ b/test/lib/users.js @@ -1,6 +1,5 @@ -"use strict"; -const assert = require("assert").strict; -const lib = require("@clusterio/lib"); +import assert from "node:assert/strict"; +import * as lib from "@clusterio/lib"; describe("lib/users", function() { @@ -44,4 +43,45 @@ describe("lib/users", function() { ); }); }); + + describe("registerPluginPermissions()", function() { + it("should define the permissions declared by plugins", function() { + lib.registerPluginPermissions([ + { name: "foo", permissions: [ + { name: "foo.first", title: "First", description: "The first" }, + { name: "foo.second", title: "Second", description: "The second", grantByDefault: true }, + ]}, + { name: "bar" }, + { name: "baz", permissions: [] }, + ]); + assert(lib.permissions.has("foo.first"), "foo.first was not defined"); + assert.equal(lib.permissions.get("foo.first").grantByDefault, false); + assert(lib.permissions.has("foo.second"), "foo.second was not defined"); + assert.equal(lib.permissions.get("foo.second").grantByDefault, true); + }); + + it("should throw if the name does not start with the plugin name", function() { + assert.throws( + () => lib.registerPluginPermissions([ + { name: "foo", permissions: [{ name: "bar.spam", title: "Spam", description: "Spam" }] }, + ]), + new Error("Expected name of permission 'bar.spam' for foo to start with 'foo.'") + ); + assert.throws( + () => lib.registerPluginPermissions([ + { name: "foo", permissions: [{ title: "Spam", description: "Spam" }] }, + ]), + new Error("Expected name of permission 'undefined' for foo to start with 'foo.'") + ); + }); + + it("should throw on already defined permission", function() { + assert.throws( + () => lib.registerPluginPermissions([ + { name: "foo", permissions: [{ name: "foo.first", title: "First", description: "The first" }] }, + ]), + new Error("Permission 'foo.first' is already defined") + ); + }); + }); }); diff --git a/test/lib/zip_ops.js b/test/lib/zip_ops.js index f9227021..99739d82 100644 --- a/test/lib/zip_ops.js +++ b/test/lib/zip_ops.js @@ -1,8 +1,7 @@ -"use strict"; -const assert = require("assert").strict; -const JSZip = require("jszip"); +import assert from "node:assert/strict"; +import JSZip from "jszip"; -const { findRoot } = require("@clusterio/lib"); +import { findRoot } from "@clusterio/lib"; describe("lib/factorio", function() { diff --git a/test/manual/test_settings_mod_pack.js b/test/manual/test_settings_mod_pack.js index fe2a699f..ae4e4110 100644 --- a/test/manual/test_settings_mod_pack.js +++ b/test/manual/test_settings_mod_pack.js @@ -1,5 +1,4 @@ -"use strict"; -const { ModPack } = require("@clusterio/lib"); +import { ModPack } from "@clusterio/lib"; const pack = new ModPack(); pack.name = "test-settings"; diff --git a/test/messages/controller.test.js b/test/messages/controller.test.js index de74d61c..d876a60c 100644 --- a/test/messages/controller.test.js +++ b/test/messages/controller.test.js @@ -1,12 +1,11 @@ -"use strict"; -const assert = require("assert").strict; -const fs = require("node:fs/promises"); -const os = require("node:os"); -const path = require("node:path"); -const lib = require("@clusterio/lib"); +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import * as lib from "@clusterio/lib"; -const { Controller, ControlConnection } = require("@clusterio/controller"); -const { testMatrix, testRoundTripJsonSerialisable } = require("../common"); +import { Controller, ControlConnection } from "@clusterio/controller"; +import { testMatrix, testRoundTripJsonSerialisable } from "../common.js"; describe("messages/controller", function() { /** @type {Controller} */ diff --git a/test/messages/host.test.js b/test/messages/host.test.js index 285d37c4..fe40256a 100644 --- a/test/messages/host.test.js +++ b/test/messages/host.test.js @@ -1,9 +1,8 @@ -"use strict"; -const assert = require("assert").strict; -const lib = require("@clusterio/lib"); +import assert from "node:assert/strict"; +import * as lib from "@clusterio/lib"; -const { Host } = require("@clusterio/host"); -const { testMatrix, testRoundTripJsonSerialisable } = require("../common"); +import { Host } from "@clusterio/host"; +import { testMatrix, testRoundTripJsonSerialisable } from "../common.js"; describe("messages/host", function() { /** @type {Host} */ diff --git a/test/messages/mod.test.js b/test/messages/mod.test.js index 09d56512..7a80b2eb 100644 --- a/test/messages/mod.test.js +++ b/test/messages/mod.test.js @@ -1,13 +1,12 @@ -"use strict"; -const assert = require("assert").strict; +import assert from "node:assert/strict"; -const lib = require("@clusterio/lib"); -const { ModDependencyResolveRequest, ModDependency, ModInfo } = lib; +import * as lib from "@clusterio/lib"; +import { ModDependencyResolveRequest, ModDependency, ModInfo } from "@clusterio/lib"; -const { testMatrix, testRoundTripJsonSerialisable } = require("../common"); +import { testMatrix, testRoundTripJsonSerialisable } from "../common.js"; -const { Controller, ControlConnection } = require("@clusterio/controller"); -const { externalTest } = require("../integration"); +import { Controller, ControlConnection } from "@clusterio/controller"; +import { externalTest } from "../integration/index.js"; describe("messages/mod", function() { /** @type {Controller} */ diff --git a/test/messages/plugin.test.js b/test/messages/plugin.test.js index e2c7d9d8..45e55048 100644 --- a/test/messages/plugin.test.js +++ b/test/messages/plugin.test.js @@ -1,9 +1,8 @@ -"use strict"; -const assert = require("assert").strict; -const lib = require("@clusterio/lib"); +import assert from "node:assert/strict"; +import * as lib from "@clusterio/lib"; -const { Controller, ControlConnection } = require("@clusterio/controller"); -const { Host } = require("@clusterio/host"); +import { Controller, ControlConnection } from "@clusterio/controller"; +import { Host } from "@clusterio/host"; describe("messages/plugin", function() { /** @type {Host} */ @@ -75,7 +74,7 @@ describe("messages/plugin", function() { description: "Description", npmPackage: "Npm Package", }]; - controller.plugins.set("Name", {}); + controller.loadedPlugins.add(controller.pluginInfos[0]); controller.config.set("Name.load_plugin", true); const response = await controlConnection.handlePluginListRequest(new lib.PluginListRequest()); @@ -90,7 +89,7 @@ describe("messages/plugin", function() { description: "Description", npmPackage: "Npm Package", }]; - host.plugins.set("Name", {}); + host.loadedPlugins.add(host.pluginInfos[0]); host.config.set("Name.load_plugin", true); const response = await host.handlePluginListRequest(new lib.PluginListRequest()); @@ -134,6 +133,61 @@ describe("messages/plugin", function() { }); }); + describe("UpdateAllRequest", function() { + function checkedPermissions(dst) { + const permissions = []; + const user = { checkPermission(permission) { permissions.push(permission); } }; + lib.UpdateAllRequest.permission(user, { dst: lib.Address.fromShorthand(dst) }); + return permissions; + } + it("requires controller and plugin update permissions on the controller", function() { + assert.deepEqual(checkedPermissions("controller"), ["core.controller.update", "core.plugin.update"]); + }); + it("requires host and plugin update permissions on a host", function() { + assert.deepEqual(checkedPermissions({ hostId: 1 }), ["core.host.update", "core.plugin.update"]); + }); + it("runs on the controller", async function() { + controller.pluginInfos = [{ npmPackage: "foo" }]; + controller.config.set("controller.allow_remote_updates", true); + controller.config.set("controller.allow_plugin_updates", true); + await controlConnection.handleUpdateAllRequest(new lib.UpdateAllRequest()); + }); + it("rejects if remote updates are disabled on the controller", async function() { + controller.config.set("controller.allow_remote_updates", false); + await assert.rejects( + controlConnection.handleUpdateAllRequest(new lib.UpdateAllRequest()), + /Remote updates are disabled on this machine/ + ); + }); + it("rejects if plugin updates are disabled on the controller", async function() { + controller.config.set("controller.allow_plugin_updates", false); + await assert.rejects( + controlConnection.handleUpdateAllRequest(new lib.UpdateAllRequest()), + /Plugin updates are disabled on this machine/ + ); + }); + it("runs on a host", async function() { + host.pluginInfos = [{ npmPackage: "foo" }]; + host.config.set("host.allow_remote_updates", true); + host.config.set("host.allow_plugin_updates", true); + await host.handleUpdateAllRequest(new lib.UpdateAllRequest()); + }); + it("rejects if remote updates are disabled on the host", async function() { + host.config.set("host.allow_remote_updates", false); + await assert.rejects( + host.handleUpdateAllRequest(new lib.UpdateAllRequest()), + /Remote updates are disabled on this machine/ + ); + }); + it("rejects if plugin updates are disabled on the host", async function() { + host.config.set("host.allow_plugin_updates", false); + await assert.rejects( + host.handleUpdateAllRequest(new lib.UpdateAllRequest()), + /Plugin updates are disabled on this machine/ + ); + }); + }); + describe("PluginInstallRequest", function() { const _fetch = global.fetch; before(function() { diff --git a/test/mock.js b/test/mock.js index 7cb9901f..0c52dfdc 100644 --- a/test/mock.js +++ b/test/mock.js @@ -1,14 +1,14 @@ -"use strict"; -const events = require("events"); -const http = require("http"); -const express = require("express"); +import events from "node:events"; +import http from "node:http"; +import express from "express"; -const lib = require("@clusterio/lib"); -const { User, UserManager, UserRecord, InstanceManager } = require("@clusterio/controller"); +import * as lib from "@clusterio/lib"; +import { User, UserManager, UserRecord, InstanceManager, ControllerHooks } from "@clusterio/controller"; +import { HostHooks, InstanceHooks } from "@clusterio/host"; const addr = lib.Address.fromShorthand; -class MockLogger { +export class MockLogger { child() { return this; } fatal() { } @@ -26,7 +26,7 @@ class MockLogger { verbose() { } } -class MockSocket { +export class MockSocket { constructor() { this.sentMessages = []; this.events = new Map(); @@ -66,7 +66,7 @@ class MockSocket { } } -class MockConnector extends lib.BaseConnector { +export class MockConnector extends lib.BaseConnector { constructor(src, dst) { super(src, dst); @@ -86,7 +86,7 @@ class MockConnector extends lib.BaseConnector { } } -class MockServer extends events.EventEmitter { +export class MockServer extends events.EventEmitter { constructor() { super(); this.reset(); @@ -116,7 +116,7 @@ class MockServer extends events.EventEmitter { } } -class MockInstance extends lib.Link { +export class MockInstance extends lib.Link { constructor() { super(new MockConnector(addr({ instanceId: 7357 }), addr({ hostId: 1 }))); this.logger = new MockLogger(); @@ -124,6 +124,8 @@ class MockInstance extends lib.Link { this.name = "test"; this.id = 7357; this.status = "running"; + this.hooks = new InstanceHooks(this.logger); + this.loadedPlugins = new Set(); this.mockConfigEntries = new Map([ ["instance.id", 7357], ["factorio.enable_save_patching", true], @@ -144,15 +146,17 @@ class MockInstance extends lib.Link { } } -class MockHost extends lib.Link { +export class MockHost extends lib.Link { constructor() { super(new MockConnector(addr({ hostId: 1 }), addr("controller"))); + this.hooks = new HostHooks(new MockLogger()); + this.loadedPlugins = new Set(); } } -class MockControl extends lib.Link { } +export class MockControl extends lib.Link { } -class MockController { +export class MockController { constructor() { this.app = express(); this.app.locals.controller = this; @@ -193,6 +197,8 @@ class MockController { this.hosts = new lib.KeyValueDatastore(); this.handles = new Map(); + this.hooks = new ControllerHooks(new MockLogger()); + this.loadedPlugins = new Set(); } get authSecret() { @@ -224,7 +230,7 @@ class MockController { } } -async function createControllerPlugin(ControllerPluginClass, info) { +export async function createControllerPlugin(ControllerPluginClass, info) { let controller = new MockController(); let metrics = {}; let logger = new MockLogger(); @@ -233,25 +239,10 @@ async function createControllerPlugin(ControllerPluginClass, info) { return plugin; } -async function createInstancePlugin(InstancePluginClass, info) { +export async function createInstancePlugin(InstancePluginClass, info) { let instance = new MockInstance(); let host = new MockHost(); - let plugin = new InstancePluginClass(info, instance, host); + let plugin = new InstancePluginClass(info, instance, host, new MockLogger()); await plugin.init(); return plugin; } - - -module.exports = { - MockLogger, - MockSocket, - MockConnector, - MockServer, - MockInstance, - MockHost, - MockControl, - MockController, - - createControllerPlugin, - createInstancePlugin, -};