Compare commits

..
Author SHA1 Message Date
Danielv123 3d97b69b07 Add Windows headless installer support 2026-03-11 23:25:02 +01:00
Danielv123 5b4621a7de Add Windows headless update downloads 2026-03-11 23:25:02 +01:00
215 changed files with 4032 additions and 10547 deletions

No files matched your search

+5 -10
View File
@@ -1,15 +1,10 @@
<!--
Describe why and what has been changed by this pull request, feel free to also ask for input on decisions you are unsure about etc.
Describe why and what has been changed by this pullrequest, feel free to also ask for input on decisions you are unsure about etc.
The changelog lines below will be copied directly from merged pull requests to the changelog on new releases. Changes are expected to preferrentially link to the associated issues. If there are no relevant issues, link to the pull request itself.
The changelog lines below will be copied directly from merged pull requests to the changelog on new releases. Changes are expected to preferentially link to the associated issues. If there are no relevant issues, link to the pull request itself.
If there are no relevant changelog entries replace the code block below with "None." followed by an optional reason for why it is not needed.
-->
### Changelog
## Changelog
```
### Features
- Added doodad to web interface. #999
- Added doodad to web interface. [#999](https://github.com/clusterio/clusterio/issues/999)
### Changes
- ...
@@ -17,6 +12,6 @@ If there are no relevant changelog entries replace the code block below with "No
### Fixes
- ...
### Breaking Changes
### Breaking changes
- ...
```
+4 -4
View File
@@ -13,9 +13,9 @@ jobs:
strategy:
matrix:
# Supported versions are active lts and maintenance see https://nodejs.org/en/about/releases/
node-version: [22.x, 24.x]
node-version: [20.x, 22.x]
# Supported versions are latest stable and one minor version before stable (even if from a previous major)
factorio-version: [1.1.110, 2.0.77, 2.1.8]
factorio-version: [1.1.110, 2.0.47]
steps:
- uses: actions/checkout@v4
@@ -54,7 +54,7 @@ jobs:
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 24.x
node-version: 20.x
- run: pnpm i --no-frozen-lockfile
- run: pnpm run lint
@@ -65,7 +65,7 @@ jobs:
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 24.x
node-version: 20.x
- run: pnpm i --no-frozen-lockfile
- run: pnpm run build-mod
# Upload build to artifacts
+4 -6
View File
@@ -6,10 +6,6 @@ on:
types:
- released
permissions:
id-token: write
contents: write
jobs:
npm:
runs-on: ubuntu-latest
@@ -19,10 +15,12 @@ jobs:
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 24.x
node-version: 22.x
registry-url: https://registry.npmjs.org
- run: pnpm install --no-frozen-lockfile
- run: pnpm -r --filter='./{plugins,packages}/*' publish --no-git-checks
env:
NODE_AUTH_TOKEN: $${{ secrets.NPM_TOKEN }}
- uses: softprops/action-gh-release@v2
with:
files: pnpm-lock.yaml
@@ -35,7 +33,7 @@ jobs:
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 24.x
node-version: 22.x
- run: pnpm install --no-frozen-lockfile
- run: pnpm -r --filter='./{plugins,packages}/*' run build-mod --output-dir ../../dist
- run: node ./publish.js factorio ./dist
+9 -163
View File
@@ -31,160 +31,6 @@ Many thanks to the following for contributing to this release:
[@username](https://github.com/username)
-->
## Version 2.0.0-alpha.27
### Features
- Added a cluster overview landing page. [#928](<https://github.com/clusterio/clusterio/issues/928>)
- Remember the instance console chat/log toggle. [#932](<https://github.com/clusterio/clusterio/pull/932>)
- Show the public address on the instance details page. [#934](<https://github.com/clusterio/clusterio/issues/934>)
- Navigation links can now be opened in a new tab. [#929](<https://github.com/clusterio/clusterio/issues/929>)
- Instances can target a Factorio release channel (e.g. `stable` or `experimental`) as their version, resolved from `factorio.com/api/latest-releases`. [#937](<https://github.com/clusterio/clusterio/pull/937>)
- The instance console in the Web UI can be resized by dragging its bottom edge, and the chosen height is remembered across page reloads. [#938](<https://github.com/clusterio/clusterio/issues/938>)
- Tables now remember their sort and filters in the URL. [#927](<https://github.com/clusterio/clusterio/issues/927>)
- Display Factorio server version in login server list. [#906](<https://github.com/clusterio/clusterio/issues/906>)
- Add "Connect via Steam" button to login server list. [#915](<https://github.com/clusterio/clusterio/pull/915>)
### Fixes
- Silenced two Ant Design console warnings in the Web UI: the deprecated Modal `destroyOnClose` prop, and a duplicate-value warning from the instance version selector. [#940](<https://github.com/clusterio/clusterio/pull/940>)
- Surface the mod portal's own error when downloading mods for an unsupported Factorio version. [#941](<https://github.com/clusterio/clusterio/pull/941>)
- Instances always create their config file on assignment preventing duplicate folder creation. [#945](<https://github.com/clusterio/clusterio/pull/945>)
### Changes
- Reworked the site layout into a fixed sidebar with a top bar. [#924](<https://github.com/clusterio/clusterio/pull/924>)
- The builtin "recycler" mod is now included correctly as builtin. [#925](<https://github.com/clusterio/clusterio/issues/925>)
- Mod `info.json` versions (a mod's version and its `factorio_version`) are now parsed leniently, matching how the game reads them, instead of being validated against a fixed list of Factorio versions. [#941](<https://github.com/clusterio/clusterio/pull/941>)
- SubscriptionFilter now directly extends Set<string>. [#942](<https://github.com/clusterio/clusterio/pull/942>)
- EventSubscriber now exposes network errors, include permission errors. [#942](<https://github.com/clusterio/clusterio/pull/942>)
- Quick bar syncing now supports quality filters. [#944](<https://github.com/clusterio/clusterio/pull/944>)
- Added support for recommended mod dependencies. [#946](<https://github.com/clusterio/clusterio/pull/946>)
### Meta
- Node 20 is no longer officially supported as it went EoL in March 2026. Please update to v22 or v24. [#943](<https://github.com/clusterio/clusterio/pull/943>)
Many thanks to the following for contributing to this release:
[@bbassie](<https://github.com/bbassie>)
[@Cooldude2606](<https://github.com/Cooldude2606>)
## Version 2.0.0-alpha.26
### Features
- Added filter state to `EventSubscriber` allowing plugings to more easilly filter events. [#913](<https://github.com/clusterio/clusterio/pull/913>)
- Added `replace` subscription action allowing filter state to be fully replaced with a single request. [#913](<https://github.com/clusterio/clusterio/pull/913>)
- Added `ValueSubscriber` and `MapSubscriber` as subclasses of `EventSubscriber`. [#913](<https://github.com/clusterio/clusterio/pull/913>)
- Added changelog viewer accessible from the version number in the web interface. [#907](<https://github.com/clusterio/clusterio/issues/907>)
- Added about popup to user menu containing support links, build, clusterio, and debug info. [#914](<https://github.com/clusterio/clusterio/pull/914>)
- Added user status filtering (admin, whitelisted, banned) to the users table. [#905](<https://github.com/clusterio/clusterio/issues/905>)
- Added instance selector to users page to scope user stats by instance. [#905](<https://github.com/clusterio/clusterio/issues/905>)
### Fixes
- Online user table on instance page now correctly defaults to online only filter. [#909](<https://github.com/clusterio/clusterio/pull/909>)
- Fixed popup not opening when pressing kill within the instance control button. [#859](<https://github.com/clusterio/clusterio/issues/859>)
- Fix division by zero in CPU usage calculation. [#916](<https://github.com/clusterio/clusterio/issues/916>)
- Fixed permissions list not including `grantByDefault` in the json representation. [#913](<https://github.com/clusterio/clusterio/pull/913>)
- Fixed subscription resources being `synced = false` if the remote data was empty. [#913](<https://github.com/clusterio/clusterio/pull/913>)
- Fix incorrect use of `path.parse` within create template for plugins. [#919](<https://github.com/clusterio/clusterio/pull/919>)
- Fixed active filter icon behaviour in user search using empty string. [#917](<https://github.com/clusterio/clusterio/pull/917>)
### Changes
- Player auth now uses a scroll pane for listing instance names. [#909](<https://github.com/clusterio/clusterio/pull/909>)
- `subscriptionUpdate` now fires for all subscription requests rather than just added subscriptions. [#913](<https://github.com/clusterio/clusterio/pull/913>)
- Unified instance and global user table columns for consistent behaviour. [#917](<https://github.com/clusterio/clusterio/pull/917>)
- Improved loading states for tables and inputs using synced state. [#917](<https://github.com/clusterio/clusterio/pull/917>)
- Refactored username rendering into a reusable component. [#917](<https://github.com/clusterio/clusterio/pull/917>)
### Breaking Changes
- Renamed `lib.Address.index` to `requestIndex` and added `addressIndex`. [#913](<https://github.com/clusterio/clusterio/pull/913>)
- `EventSubscriber` no longer includes a values map, use `MapSubscriber` instead. [#913](<https://github.com/clusterio/clusterio/pull/913>)
### Meta
- Version dependencies now use `^` which enables updates to be applied one package at a time. [#803](<https://github.com/clusterio/clusterio/issues/803>)
- Updated change log creation to include new lines and user contributions. [#909](<https://github.com/clusterio/clusterio/pull/909>)
Many thanks to the following for contributing to this release:
[@Cooldude2606](<https://github.com/Cooldude2606>)
## Version 2.0.0-alpha.25
### Features
- Added double interrupt logic to ctl to force immediate exit. [#887](<https://github.com/clusterio/clusterio/issues/887>)
- Allow selection of factorio versions not listed on the Wube website for instances and mod packs. [#867](<https://github.com/clusterio/clusterio/issues/867>)
### Fixes
- Fix the webui being unable to set host config values and that no error was displayed. [#891](<https://github.com/clusterio/clusterio/issues/891>)
- Fix instance config not updating on changes being applied. [#901](<https://github.com/clusterio/clusterio/pull/901>)
- Incorrect acccessing of player inventory during crafting queue serialisation when in remote view. [#892](<https://github.com/clusterio/clusterio/issues/892>)
- Use physical location and controller for syncing player to prevent teleporting with remote view. [#894](<https://github.com/clusterio/clusterio/pull/894>)
- Fixed player exiting space platform hub on sync and other remote view edge cases. [#894](<https://github.com/clusterio/clusterio/pull/894>)
### Changes
- Config validation errors are no longer logged when raised during an request handler. [#888](<https://github.com/clusterio/clusterio/issues/888>)
- Updated role permission editor to use grouped layout with search and improved formatting. [#902](<https://github.com/clusterio/clusterio/pull/902>)
### Breaking Changes
- Removed obsolete functionality to download headless server in the installer. [#890](<https://github.com/clusterio/clusterio/pull/890>)
Many thanks to the following for contributing to this release:
[@Cooldude2606](<https://github.com/Cooldude2606>)
[@Daniel](<https://github.com/Daniel>)
[@Hornwitser](<https://github.com/Hornwitser>)
## Version 2.0.0-alpha.24
### Features
- Added `FactorioIcon` component for showing icons from the game in the web UI. [#875](https://github.com/clusterio/clusterio/pull/875)
- Added `useDefaultModPack` hook to the web UI. [#875](https://github.com/clusterio/clusterio/pull/875)
- Added types for the exported metadata for mod packs. [#526](https://github.com/clusterio/clusterio/issues/526)
- Added config value validation beyond simple type checks. [#856](https://github.com/clusterio/clusterio/pull/856)
- Added config staging to allow setting many values at once. [#856](https://github.com/clusterio/clusterio/pull/856)
- Added a warning when attempting to set player autosave slots lower than autosave slots. [#855](https://github.com/clusterio/clusterio/issues/855)
- Added `controller.authSecret` as the type of config field `controller.auth_secret` was inconsistent. [#856](https://github.com/clusterio/clusterio/pull/856)
- Added support for the `node:` prefix in import statements in web UI code. [#881](https://github.com/clusterio/clusterio/pull/881)
- Added config validation to load plugin to error when save patching or script commands are disabled [#884](https://github.com/clusterio/clusterio/issues/884)
### Fixes
- Fixed host printing a deprecation warning on startup. [#855](https://github.com/clusterio/clusterio/issues/855)
- Fixed exported prototype metadata missing localised_name. [#875](https://github.com/clusterio/clusterio/pull/875)
- Fixed headless download option in installer failing with `TypeError: callback is not a function`. [#860](https://github.com/clusterio/clusterio/issues/860)
- Fixed controller crashing if a host attempted to download a mod that had been deleted from the controller's filesystem while the controller was running. [#860](https://github.com/clusterio/clusterio/issues/860)
### Changes
- Moved `/icon-reference` to `/mods/mod-packs/:id/icon-reference` and linked it from the mod pack page. [#875](https://github.com/clusterio/clusterio/pull/875)
- Made the `useAccount`, `useModPacks`, `useMods`, `useRoles`, `useSaves`, `useSystems`, `useUsers` and related hooks in the web UI available to plugins. [#875](https://github.com/clusterio/clusterio/pull/875)
- Exported prototypes are now based on the `defines.prototypes` mapping from the game. [#875](https://github.com/clusterio/clusterio/pull/875)
- Removed usage of fs-extra. [#860](https://github.com/clusterio/clusterio/issues/860)
### Breaking Changes
- Removed `useExportManifest` from the web UI. Use `.exportManifest` on a mod pack from one of the `useModPack` hooks instead. [#875](https://github.com/clusterio/clusterio/pull/875)
- Replaced the 8 `useTypeMetadata` hooks in the web UI such as `useItemMetadata` with `useExportPrototypeMetadata`. [#875](https://github.com/clusterio/clusterio/pull/875)
- `.item-name` css classes are no longer created in the web UI, and will not render icons from the game. Use the `FactorioIcon` component instead and pass it a prototype from `useExportPrototypeMetadata`. [#875](https://github.com/clusterio/clusterio/pull/875)
- Renamed `useLocale` hook in web UI to `useExportLocale` which now requires the mod pack to load the exported locale from as an argument. [#875](https://github.com/clusterio/clusterio/pull/875)
- Fluids are no longer merged with items in the exported prototype metadata for mod packs. [#875](https://github.com/clusterio/clusterio/pull/875)
### Meta
- Changelog entries are now parsed with a script from pull request descriptions. [#871](https://github.com/clusterio/clusterio/pull/871)
Many thanks to the following for contributing to this release:
[@Cooldude2606](https://github.com/Cooldude2606)
[@Hornwitser](https://github.com/Hornwitser)
## Version 2.0.0-alpha.23
### Major Features
@@ -197,29 +43,29 @@ Many thanks to the following for contributing to this release:
- Added config lock files to prevent process duplication. [#815](https://github.com/clusterio/clusterio/issues/815)
- Added process and system start times to system metrics. [#826](https://github.com/clusterio/clusterio/issues/826)
- Added icons to status tags for host and instances. [#827](https://github.com/clusterio/clusterio/issues/827)
- Extended export-data to produce icons for recipes, signals, technologies, planets, qualities and entities. [#838](https://github.com/clusterio/clusterio/pull/838)
- Extended export-data to produce separate spritesheets for recipes, signals, technologies, planets, qualities, entities, and static UI icons. [#838](https://github.com/clusterio/clusterio/pull/838)
- Added Icon Reference page to web interface at `/icon-reference` for browsing all exported icons. [#838](https://github.com/clusterio/clusterio/pull/838)
- Added visual flash to RCON result box to confirm command execution, even when the output is identical to the previous result. [#793](https://github.com/clusterio/clusterio/issues/793)
### Changes
- Login screen now displays the cluster name. [#818](https://github.com/clusterio/clusterio/issues/818)
- Instance versions can now be set using Major.Minor without specifying a patch version. [#820](https://github.com/clusterio/clusterio/issues/820)
- Versions for factorio and mod packs can now be selected using a drop down menu. [#820](https://github.com/clusterio/clusterio/issues/820)
- Instance versions can now be set using Major.Minor without specifing a patch version. [#820](https://github.com/clusterio/clusterio/issues/820)
- Versions for factorio and modpacks can now be selected using a drop down menu. [#820](https://github.com/clusterio/clusterio/issues/820)
- The controller will now refuse to start if no users were loaded from file. [#814](https://github.com/clusterio/clusterio/issues/814)
- Improved wording and display of the generate host token modal. [#828](https://github.com/clusterio/clusterio/issues/828)
- Installations where multiple conflicting clusterio/lib packages have been installed will now fail with an error [#863](https://github.com/clusterio/clusterio/issues/863).
- Installations where muliple conflicting clusterio/lib packages have been installed will now fail with an error [#863](https://github.com/clusterio/clusterio/issues/863).
- Export completion now logs per-category icon counts. [#838](https://github.com/clusterio/clusterio/pull/838)
### Fixes
- Removed debug log statement from equipment grid serialization. [#807](https://github.com/clusterio/clusterio/pull/807)
- Fix that the documentation had a dead link to details of the message passing protocol [#812](https://github.com/clusterio/clusterio/issues/812)
- Removed debug log statement from equipment grid serialization. [#XXX](https://github.com/clusterio/clusterio/pull/XXX)
- Fix that the documentation had a dead link to details of the message passing protocol (#812)
- Fixed case where achievements were not disabled. [#817](https://github.com/clusterio/clusterio/pull/817)
- Fix headless download on linux install. [#816](https://github.com/clusterio/clusterio/issues/816)
- Fix option `--no-download-headless` being ignored. [#816](https://github.com/clusterio/clusterio/issues/816)
- Fix headless download on linux install. [#815](https://github.com/clusterio/clusterio/issues/816)
- Fix option `--no-download-headless` being ignored. [#815](https://github.com/clusterio/clusterio/issues/816)
- Fixed action buttons overflowing on narrow screens across the web interface. [#810](https://github.com/clusterio/clusterio/issues/810)
- Fixed player logistics failing to load on servers without unlocked logistics networks. [#806](https://github.com/clusterio/clusterio/issues/806)
- Fixed player logistics failing to load on servers without unlocked logistics networks. [#1234](https://github.com/clusterio/clusterio/issues/1234)
- Fixed mod dependency parser breaking with inconsistent spaces. [#834](https://github.com/clusterio/clusterio/issues/834)
- No longer attempts to sync inventory in single player. [#835](https://github.com/clusterio/clusterio/issues/835)
- Fixed tests failing when the configured factorio_directory contains stuff other than Factorio installs [#540](https://github.com/clusterio/clusterio/issues/540).
+1 -1
View File
@@ -146,7 +146,7 @@ Your instances (save files etc) will be stored there.
Make sure to note down the admin authentication token it provides at the end as you will need it later.
3. If you chose to use local factorio directory for the Factorio installation, download the Windows 64-bit zip package from https://www.factorio.com/download and extract it to the `factorio` folder in your clusterio installation folder.
3. If you chose to use the local `factorio` directory for the Factorio installation, you can let the installer download the latest headless release automatically, or manually download the Windows 64-bit zip package from https://www.factorio.com/download and extract it to the `factorio` folder in your clusterio installation folder.
### MacOS Setup
+4 -6
View File
@@ -8,7 +8,7 @@
"use strict";
const path = require("path");
const fs = require("node:fs/promises");
const fs = require("fs-extra");
const yargs = require("yargs");
const npmPackage = require("./package.json");
@@ -24,15 +24,13 @@ let DRY = false;
* @param {string} filePath The path to remove.
*/
async function tryRemove(filePath) {
try {
await fs.access(filePath);
if (await fs.exists(filePath)) {
// eslint-disable-next-line no-console
console.log(filePath);
} catch {
return;
}
if (!DRY) {
await fs.rm(filePath, { force: true, recursive: true, maxRetries: 10 });
await fs.remove(filePath);
}
}
-2
View File
@@ -137,8 +137,6 @@ You can also pass the `--set-upstream` (or `-u`) flag to make `git push` do the
After your feature branch have been merged you should consider it final.
If there are more changes you want to have merged or mistakes you need to fix you must start a new feature branch and submit a new pull request.
Pull request descriptions should contain a `### Changelog` section with additions for the changelog contained inside a code block, see the [CHANGELOG.md](/CHANGELOG.md) file for the formatting of changelog entries and possible sections to use.
If there are no changelog entries to add, remove the code block and start the changelog section with the text `None.`, followed by an optional reason for why it is not is needed.
### Editing your Feature Branch
+1 -3
View File
@@ -5,9 +5,7 @@
- CHANGELOG.md in the repository root
- The changelog.txt file in mod directories
Pull requests will usually have suggestions for changelog entries. These can be automatically parsed and formatted by running `node scripts/pr_to_changelog.mts`, though you will have to manually sort through which should go in the changelog in the repository root and which should go in the changelogs in mod directories.
If the script fails due to GitHub's 60/hour public access rate limit, you can set the `GH_TOKEN` environment variable to a fine-grained personal access token which has a much higher rate limits.
Pull requests will usually have suggestions for changelog entries.
## Packages
+7 -7
View File
@@ -26,7 +26,7 @@
]
},
"engines": {
"node": ">=22"
"node": ">=20"
},
"workspaces": [
"packages/*",
@@ -35,22 +35,22 @@
"external_plugins/*/*"
],
"devDependencies": {
"@clusterio/controller": "workspace:^",
"@clusterio/ctl": "workspace:^",
"@clusterio/host": "workspace:^",
"@clusterio/lib": "workspace:^",
"@clusterio/web_ui": "workspace:^",
"@clusterio/controller": "workspace:*",
"@clusterio/ctl": "workspace:*",
"@clusterio/host": "workspace:*",
"@clusterio/lib": "workspace:*",
"@clusterio/web_ui": "workspace:*",
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "^9.32.0",
"@stylistic/eslint-plugin": "^5.2.2",
"@swc/core": "^1.13.3",
"@types/node": "catalog:",
"@typescript-eslint/eslint-plugin": "^8.38.0",
"@typescript-eslint/parser": "^8.38.0",
"eslint": "^9.32.0",
"eslint-plugin-node": "^11.1.0",
"express": "^5.1.0",
"form-data": "^4.0.4",
"fs-extra": "^11.3.0",
"globals": "^16.3.0",
"jsonwebtoken": "^9.0.2",
"jszip": "^3.10.1",
+30 -6
View File
@@ -15,9 +15,10 @@
*/
import path from "path";
import fs from "node:fs/promises";
import fs from "fs-extra";
import crypto from "crypto";
import setBlocking from "set-blocking";
import yargs from "yargs";
import util from "util";
import winston from "winston";
@@ -181,8 +182,7 @@ async function handleBootstrapCommand(
// eslint-disable-next-line no-console
console.log(jwt.sign(
{ aud: "host", host: args.id },
// Auth secret is never null after startup
Buffer.from(controllerConfig.get("controller.auth_secret")!, "base64")
Buffer.from(controllerConfig.get("controller.auth_secret"), "base64")
));
} else if (subCommand === "create-ctl-config") {
@@ -445,9 +445,33 @@ async function startup() {
process.exit(1);
}
// Handle interrupts
process.on("SIGINT", lib.createShutdownGuard(logger, "interrupt", controller.stop.bind(controller)));
process.on("SIGTERM", lib.createShutdownGuard(logger, "termination", controller.stop.bind(controller)));
let secondSigint = false;
process.on("SIGINT", () => {
if (secondSigint) {
setBlocking(true);
logger.fatal("Caught second interrupt, terminating immediately");
process.exit(1);
}
secondSigint = true;
logger.info("Caught interrupt signal, shutting down");
controller.stop();
});
let secondSigterm = false;
process.on("SIGTERM", () => {
if (secondSigterm) {
setBlocking(true);
logger.fatal("Caught second termination, terminating immediately");
process.exit(1);
}
secondSigterm = true;
logger.info("Caught termination signal, shutting down");
controller.stop();
});
process.on("SIGHUP", () => {
logger.info("Terminal closed, shutting down");
controller.stop();
+20 -16
View File
@@ -3,7 +3,7 @@
"description": "Implementation of Clusterio controller",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.27",
"version": "2.0.0-alpha.23",
"main": "dist/node/index.js",
"scripts": {
"prepare": "tsc --build && webpack-cli --mode production",
@@ -17,41 +17,45 @@
"factorio"
],
"engines": {
"node": ">=22"
"node": ">=20"
},
"dependencies": {
"@clusterio/lib": "workspace:^",
"@clusterio/lib": "workspace:*",
"@sinclair/typebox": "catalog:",
"busboy": "^1.6.0",
"compression": "^1.8.0",
"express": "catalog:",
"finalhandler": "^1.3.1",
"set-blocking": "catalog:",
"jsonwebtoken": "catalog:",
"express": "catalog:",
"fs-extra": "catalog:",
"ws": "catalog:",
"jszip": "catalog:",
"winston": "catalog:",
"winston-daily-rotate-file": "catalog:",
"ws": "catalog:",
"busboy": "^1.6.0",
"compression": "^1.8.0",
"finalhandler": "^1.3.1",
"yargs": "^17.7.2"
},
"devDependencies": {
"@clusterio/web_ui": "workspace:^",
"@swc/core": "catalog:",
"@types/busboy": "^1.5.0",
"@types/compression": "^1.7.2",
"@types/express": "catalog:",
"@types/finalhandler": "catalog:",
"@clusterio/web_ui": "workspace:*",
"@types/jsonwebtoken": "catalog:",
"@types/set-blocking": "catalog:",
"@types/finalhandler": "catalog:",
"@types/express": "catalog:",
"@types/fs-extra": "catalog:",
"@types/node": "catalog:",
"@types/ws": "catalog:",
"@types/yargs": "^17.0.33",
"@types/compression": "^1.7.2",
"@types/busboy": "^1.5.0",
"@swc/core": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"react-router-dom": "catalog:",
"typescript": "catalog:",
"webpack": "catalog:",
"webpack-cli": "catalog:",
"webpack-dev-middleware": "^6.1.1",
"webpack-merge": "catalog:"
"webpack-merge": "catalog:",
"webpack-dev-middleware": "^6.1.1"
},
"publishConfig": {
"access": "public"
+3 -10
View File
@@ -1,5 +1,5 @@
import path from "path";
import fs from "node:fs/promises";
import fs from "fs-extra";
import type Controller from "./Controller";
import type WsServerConnector from "./WsServerConnector";
@@ -33,7 +33,6 @@ export default class BaseConnection extends lib.Link {
this.handle(lib.ModPackGetDefaultRequest, this.handleModPackGetDefaultRequest.bind(this));
this.handle(lib.ModDownloadRequest, this.handleModDownloadRequest.bind(this));
this.handle(lib.FactorioVersionsRequest, this.handleFactorioVersionsRequest.bind(this));
this.handle(lib.LatestReleasesRequest, this.handleLatestReleasesRequest.bind(this));
this.handle(lib.SubscriptionRequest, this.handleSubscriptionRequest.bind(this));
this.connector.on("close", () => {
@@ -80,7 +79,7 @@ export default class BaseConnection extends lib.Link {
return modPack;
}
getMod(mod: { name: string, version: lib.SourceVersion, sha1?: string }) {
getMod(mod: { name: string, version: lib.FullVersion, sha1?: string }) {
let filename = lib.ModInfo.filename(mod.name, mod.version);
let modInfo = this._controller.modStore.files.get(filename);
if (!modInfo) {
@@ -95,12 +94,10 @@ export default class BaseConnection extends lib.Link {
async handleModDownloadRequest(request: lib.ModDownloadRequest) {
let mod = this.getMod(request);
let modPath = path.join(this._controller.config.get("controller.mods_directory"), mod.filename);
// Open source file before creating the stream in case it throws.
let source = (await fs.open(modPath)).createReadStream();
let stream = await routes.createProxyStream(this._controller.app);
stream.filename = mod.filename;
stream.source = source;
stream.source = fs.createReadStream(modPath);
stream.mime = "application/zip";
stream.size = String(mod.size);
@@ -114,8 +111,4 @@ export default class BaseConnection extends lib.Link {
async handleFactorioVersionsRequest(request: lib.FactorioVersionsRequest) {
return await this._controller.factorioVersions.get(request.maxAgeMs);
}
async handleLatestReleasesRequest(request: lib.LatestReleasesRequest) {
return await this._controller.latestReleases.get(request.maxAgeMs);
}
}
+15 -82
View File
@@ -73,7 +73,6 @@ export default class ControlConnection extends BaseConnection {
this.handle(lib.ControllerRestartRequest, this.handleControllerRestartRequest.bind(this));
this.handle(lib.ControllerUpdateRequest, this.handleControllerUpdateRequest.bind(this));
this.handle(lib.ControllerConfigGetRequest, this.handleControllerConfigGetRequest.bind(this));
this.handle(lib.ControllerConfigSetRequest, this.handleControllerConfigSetRequest.bind(this));
this.handle(lib.ControllerConfigSetFieldRequest, this.handleControllerConfigSetFieldRequest.bind(this));
this.handle(lib.ControllerConfigSetPropRequest, this.handleControllerConfigSetPropRequest.bind(this));
this.handle(lib.HostRevokeTokensRequest, this.handleHostRevokeTokensRequest.bind(this));
@@ -85,7 +84,6 @@ export default class ControlConnection extends BaseConnection {
this.handle(lib.InstanceCreateRequest, this.handleInstanceCreateRequest.bind(this));
this.handle(lib.InstanceDeleteRequest, this.handleInstanceDeleteRequest.bind(this));
this.handle(lib.InstanceConfigGetRequest, this.handleInstanceConfigGetRequest.bind(this));
this.handle(lib.InstanceConfigSetRequest, this.handleInstanceConfigSetRequest.bind(this));
this.handle(lib.InstanceConfigSetFieldRequest, this.handleInstanceConfigSetFieldRequest.bind(this));
this.handle(lib.InstanceConfigSetPropRequest, this.handleInstanceConfigSetPropRequest.bind(this));
this.handle(lib.InstanceAssignRequest, this.handleInstanceAssignRequest.bind(this));
@@ -197,26 +195,6 @@ export default class ControlConnection extends BaseConnection {
return this._controller.config.toRemote("control");
}
async handleControllerConfigSetRequest(request: lib.ControllerConfigSetRequest) {
try {
for (const [fieldName, fieldValue] of Object.entries(request.fields)) {
if (typeof fieldValue === "object") {
for (const [propName, propValue] of Object.entries(fieldValue)) {
this._controller.config.stageProp(
fieldName as keyof lib.ControllerConfigFields, propName, propValue, "control"
);
}
} else {
this._controller.config.stage(fieldName as keyof lib.ControllerConfigFields, fieldValue, "control");
}
}
this._controller.config.commitStaging();
} finally {
this._controller.config.revertStaging();
}
}
async handleControllerConfigSetFieldRequest(request: lib.ControllerConfigSetFieldRequest) {
this._controller.config.set(request.field as keyof lib.ControllerConfigFields, request.value, "control");
}
@@ -302,43 +280,17 @@ export default class ControlConnection extends BaseConnection {
return instance.config.toRemote("control");
}
private static validateInstanceConfigSetField(fieldName: string) {
if (fieldName === "instance.assigned_host") {
async handleInstanceConfigSetFieldRequest(request: lib.InstanceConfigSetFieldRequest) {
let instance = this._controller.instances.getForRequest(request.instanceId);
if (request.field === "instance.assigned_host") {
throw new lib.RequestError("instance.assigned_host must be set through the assign-host interface");
}
if (fieldName === "instance.id") {
if (request.field === "instance.id") {
// XXX is this worth implementing? It's race condition galore.
throw new lib.RequestError("Setting instance.id is not supported");
}
}
async handleInstanceConfigSetRequest(request: lib.InstanceConfigSetRequest) {
const instance = this._controller.instances.getForRequest(request.instanceId);
try {
for (const [fieldName, fieldValue] of Object.entries(request.fields)) {
ControlConnection.validateInstanceConfigSetField(fieldName);
if (typeof fieldValue === "object") {
for (const [propName, propValue] of Object.entries(fieldValue)) {
instance.config.stageProp(
fieldName as keyof lib.InstanceConfigFields, propName, propValue, "control"
);
}
} else {
instance.config.stage(fieldName as keyof lib.InstanceConfigFields, fieldValue, "control");
}
}
instance.config.commitStaging();
await this._controller.instanceConfigUpdated(instance);
} finally {
instance.config.revertStaging();
}
}
async handleInstanceConfigSetFieldRequest(request: lib.InstanceConfigSetFieldRequest) {
const instance = this._controller.instances.getForRequest(request.instanceId);
ControlConnection.validateInstanceConfigSetField(request.field);
instance.config.set(request.field as keyof lib.InstanceConfigFields, request.value, "control");
await this._controller.instanceConfigUpdated(instance);
}
@@ -346,7 +298,6 @@ export default class ControlConnection extends BaseConnection {
async handleInstanceConfigSetPropRequest(request: lib.InstanceConfigSetPropRequest) {
let instance = this._controller.instances.getForRequest(request.instanceId);
let { field, prop, value } = request;
ControlConnection.validateInstanceConfigSetField(field);
instance.config.setProp(field as keyof lib.InstanceConfigFields, prop, value, "control");
await this._controller.instanceConfigUpdated(instance);
}
@@ -675,7 +626,6 @@ export default class ControlConnection extends BaseConnection {
type ModPortalModType = InstanceType<typeof lib.ModPortalGetAllRequest.Response>["mods"][number];
type ModPortalReleaseType = NonNullable<ModPortalModType["releases"]>[number];
const dependencyRequirements = new Map<string, lib.ModVersionRange>();
const recommendedRequirements = new Map<string, lib.ModVersionRange>();
const optionalRequirements = new Map<string, lib.ModVersionRange>();
const candidateReleases = new Map<string, lib.ModInfo>();
const errors = new Map<string, lib.ModDependencyResolveErrors>();
@@ -708,7 +658,7 @@ export default class ControlConnection extends BaseConnection {
const release = releases.releases
.filter(info => versionRange.testVersion(info.version))
.reduce<ModPortalReleaseType | undefined>((max, cur) => (
max && lib.integerSourceVersion(max.version) > lib.integerSourceVersion(cur.version) ? max : cur
max && lib.integerFullVersion(max.version) > lib.integerFullVersion(cur.version) ? max : cur
), undefined);
if (!release) {
@@ -740,32 +690,16 @@ export default class ControlConnection extends BaseConnection {
continue;
}
// Get the current version range (dependencies can be promoted to a stricter requirement)
const versionRange =
dependencyRequirements.get(mod.name)
?? recommendedRequirements.get(mod.name)
?? optionalRequirements.get(mod.name)
?? new lib.ModVersionRange();
if (mod.required) {
// Add to required, remove from recommended and optional
dependencyRequirements.set(mod.name, versionRange);
recommendedRequirements.delete(mod.name);
optionalRequirements.delete(mod.name);
} else if (mod.recommended) {
// If not required, add to recommended and remove from optional
if (!dependencyRequirements.has(mod.name)) {
recommendedRequirements.set(mod.name, versionRange);
// Get the current version range (optionals are tracked in case they become required)
let versionRange = dependencyRequirements.get(mod.name);
if (!versionRange) {
versionRange = optionalRequirements.get(mod.name) ?? new lib.ModVersionRange();
if (mod.required) {
dependencyRequirements.set(mod.name, versionRange);
optionalRequirements.delete(mod.name);
} else {
optionalRequirements.set(mod.name, versionRange);
}
} else if (
!dependencyRequirements.has(mod.name)
&& !recommendedRequirements.has(mod.name)
) {
// If not required or recommended, add to optional
optionalRequirements.set(mod.name, versionRange);
}
// Update the version range if needed
@@ -774,7 +708,6 @@ export default class ControlConnection extends BaseConnection {
if (!versionRange.valid) {
candidateReleases.delete(mod.name);
errors.set(mod.name, "unsatisfiable");
continue;
}
}
@@ -822,9 +755,9 @@ export default class ControlConnection extends BaseConnection {
}
}
// Remove incompatible and unsatisfiable dependencies if they aren't required by others
// Remove incompatible dependencies if they aren't required by others
for (const [modName, error] of errors) {
if (!dependencyRequirements.has(modName) && (error === "incompatible" || error === "unsatisfiable")) {
if (error === "incompatible" && !dependencyRequirements.has(modName)) {
errors.delete(modName);
}
}
+12 -65
View File
@@ -7,7 +7,7 @@ import finalhandler from "finalhandler";
import compression from "compression";
import events from "events";
import fs from "node:fs/promises";
import fs from "fs-extra";
import http from "http";
import https from "https";
import jwt from "jsonwebtoken";
@@ -121,31 +121,9 @@ export default class Controller {
}
});
// Cache for the latest stable/experimental factorio releases. Persisted to
// disk so that release channel targets can still be resolved when
// factorio.com is unavailable.
latestReleases = new lib.ValueCache(async () => {
const cachePath = path.join(this.config.get("controller.database_directory"), "latest-releases.json");
try {
const releases = await lib.fetchLatestReleases();
await lib.safeOutputFile(cachePath, JSON.stringify(releases, null, "\t"));
return releases;
} catch (err: any) {
logger.warn(`Failed to retrieve Factorio releases ${err.message}`);
try {
return JSON.parse(await fs.readFile(cachePath, "utf8")) as lib.LatestReleases;
} catch (readErr: any) {
if (readErr.code !== "ENOENT") {
logger.warn(`Failed to read cached Factorio releases ${readErr.message}`);
}
return {};
}
}
});
static async bootstrap(config: lib.ControllerConfig) {
let databaseDirectory = config.get("controller.database_directory");
await fs.mkdir(databaseDirectory, { recursive: true });
await fs.ensureDir(databaseDirectory);
// Migrate combined user and role file from 2.0.0-alpha.22
await UserManager.attemptMigrateLegacyUsersFile(
@@ -195,7 +173,7 @@ export default class Controller {
).bootstrap());
let modsDirectory = config.get("controller.mods_directory");
await fs.mkdir(modsDirectory, { recursive: true });
await fs.ensureDir(modsDirectory);
const modStore = await lib.ModStore.fromDirectory(modsDirectory);
// Add default mod packs
@@ -290,11 +268,6 @@ export default class Controller {
this.users.records.on("update", this.usersUpdated.bind(this));
}
get authSecret() {
// Auth secret is never null after startup
return Buffer.from(this.config.get("controller.auth_secret")!, "base64");
}
async start(args: ControllerArgs) {
if (this._state !== "new") {
throw new Error(`Cannot start in state ${this._state}`);
@@ -624,7 +597,7 @@ export default class Controller {
// First check the clusterio version
const runningVersion = this.config.get("controller.version");
const packageJsonPath = require.resolve("@clusterio/controller/package.json");
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, "utf8"));
const packageJson = await fs.readJSON(packageJsonPath);
if (runningVersion !== packageJson.version) {
this.config.restartRequired = true;
return true;
@@ -641,7 +614,7 @@ 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 = await fs.readJSON(pluginPackageJsonPath);
if (pluginInfo.version !== pluginPackageJson.version) {
this.config.restartRequired = true;
return true;
@@ -763,11 +736,8 @@ export default class Controller {
const serialized = rawJson as Static<typeof lib.ModPack.jsonSchema>[];
return serialized.map(json => {
for (const mod of json.mods) {
// migrate: 2.0.0-alpha.22 - old buildins used X.Y rather than X.Y.Z
// All other versions are kept verbatim so they still match the mod's file name.
if (lib.isPartialVersion(mod.version)) {
mod.version = lib.normaliseFullVersion(mod.version) as lib.SourceVersion;
}
// migrate: 2.0.0.alpha.22 - json schema now enforces X.Y.Z for mod version, builtins would use X.Y only
mod.version = lib.normaliseFullVersion(mod.version);
}
return json;
});
@@ -900,9 +870,6 @@ export default class Controller {
}
async handleSystemInfoSubscription(request: lib.SubscriptionRequest) {
if (request.action === "unsubscribe") {
return null;
}
const systems = [...this.systems.values()].filter(
metric => metric.updatedAtMs > request.lastRequestTimeMs,
);
@@ -916,7 +883,10 @@ export default class Controller {
* @param hostId - ID of host to generate a token for.
*/
generateHostToken(hostId: number): string {
return jwt.sign({ aud: "host", host: hostId }, this.authSecret);
return jwt.sign(
{ aud: "host", host: hostId },
Buffer.from(this.config.get("controller.auth_secret"), "base64")
);
}
hostsUpdated(hosts: HostRecord[]) {
@@ -925,9 +895,6 @@ export default class Controller {
}
async handleHostSubscription(request: lib.SubscriptionRequest) {
if (request.action === "unsubscribe") {
return null;
}
const hosts = [...this.hosts.values()].filter(
host => host.updatedAtMs > request.lastRequestTimeMs,
).map(host => host.toHostDetails());
@@ -975,9 +942,6 @@ export default class Controller {
}
async handleInstanceDetailsSubscription(request: lib.SubscriptionRequest) {
if (request.action === "unsubscribe") {
return null;
}
const instances = [...this.instances.values()].filter(
instance => instance.updatedAtMs > request.lastRequestTimeMs,
).map(instance => instance.toInstanceDetails());
@@ -989,12 +953,7 @@ export default class Controller {
}
async handleInstanceSaveDetailsSubscription(request: lib.SubscriptionRequest) {
if (request.action === "unsubscribe") {
return null;
}
const saves = [...this.saves.values()].filter(
save => save.updatedAtMs > request.lastRequestTimeMs
);
const saves = [...this.saves.values()].filter(save => save.updatedAtMs > request.lastRequestTimeMs);
return saves.length ? new lib.InstanceSaveDetailsUpdatesEvent(saves) : null;
}
@@ -1005,9 +964,6 @@ export default class Controller {
}
async handleModPackSubscription(request: lib.SubscriptionRequest) {
if (request.action === "unsubscribe") {
return null;
}
const modPacks = [...this.modPacks.values()].filter(
modPack => modPack.updatedAtMs > request.lastRequestTimeMs,
);
@@ -1021,9 +977,6 @@ export default class Controller {
}
async handleModSubscription(request: lib.SubscriptionRequest) {
if (request.action === "unsubscribe") {
return null;
}
const mods = [...this.modStore.files.values()].filter(
mod => mod.updatedAtMs > request.lastRequestTimeMs,
);
@@ -1035,9 +988,6 @@ export default class Controller {
}
async handleUserSubscription(request: lib.SubscriptionRequest) {
if (request.action === "unsubscribe") {
return null;
}
const users = [...this.users.records.values()].filter(
user => user.updatedAtMs > request.lastRequestTimeMs,
);
@@ -1085,9 +1035,6 @@ export default class Controller {
}
async handleRoleSubscription(request: lib.SubscriptionRequest) {
if (request.action === "unsubscribe") {
return null;
}
const roles = [...this.roles.values()].filter(
role => role.updatedAtMs > request.lastRequestTimeMs,
);
+2 -3
View File
@@ -1,4 +1,4 @@
import fs from "node:fs/promises";
import fs from "fs-extra";
import jwt from "jsonwebtoken";
import * as lib from "@clusterio/lib";
@@ -151,8 +151,7 @@ export default class UserManager {
signUserToken(user: UserRecord): string {
return jwt.sign(
{ aud: "user", user: user.id },
// auth secret is never null after startup
Buffer.from(this._controllerConfig.get("controller.auth_secret")!, "base64")
Buffer.from(this._controllerConfig.get("controller.auth_secret"), "base64")
);
}
+23 -5
View File
@@ -223,7 +223,10 @@ ${err.stack}`
createSession(dst: lib.Address): [ WsServerConnector, string ] {
let sessionId = this.nextSessionId;
this.nextSessionId += 1;
let sessionToken = jwt.sign({ aud: this.sessionAud, sid: sessionId }, this.controller.authSecret);
let sessionToken = jwt.sign(
{ aud: this.sessionAud, sid: sessionId },
Buffer.from(this.controller.config.get("controller.auth_secret"), "base64"),
);
let sessionTimeout = this.controller.config.get("controller.session_timeout");
let heartbeatInterval = this.controller.config.get("controller.heartbeat_interval");
let connector = new WsServerConnector(dst, sessionId, sessionTimeout, heartbeatInterval);
@@ -244,8 +247,13 @@ ${err.stack}`
}
try {
// migrate: allow pre-rename tokens issued to hosts before alpha-14
const tokenPayload = jwt.verify(data.token, this.controller.authSecret, { audience: ["host", "slave"] });
let tokenPayload = jwt.verify(
data.token,
Buffer.from(this.controller.config.get("controller.auth_secret"), "base64"),
// migrate: allow pre-rename tokens issued to hosts before alpha-14
{ audience: ["host", "slave"] }
);
if (typeof tokenPayload === "string") {
throw new Error("unexpected JsonWebToken type");
}
@@ -316,7 +324,12 @@ ${err.stack}`
async registerControl(data: lib.RegisterControlData, socket: WebSocket, req: IncomingMessage) {
let user;
try {
const tokenPayload = jwt.verify(data.token, this.controller.authSecret, { audience: "user" });
let tokenPayload = jwt.verify(
data.token,
Buffer.from(this.controller.config.get("controller.auth_secret"), "base64"),
{ audience: "user" }
);
if (typeof tokenPayload === "string") {
throw new Error("unexpected JsonWebToken type");
}
@@ -363,7 +376,12 @@ ${err.stack}`
resumeSession(data: lib.ResumeData, socket: WebSocket, req: IncomingMessage) {
let connector;
try {
const payload = jwt.verify(data.sessionToken, this.controller.authSecret, { audience: this.sessionAud });
let payload = jwt.verify(
data.sessionToken,
Buffer.from(this.controller.config.get("controller.auth_secret"), "base64"),
{ audience: this.sessionAud }
);
if (typeof payload === "string") {
throw new Error("unexpected JsonWebToken type");
}
+39 -14
View File
@@ -1,10 +1,10 @@
import type { Application, Request, Response } from "express";
import Controller from "./Controller";
import type Controller from "./Controller";
import busboy from "busboy";
import crypto from "crypto";
import events from "events";
import fs from "node:fs/promises";
import fs from "fs-extra";
import JSZip from "jszip";
import jwt from "jsonwebtoken";
import path from "path";
@@ -148,7 +148,12 @@ function validateHostToken(req: Request, res: Response, next: any) {
}
try {
const tokenPayload = jwt.verify(token, req.app.locals.controller.authSecret, { audience: "host" });
const tokenPayload = jwt.verify(
token,
Buffer.from(req.app.locals.controller.config.get("controller.auth_secret"), "base64"),
{ audience: "host" }
);
if (typeof tokenPayload === "string") {
throw new Error("unexpected JsonWebToken type");
}
@@ -175,7 +180,12 @@ function validateUserToken(req: Request, res: Response, next: any) {
}
try {
const tokenPayload = jwt.verify(token, req.app.locals.controller.authSecret, { audience: "user" });
let tokenPayload = jwt.verify(
token,
Buffer.from(req.app.locals.controller.config.get("controller.auth_secret"), "base64"),
{ audience: "user" }
);
if (typeof tokenPayload === "string") {
throw new Error("unexpected JsonWebToken type");
}
@@ -231,13 +241,12 @@ async function uploadExport(req: Request, res: Response) {
"export/settings.json",
"export/prototypes.json",
"export/locale.json",
"export/defines.json",
"export/spritesheet.png",
"export/metadata.json",
];
let assets: any = {};
let settingPrototypes: lib.ExportSettings = {};
let settingPrototypes = {};
for (let filePath of exportFiles) {
let file = zip.file(filePath);
if (!file) {
@@ -543,16 +552,30 @@ async function uploadMod(req: Request, res: Response) {
}
const modsDirectory = req.app.locals.controller.config.get("controller.mods_directory");
const filePath = path.join(modsDirectory, filename);
const [tempFilePath, writeStream] = await lib.createTempWriteStream(filePath);
let tempFilename = filename.replace(/(\.zip)?$/, ".tmp.zip");
let writeStream;
try {
while (true) {
try {
writeStream = fs.createWriteStream(path.join(modsDirectory, tempFilename), { flags: "wx" });
await events.once(writeStream, "open");
break;
} catch (err: any) {
if (err.code === "EEXIST") {
tempFilename = await lib.findUnusedName(modsDirectory, tempFilename, ".tmp.zip");
} else {
throw err;
}
}
}
stream.pipe(writeStream);
await finished(writeStream);
const modInfo = await lib.ModInfo.fromModFile(tempFilePath);
const modInfo = await lib.ModInfo.fromModFile(path.join(modsDirectory, tempFilename));
checkModName(modInfo.filename);
await fs.rename(tempFilePath, path.join(modsDirectory, modInfo.filename));
await fs.rename(path.join(modsDirectory, tempFilename), path.join(modsDirectory, modInfo.filename));
req.app.locals.controller.modStore.addMod(modInfo);
mods.push(modInfo.toJSON());
@@ -562,13 +585,15 @@ async function uploadMod(req: Request, res: Response) {
stream.resume();
// Attempt to clean up.
writeStream.destroy();
if (writeStream) {
writeStream.destroy();
}
try {
await fs.unlink(tempFilePath);
await fs.unlink(path.join(modsDirectory, tempFilename));
} catch (unlinkErr: any) {
if (unlinkErr.code !== "ENOENT") {
logger.error(`Error removing ${tempFilePath}: ${unlinkErr.message}`);
logger.error(`Error removing ${tempFilename}: ${unlinkErr.message}`);
}
}
}
@@ -670,12 +695,12 @@ export const webRoutes = [
"/instances/:id/view",
"/mods",
"/mods/mod-packs/:id/view",
"/mods/mod-packs/:id/icon-reference",
"/users",
"/users/:id/view",
"/roles",
"/roles/:id/view",
"/plugins",
"/plugins/:name/view",
"/icon-reference",
"/error",
];
+198 -36
View File
@@ -2,7 +2,7 @@
"use strict";
const child_process = require("child_process");
const fs = require("node:fs/promises");
const fs = require("fs-extra");
const inquirer = require("inquirer");
const os = require("os");
const path = require("path");
@@ -14,6 +14,7 @@ const { levels, logger, setLogLevel } = require("./logging");
const { copyPluginTemplates } = require("./template");
let dev = false;
const scriptExt = process.platform === "win32" ? ".cmd" : "";
const finished = util.promisify(stream.finished);
const { escapeArg } = require("./escape_arg");
// I hate this, there is a bug with rl.close on windows that was meant to have been fixed in Node14.4
@@ -42,6 +43,9 @@ const availablePlugins = [
{ name: "Server Select", value: "@hornwitser/server_select" },
];
const windowsHeadlessDownloadUrl = "https://factorio-repackager.fly.dev/get-download/latest/headless/win64";
const headlessDownloadPlatforms = new Set(["linux", "win32"]);
const factorioLocations = {
win32: [
"C:\\Program Files\\Factorio",
@@ -52,6 +56,10 @@ const factorioLocations = {
],
};
function supportsHeadlessDownload(platform = process.platform) {
return headlessDownloadPlatforms.has(platform);
}
class LineSplitter extends stream.Transform {
constructor(options) {
@@ -96,24 +104,11 @@ class LineSplitter extends stream.Transform {
class InstallError extends Error { }
async function pathExists(filePath) {
try {
await fs.access(filePath);
} catch (err) {
if (err.code === "ENOENT" || err.code === "ENOTDIR") {
return false;
}
}
return true;
}
async function safeOutputFile(file, data, options={}) {
let { dir, name, ext } = path.parse(file);
let temporary = path.join(dir, `${name}.tmp${ext}`);
if (dir) {
await fs.mkdir(dir, { recursive: true });
}
await fs.writeFile(temporary, data, { ...options, flush: true });
await fs.outputFile(temporary, data, options);
await fs.rename(temporary, file);
}
@@ -233,7 +228,7 @@ async function migrateRename(args) {
}
async function migrateConfig(source, destination) {
if (await pathExists(source) && !await pathExists(destination)) {
if (await fs.pathExists(source) && !await fs.pathExists(destination)) {
await safeOutputFile(destination, JSON.stringify(
renameConfig(JSON.parse(await fs.readFile(source))), null, "\t"
));
@@ -245,7 +240,7 @@ async function migrateRename(args) {
await migrateConfig("config-slave.json", "config-host.json");
let instancesFile = path.join("database", "instances.json");
if (await pathExists(instancesFile)) {
if (await fs.pathExists(instancesFile)) {
await safeOutputFile(instancesFile, JSON.stringify(
JSON.parse(await fs.readFile(instancesFile)).map(renameConfig), null, "\t"
));
@@ -253,7 +248,7 @@ async function migrateRename(args) {
}
let usersFile = path.join("database", "users.json");
if (await pathExists(usersFile)) {
if (await fs.pathExists(usersFile)) {
let users = JSON.parse(await fs.readFile(usersFile));
for (let role of users["roles"]) {
role["permissions"] = role["permissions"].map(rename);
@@ -270,7 +265,7 @@ async function migrateRename(args) {
async function migrateLog(inputFile, outputFile) {
let lineStream = new LineSplitter({ readableObjectMode: true });
let fileStream = (await fs.open(inputFile)).createReadStream();
let fileStream = fs.createReadStream(inputFile);
fileStream.pipe(lineStream);
let lines = [];
@@ -290,7 +285,7 @@ async function migrateRename(args) {
}
async function migrateLogsDir(inputLogs, outputLogs) {
if (await pathExists(inputLogs)) {
if (await fs.pathExists(inputLogs)) {
logger.info(`Migrating ${inputLogs} to ${outputLogs}`);
for (let logFile of await fs.readdir(inputLogs)) {
if (!logFile.endsWith(".log")) {
@@ -298,7 +293,7 @@ async function migrateRename(args) {
}
let inputFile = path.join(inputLogs, logFile);
let outputFile = path.join(outputLogs, rename(logFile));
if (!await pathExists(outputFile)) {
if (!await fs.pathExists(outputFile)) {
await migrateLog(inputFile, outputFile);
logger.verbose(`Migrated ${inputFile} to ${outputFile}`);
}
@@ -309,16 +304,16 @@ async function migrateRename(args) {
await migrateLogsDir(path.join("logs", "master"), path.join("logs", "controller"));
await migrateLogsDir(path.join("logs", "slave"), path.join("logs", "host"));
if (
await pathExists(path.join("logs", "cluster"))
&& !await pathExists(path.join("logs", "cluster-prerename"))
await fs.pathExists(path.join("logs", "cluster"))
&& !await fs.pathExists(path.join("logs", "cluster-prerename"))
) {
await fs.rename(path.join("logs", "cluster"), path.join("logs", "cluster-prerename"));
await migrateLogsDir(path.join("logs", "cluster-prerename"), path.join("logs", "cluster"));
}
if (
await pathExists("sharedMods")
&& !await pathExists("mods")
await fs.pathExists("sharedMods")
&& !await fs.pathExists("mods")
) {
logger.info("Moving sharedMods/ to mods/");
await fs.rename("sharedMods", "mods");
@@ -340,8 +335,8 @@ async function migrateRename(args) {
await execFile(`npm${scriptExt}`, ["update"]);
}
const hasRunMaster = await pathExists("run-master.sh") || await pathExists("run-master.cmd");
const hasRunSlave = await pathExists("run-slave.sh") || await pathExists("run-slave.cmd");
const hasRunMaster = await fs.pathExists("run-master.sh") || await fs.pathExists("run-master.cmd");
const hasRunSlave = await fs.pathExists("run-slave.sh") || await fs.pathExists("run-slave.cmd");
if (hasRunMaster || hasRunSlave) {
logger.info("Writing run scripts");
let mode = "standalone";
@@ -366,7 +361,7 @@ async function migrateRename(args) {
async function installClusterio(mode, plugins) {
try {
await fs.writeFile("package.json", JSON.stringify({
await fs.outputFile("package.json", JSON.stringify({
name: "clusterio-install",
private: true,
}, null, 2), { flag: "wx" });
@@ -655,10 +650,26 @@ async function inquirerMissingArgs(args) {
let locations = factorioLocations[process.platform] || [];
let foundLocations = [];
for (let location of locations) {
if (await pathExists(path.join(location, "data", "changelog.txt"))) {
if (await fs.pathExists(path.join(location, "data", "changelog.txt"))) {
foundLocations.push(location);
}
}
if (supportsHeadlessDownload()) {
if (args.hasOwnProperty("downloadHeadless")) { answers.downloadHeadless = args.downloadHeadless; }
answers = await inquirer.prompt([
{
type: "confirm",
name: "downloadHeadless",
message: "Automatically download latest headless Factorio release?",
default: true,
},
], answers);
if (!answers.factorioDir && answers.downloadHeadless) {
answers.factorioDir = "factorio";
}
}
answers = await inquirer.prompt([
{
@@ -667,7 +678,7 @@ async function inquirerMissingArgs(args) {
message: "Path to Factorio installation",
choices: [
...foundLocations.map(location => ({ name: `${location} (auto detected)`, value: location })),
{ name: "Use local factorio directory, you must copy an installation to it", value: "factorio" },
{ name: "Use local factorio directory", value: "factorio" },
{ name: "Provide path manually", value: null },
],
},
@@ -683,7 +694,7 @@ async function inquirerMissingArgs(args) {
},
], answers);
} else if (answers.factorioDir === "factorio") {
await fs.mkdir("factorio", { recursive: true });
await fs.ensureDir("factorio");
}
}
@@ -750,6 +761,134 @@ async function inquirerMissingArgs(args) {
return answers;
}
async function downloadLinuxServer() {
let res = await fetch("https://factorio.com/get-download/stable/headless/linux64");
// get the filename of the latest factorio archive from redirected url
const url = new URL(res.url); // The res is redirects, need final url
const filename = path.posix.basename(url.pathname);
const match = filename.match(/(?<=factorio_headless_x64_|factorio-headless_linux_)\d+\.\d+\.\d+(?=\.tar\.xz)/);
if (!match || !match.length) {
throw Error(`Unable to extract version from filename: ${filename}`);
}
const version = match[0];
const tmpDir = "temp/create-temp/";
const archivePath = tmpDir + filename;
const tmpArchivePath = `${archivePath}.tmp`;
const factorioDir = `factorio/${version}/`;
const tmpFactorioDir = tmpDir + version;
if (await fs.pathExists(factorioDir)) {
logger.warn(`setting downloadDir to ${factorioDir}, but not downloading because already existing`);
} else {
await fs.emptyDir(tmpDir);
logger.info("Downloading latest Factorio server release. This may take a while.");
const writeStream = fs.createWriteStream(tmpArchivePath);
stream.Readable.fromWeb(res.body).pipe(writeStream);
await finished(writeStream);
await fs.rename(tmpArchivePath, archivePath);
try {
await fs.ensureDir(tmpFactorioDir);
await execFile("tar", [
"xf", archivePath, "-C", tmpFactorioDir, "--strip-components", "1",
]);
} catch (e) {
logger.error("error executing command- do you have 'xz-utils' installed?");
throw e;
}
await fs.ensureDir("factorio");
await fs.rename(tmpFactorioDir, factorioDir);
await fs.unlink(archivePath);
await fs.rm(tmpDir);
}
}
async function getFactorioVersion(factorioDir) {
const changelogPath = path.join(factorioDir, "data", "changelog.txt");
const changelog = await fs.readFile(changelogPath, "utf8");
for (const line of changelog.split(/\r?\n/)) {
const index = line.indexOf(":");
if (index === -1) {
continue;
}
if (line.slice(0, index).trim().toLowerCase() === "version") {
return line.slice(index + 1).trim();
}
}
throw new Error(`Unable to detect Factorio version from ${changelogPath}`);
}
async function findFactorioRoot(rootDir) {
const entries = await fs.readdir(rootDir);
const factorioRoot = entries.find(entry => entry === "factorio" || entry === "Factorio")
?? (entries.length === 1 ? entries[0] : null);
if (!factorioRoot) {
throw new Error(`Unable to locate extracted Factorio directory in ${rootDir}`);
}
return path.join(rootDir, factorioRoot);
}
async function downloadWindowsServer() {
let res = await fetch(windowsHeadlessDownloadUrl);
if (res.status === 202) {
throw new Error("Latest Windows headless package is not ready yet, try again in a few minutes.");
}
if (!res.ok) {
throw new Error(`Failed to fetch ${windowsHeadlessDownloadUrl}: HTTP ${res.status} ${res.statusText}`);
}
if (!res.body) {
throw new Error("Windows headless download did not include an archive payload");
}
const tmpDir = path.join("temp", "create-temp");
const archivePath = path.join(tmpDir, "factorio-headless-win64.zip");
const tmpArchivePath = `${archivePath}.tmp`;
const tmpExtractDir = path.join(tmpDir, "win64-extract");
await fs.emptyDir(tmpDir);
logger.info("Downloading latest Windows headless release. This may take a while.");
const writeStream = fs.createWriteStream(tmpArchivePath);
stream.Readable.fromWeb(res.body).pipe(writeStream);
await finished(writeStream);
await fs.rename(tmpArchivePath, archivePath);
try {
const expandArchiveCommand =
`Expand-Archive -LiteralPath '${archivePath.replace(/'/g, "''")}' ` +
`-DestinationPath '${tmpExtractDir.replace(/'/g, "''")}' -Force`;
await execFile("powershell.exe", [
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-Command",
expandArchiveCommand,
]);
} catch (err) {
logger.error("error executing command - do you have PowerShell available?");
throw err;
}
const tmpFactorioDir = await findFactorioRoot(tmpExtractDir);
const version = await getFactorioVersion(tmpFactorioDir);
const factorioDir = path.join("factorio", version);
if (await fs.pathExists(factorioDir)) {
logger.warn(`setting downloadDir to ${factorioDir}, but not downloading because already existing`);
} else {
await fs.ensureDir("factorio");
await fs.rename(tmpFactorioDir, factorioDir);
}
await fs.rm(tmpDir, { recursive: true, force: true });
}
async function main() {
let args = yargs
.option("log-level", {
@@ -772,6 +911,10 @@ async function main() {
.option("http-port", {
nargs: 1, describe: "HTTP port to listen on [standalone/controller]", type: "number",
})
.option("download-headless", {
describe: "Download the latest headless release [standalone/host]",
type: "boolean", nargs: 0,
})
.option("host-name", {
nargs: 1, describe: "Host name [host]", type: "string",
})
@@ -806,11 +949,17 @@ async function main() {
;
if (process.platform === "linux") {
args = args
.option("allow-install-as-root", {
nargs: 0, describe: "(Linux only) Allow installing as root (not recommended)", type: "boolean",
})
;
args = args.option("allow-install-as-root", {
nargs: 0, describe: "(Linux only) Allow installing as root (not recommended)", type: "boolean",
});
}
if (supportsHeadlessDownload()) {
args = args.option("download-headless", {
nargs: 0,
describe: "Automatically download and unpack the latest headless Factorio release. " +
"Can be set to false using --no-download-headless.",
type: "boolean",
});
}
args = args.argv;
@@ -836,6 +985,19 @@ async function main() {
return;
}
if (answers.downloadHeadless) {
if (answers.factorioDir !== "factorio") {
throw new InstallError("--download-headless option requires --factorio-dir to be set to factorio");
}
if (process.platform === "linux") {
await downloadLinuxServer();
} else if (process.platform === "win32") {
await downloadWindowsServer();
} else {
throw new InstallError("--download-headless is only supported on Linux and Windows");
}
}
if (!dev) {
await installClusterio(answers.mode, answers.plugins);
}
+3 -2
View File
@@ -3,7 +3,7 @@
"description": "Installer for clusterio",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.27",
"version": "2.0.0-alpha.23",
"main": "create.js",
"bin": {
"clusteriocreate": "./create.js"
@@ -13,10 +13,11 @@
"factorio"
],
"engines": {
"node": ">=22"
"node": ">=20"
},
"dependencies": {
"chalk": "catalog:",
"fs-extra": "catalog:",
"inquirer": "^8.2.4",
"yargs": "^17.7.2"
},
+2 -8
View File
@@ -1,6 +1,6 @@
"use strict";
const { logger } = require("./logging");
const fs = require("node:fs/promises");
const fs = require("fs-extra");
const path = require("path");
const _package = require("./package.json");
@@ -61,15 +61,9 @@ async function copyTemplateFile(src, dst, properties) {
return include ? content : "";
});
// Create folder for output file
const { dir } = path.parse(dst);
if (dir) {
await fs.mkdir(dir, { recursive: true });
}
// Attempt to write the output file, warn if it already exists
try {
await fs.writeFile(dst, outputData, { flag: "wx" });
await fs.outputFile(dst, outputData, { flag: "wx" });
} catch (err) {
if (err.code === "EEXIST") {
logger.warn(`Could not create file ${dst} because it already exists`);
+11 -4
View File
@@ -4,10 +4,11 @@
* Command line interface for controlling a Clusterio cluster
* @module ctl/ctl
*/
import fs from "node:fs/promises";
import fs from "fs-extra";
import yargs, { type Argv } from "yargs";
import path from "path";
import { version } from "./package.json";
import setBlocking from "set-blocking";
import { strict as assert } from "assert";
// Reduce startup time by lazy compiling schemas.
@@ -310,9 +311,15 @@ async function startControl() {
throw err;
}
// Handle interrupts
process.on("SIGINT", lib.createShutdownGuard(logger, "interrupt", control.shutdown.bind(control)));
process.on("SIGTERM", lib.createShutdownGuard(logger, "termination", control.shutdown.bind(control)));
process.on("SIGINT", () => {
logger.info("Caught interrupt signal, closing connection");
control.shutdown().catch(err => {
setBlocking(true);
logger.error(err.stack);
process.exit(1);
});
});
try {
const targetCommand = selectTargetCommand(args, rootCommands);
+8 -4
View File
@@ -3,7 +3,7 @@
"description": "Clusterio command line management tool",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.27",
"version": "2.0.0-alpha.23",
"main": "dist/node/index.js",
"scripts": {
"prepare": "tsc --build",
@@ -17,19 +17,23 @@
"factorio"
],
"engines": {
"node": ">=22"
"node": ">=20"
},
"dependencies": {
"@clusterio/lib": "workspace:^",
"as-table": "^1.0.55",
"@clusterio/lib": "workspace:*",
"set-blocking": "catalog:",
"fs-extra": "catalog:",
"winston": "catalog:",
"as-table": "^1.0.55",
"yargs": "^17.7.2"
},
"publishConfig": {
"access": "public"
},
"devDependencies": {
"@types/fs-extra": "catalog:",
"@types/node": "catalog:",
"@types/set-blocking": "catalog:",
"@types/yargs": "^17.0.33",
"typescript": "catalog:"
}
+1 -1
View File
@@ -1,4 +1,4 @@
import fs from "node:fs/promises";
import fs from "fs-extra";
import asTableModule from "as-table";
import events from "events";
import os from "os";
-20
View File
@@ -9,23 +9,3 @@ debugCommands.add(new lib.Command({
control.keepOpen = true;
},
}));
debugCommands.add(new lib.Command({
definition: [
"parse-exchange-string <exchange-string>",
"Parse and print the contents of an exchange string",
(yargs) => {
yargs.positional("exchange-string", { describe: "String to parse", type: "string" });
yargs.option("pretty", { alias: "p", type: "boolean", description: "Pretty output" });
}],
handler: async function(args: { exchangeString: string, pretty: boolean }, control: Control) {
const result = lib.readMapExchangeString(args.exchangeString);
if (args.pretty) {
// eslint-disable-next-line no-console
console.log(JSON.stringify(result, null, 2));
} else {
// eslint-disable-next-line no-console
console.log(JSON.stringify(result));
}
},
}));
+2 -2
View File
@@ -1,4 +1,4 @@
import fs from "node:fs/promises";
import fs from "fs-extra";
import asTableModule from "as-table";
import events from "events";
import os from "os";
@@ -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.outputFile(args.output, content, { flag: "wx" });
} catch (err: any) {
if (err.code === "EEXIST") {
throw new lib.CommandError(`File ${args.output} already exists`);
+27 -3
View File
@@ -1,4 +1,4 @@
import fs from "node:fs/promises";
import fs from "fs-extra";
import asTableModule from "as-table";
import events from "events";
import path from "path";
@@ -338,7 +338,7 @@ instanceSaveCommands.add(new lib.Command({
const response = await fetch(url, {
method: "POST",
body: (await fs.open(args.filepath)).createReadStream(),
body: await fs.createReadStream(args.filepath),
duplex: "half",
headers: {
"X-Access-Token": control.config.get("control.controller_token")!,
@@ -417,7 +417,31 @@ instanceSaveCommands.add(new lib.Command({
let url = new URL(control.config.get("control.controller_url")!);
url.pathname += `api/stream/${streamId}`;
const filename = await lib.downloadFile(url, args.save, "rename");
let response = await fetch(url);
if (!response.body) {
throw new lib.CommandError("Got empty response from server.");
}
let writeStream;
let tempFilename = args.save.replace(/(\.zip)?$/, ".tmp.zip");
while (true) {
try {
writeStream = fs.createWriteStream(tempFilename, { flags: "wx" });
await events.once(writeStream, "open");
break;
} catch (err: any) {
if (err.code === "EEXIST") {
tempFilename = await lib.findUnusedName(".", tempFilename, ".tmp.zip");
} else {
throw err;
}
}
}
stream.Readable.fromWeb(response.body).pipe(writeStream);
await finished(writeStream);
let filename = await lib.findUnusedName(".", args.save, ".zip");
await fs.rename(tempFilename, filename);
logger.info(`Downloaded ${args.save}${args.save === filename ? "" : ` as ${filename}`}`);
},
+29 -11
View File
@@ -1,4 +1,4 @@
import fs from "node:fs/promises";
import fs from "fs-extra";
import asTableModule from "as-table";
import events from "events";
import path from "path";
@@ -20,7 +20,7 @@ modCommands.add(new lib.Command({
yargs.positional("mod-version", { describe: "Version of the mod", type: "string" });
}],
handler: async function(args: { name: string, modVersion: string }, control: Control) {
if (!lib.isSourceVersion(args.modVersion)) {
if (!lib.isFullVersion(args.modVersion)) {
throw new lib.CommandError("mod-version must match format digit.digit.digit");
}
let modInfo = await control.send(new lib.ModGetRequest(args.name, args.modVersion));
@@ -108,13 +108,9 @@ modCommands.add(new lib.Command({
},
control: Control
) {
const searchVersion = lib.normaliseSourceVersion(String(args.factorioVersion));
if (searchVersion === undefined) {
throw new Error(`Invalid factorio version "${args.factorioVersion}"`);
}
let response = await control.send(new lib.ModSearchRequest(
args.query,
lib.normaliseMajorMinorVersion(searchVersion),
lib.normaliseApiVersion(args.factorioVersion as any),
args.page,
args.pageSize,
args.sort,
@@ -155,7 +151,7 @@ modCommands.add(new lib.Command({
let response = await fetch(url, {
method: "POST",
body: (await fs.open(args.file)).createReadStream(),
body: fs.createReadStream(args.file),
duplex: "half",
headers: {
"X-Access-Token": control.config.get("control.controller_token")!,
@@ -191,15 +187,37 @@ modCommands.add(new lib.Command({
yargs.positional("mod-version", { describe: "Version of mod to download", type: "string" });
}],
handler: async function(args: { name: string, modVersion: string }, control: Control) {
if (!lib.isSourceVersion(args.modVersion)) {
if (!lib.isFullVersion(args.modVersion)) {
throw new lib.CommandError("mod-version must match format digit.digit.digit");
}
let streamId = await control.send(new lib.ModDownloadRequest(args.name, args.modVersion));
let url = new URL(control.config.get("control.controller_url")!);
url.pathname += `api/stream/${streamId}`;
let response = await fetch(url);
if (!response.body) {
throw new lib.CommandError("Got empty response from server");
}
let writeStream;
let filename = `${args.name}_${args.modVersion}.zip`;
await lib.downloadFile(url, filename, "overwrite");
let tempFilename = filename.replace(/(\.zip)?$/, ".tmp.zip");
while (true) {
try {
writeStream = fs.createWriteStream(tempFilename, { flags: "wx" });
await events.once(writeStream, "open");
break;
} catch (err: any) {
if (err.code === "EEXIST") {
tempFilename = await lib.findUnusedName(".", tempFilename, ".tmp.zip");
} else {
throw err;
}
}
}
stream.Readable.fromWeb(response.body).pipe(writeStream);
await finished(writeStream);
await fs.rename(tempFilename, filename);
logger.info(`Downloaded ${filename}`);
},
@@ -211,7 +229,7 @@ modCommands.add(new lib.Command({
yargs.positional("mod-version", { describe: "Version of mod to delete", type: "string" });
}],
handler: async function(args: { name: string, modVersion: string }, control: Control) {
if (!lib.isSourceVersion(args.modVersion)) {
if (!lib.isFullVersion(args.modVersion)) {
throw new lib.CommandError("mod-version must match format digit.digit.digit");
}
await control.send(new lib.ModDeleteRequest(args.name, args.modVersion));
+1 -1
View File
@@ -132,7 +132,7 @@ function setModPackMods(modPack: lib.ModPack, mods: string[] | undefined) {
if (!version) {
throw new lib.CommandError("Added mod must be formatted as name:version or name:version:sha1");
}
if (!lib.isSourceVersion(version)) {
if (!lib.isFullVersion(version)) {
throw new lib.CommandError("version must match the format digit.digit.digit");
}
if (sha1 && !/^[0-9a-f]{40}$/.test(sha1)) {
+4 -4
View File
@@ -1,4 +1,4 @@
import fs from "node:fs/promises";
import fs from "fs-extra";
import asTableModule from "as-table";
import path from "path";
@@ -224,7 +224,7 @@ async function handleImportOrRestore(args: {
print(`${restore ? "Restored" : "Imported"} ${importType} from ${args.filepath}`);
if (restore && backup && !args.noBackup) {
await fs.writeFile(`${importType}-backup.json`, JSON.stringify(backup, undefined, "\t"));
await fs.writeJSON(`${importType}-backup.json`, backup, { spaces: 2 });
print(`Wrote backup to ${importType}-backup.json`);
}
}
@@ -296,9 +296,9 @@ userCommands.add(new lib.Command({
exportType = "whitelist";
}
await fs.writeFile(args.filepath, JSON.stringify(await control.send(
await fs.writeJSON(args.filepath, await control.send(
new lib.UserBulkExportRequest(exportType)
), undefined, "\t"));
), { spaces: 2 });
print(`Exported ${exportType} to ${args.filepath}`);
},
}));
+31 -6
View File
@@ -12,9 +12,10 @@
* @example
* npx clusteriohost run
*/
import fs from "node:fs/promises";
import fs from "fs-extra";
import path from "path";
import yargs from "yargs";
import setBlocking from "set-blocking";
import { version } from "./package.json";
import winston from "winston";
import "winston-daily-rotate-file";
@@ -198,9 +199,9 @@ async function startHost() {
await hostConfigLock.acquire();
// If we get here the command was run
await fs.mkdir(hostConfig.get("host.instances_directory"), { recursive: true });
await fs.mkdir(hostConfig.get("host.mods_directory"), { recursive: true });
await fs.mkdir("modules", { recursive: true });
await fs.ensureDir(hostConfig.get("host.instances_directory"));
await fs.ensureDir(hostConfig.get("host.mods_directory"));
await fs.ensureDir("modules");
// Set the process title, shows up as the title of the CMD window on windows
// and as the process name in ps/top on linux.
@@ -238,8 +239,32 @@ async function startHost() {
);
// Handle interrupts
process.on("SIGINT", lib.createShutdownGuard(logger, "interrupt", host.shutdown.bind(host)));
process.on("SIGTERM", lib.createShutdownGuard(logger, "termination", host.shutdown.bind(host)));
let secondSigint = false;
process.on("SIGINT", () => {
if (secondSigint) {
setBlocking(true);
logger.fatal("Caught second interrupt, terminating immediately");
process.exit(1);
}
secondSigint = true;
logger.info("Caught interrupt signal, shutting down");
host!.shutdown();
});
let secondSigterm = false;
process.on("SIGTERM", () => {
if (secondSigterm) {
setBlocking(true);
logger.fatal("Caught second termination, terminating immediately");
process.exit(1);
}
secondSigterm = true;
logger.info("Caught termination signal, shutting down");
host!.shutdown();
});
process.on("SIGHUP", () => {
logger.info("Terminal closed, shutting down");
host!.shutdown();
@@ -16,10 +16,6 @@
{
"version": "2.0.20",
"factorio_version": "2.0"
},
{
"version": "2.0.21",
"factorio_version": "2.1"
}
],
"title": "Clusterio Library (Alpha)",
-1
View File
@@ -9,5 +9,4 @@ script.on_init(function()
for name, mod_setting in pairs(mod_settings) do
send_json("mod_setting_mod", { name = name, mod = mod_setting.mod })
end
send_json("defines", defines)
end)
@@ -17,8 +17,6 @@ local lib_json = require("json")
--- @field prototypes LuaPrototypes
--- @field active_mods table<string, string>
--- @field write_file fun(filename: string, data: LocalisedString, append: boolean?, for_player: uint?)
--- @field encode_string fun(string: string): string
--- @field decode_string fun(string: string): string
--- @field table_to_json fun(data: table): string
--- @field json_to_table fun(json: string): AnyBasic?
local compat = {}
@@ -179,24 +177,6 @@ function runtime_properties.write_file()
end
end
--- Deflates and base64 encodes the given string.
function runtime_properties.encode_string()
if (v0_18 and compat.version_ge("0.18.7")) or v1 then
return game.encode_string
elseif v2 then
return helpers.encode_string
end
end
--- Base64 decodes and inflates the given string.
function runtime_properties.decode_string()
if (v0_18 and compat.version_ge("0.18.7")) or v1 then
return game.decode_string
elseif v2 then
return helpers.decode_string
end
end
--- Convert tables to and from json, we have backported this to work before runtime
if v0 or v1 then
compat.table_to_json = function(tbl)
+10 -8
View File
@@ -3,7 +3,7 @@
"description": "Implementation of Clusterio host server",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.27",
"version": "2.0.0-alpha.23",
"main": "dist/node/index.js",
"scripts": {
"prepare": "tsc --build",
@@ -18,19 +18,20 @@
"factorio"
],
"engines": {
"node": ">=22"
"node": ">=20"
},
"dependencies": {
"@clusterio/lib": "workspace:^",
"@clusterio/lib": "workspace:*",
"@sinclair/typebox": "catalog:",
"jimp": "^1.6.0",
"set-blocking": "catalog:",
"fs-extra": "catalog:",
"jszip": "catalog:",
"winston": "catalog:",
"winston-daily-rotate-file": "catalog:",
"jimp": "^0.22.8",
"pidusage": "^3.0.2",
"rcon-client": "^4.2.5",
"semver": "^7.7.1",
"set-blocking": "catalog:",
"winston": "catalog:",
"winston-daily-rotate-file": "catalog:",
"yargs": "^17.7.2"
},
"publishConfig": {
@@ -38,9 +39,10 @@
},
"devDependencies": {
"@types/node": "catalog:",
"@types/fs-extra": "catalog:",
"@types/set-blocking": "catalog:",
"@types/pidusage": "^2.0.2",
"@types/semver": "^7.5.0",
"@types/set-blocking": "catalog:",
"@types/yargs": "^17.0.33",
"typescript": "catalog:"
}
+61 -50
View File
@@ -1,4 +1,4 @@
import fs from "node:fs/promises";
import fs from "fs-extra";
import path from "path";
import events from "events";
import pidusage from "pidusage";
@@ -294,7 +294,7 @@ export default class Host extends lib.Link {
static async bootstrap(hostConfig: lib.HostConfig) {
const modsDirectory = hostConfig.get("host.mods_directory");
await fs.mkdir(modsDirectory, { recursive: true });
await fs.ensureDir(modsDirectory);
return [
await lib.ModStore.fromDirectory(modsDirectory),
] as const;
@@ -387,7 +387,6 @@ export default class Host extends lib.Link {
this.handle(lib.HostRestartRequest, this.handleHostRestartRequest.bind(this));
this.handle(lib.HostUpdateRequest, this.handleHostUpdateRequest.bind(this));
this.handle(lib.HostConfigGetRequest, this.handleHostConfigGetRequest.bind(this));
this.handle(lib.HostConfigSetRequest, this.handleHostConfigSetRequest.bind(this));
this.handle(lib.HostConfigSetFieldRequest, this.handleHostConfigSetFieldRequest.bind(this));
this.handle(lib.HostConfigSetPropRequest, this.handleHostConfigSetPropRequest.bind(this));
this.handle(lib.HostMetricsRequest, this.handleHostMetricsRequest.bind(this));
@@ -504,41 +503,18 @@ export default class Host extends lib.Link {
return this.config.toRemote("control");
}
private static validateHostConfigSetField(fieldName: string) {
if (fieldName === "host.id") {
async handleHostConfigSetFieldRequest(request: lib.HostConfigSetFieldRequest) {
if (request.field === "host.id") {
// Changing host.id at runtime is not worth the effort to
// support and will break a lot of things if it is allowed.
throw new lib.RequestError("Setting 'host.id' while host is running is not supported");
}
}
async handleHostConfigSetRequest(request: lib.HostConfigSetRequest) {
try {
for (const [fieldName, fieldValue] of Object.entries(request.fields)) {
Host.validateHostConfigSetField(fieldName);
if (typeof fieldValue === "object") {
for (const [propName, propValue] of Object.entries(fieldValue)) {
this.config.stageProp(fieldName as keyof lib.HostConfigFields, propName, propValue, "control");
}
} else {
this.config.stage(fieldName as keyof lib.HostConfigFields, fieldValue, "control");
}
}
this.config.commitStaging();
} finally {
this.config.revertStaging();
}
}
async handleHostConfigSetFieldRequest(request: lib.HostConfigSetFieldRequest) {
Host.validateHostConfigSetField(request.field);
this.config.set(request.field as keyof lib.HostConfigFields, request.value, "control");
}
async handleHostConfigSetPropRequest(request: lib.HostConfigSetPropRequest) {
let { field, prop, value } = request;
Host.validateHostConfigSetField(field);
this.config.setProp(field as keyof lib.HostConfigFields, prop, value, "control");
}
@@ -615,10 +591,19 @@ export default class Host extends lib.Link {
let url = new URL(this.config.get("host.controller_url"));
url.pathname += `api/stream/${streamId}`;
let response = await fetch(url);
if (!response.body) {
throw new Error(`Empty body downloading stream ${streamId} from controller`);
}
const file = lib.ModInfo.filename(mod.name, mod.version);
const filePath = path.join(this.modStore.modsDirectory, file);
await lib.downloadFile(url, filePath, "overwrite");
const tempFilePath = filePath.replace(/(\.zip)?$/, ".tmp.zip");
const writeStream = fs.createWriteStream(tempFilePath, { flags: "w" });
await events.once(writeStream, "open");
stream.Readable.fromWeb(response.body).pipe(writeStream);
await finished(writeStream);
await fs.rename(tempFilePath, filePath);
const modInfo = await this.modStore.loadFile(file);
if (mod.sha1 && mod.sha1 !== modInfo.sha1) {
@@ -659,8 +644,10 @@ export default class Host extends lib.Link {
return await this.downloadMod(mod);
}
async fetchMods(mods: Iterable<lib.ModRecord>, factorioVersion: lib.PartialVersion) {
const builtinModNames = lib.ModPack.getBuiltinModNames(factorioVersion);
async fetchMods(mods: Iterable<lib.ModRecord>) {
// This is better than the previous hard coded names
// But it really shouldn't be a hard coded version either
const builtinModNames = lib.ModPack.getBuiltinModNames("2.0");
const modInfos: Promise<lib.ModInfo>[] = [];
for (const mod of mods) {
if (builtinModNames.includes(mod.name)) {
@@ -845,7 +832,7 @@ export default class Host extends lib.Link {
// First check the clusterio version
const runningVersion = this.config.get("host.version");
const packageJsonPath = require.resolve("@clusterio/host/package.json");
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, "utf8"));
const packageJson = await fs.readJSON(packageJsonPath);
if (runningVersion !== packageJson.version) {
this.config.restartRequired = true;
return true;
@@ -862,7 +849,7 @@ 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 = await fs.readJSON(pluginPackageJsonPath);
if (pluginInfo.version !== pluginPackageJson.version) {
this.config.restartRequired = true;
return true;
@@ -929,17 +916,12 @@ export default class Host extends lib.Link {
checkRequestSaveName(newName);
let instance = this.getRequestInstance(instanceId);
try {
// Link save in the new location.
await fs.link(
await fs.move(
path.join(instance.path, "saves", oldName),
path.join(instance.path, "saves", newName),
{ overwrite: false },
);
// Unlink save in old location.
await fs.unlink(path.join(instance.path, "saves", oldName));
} catch (err: any) {
if (err.code === "EEXIST") {
throw new lib.RequestError(`${newName} already exists`);
}
if (err.code === "ENOENT") {
throw new lib.RequestError(`${oldName} does not exist`);
}
@@ -954,10 +936,10 @@ export default class Host extends lib.Link {
checkRequestSaveName(destination);
let instance = this.getRequestInstance(instanceId);
try {
await fs.copyFile(
await fs.copy(
path.join(instance.path, "saves", source),
path.join(instance.path, "saves", destination),
fs.constants.COPYFILE_EXCL | fs.constants.COPYFILE_FICLONE
{ overwrite: false, errorOnExist: true },
);
} catch (err: any) {
if (err.code === "ENOENT") {
@@ -983,15 +965,16 @@ export default class Host extends lib.Link {
try {
if (copy) {
await fs.copyFile(
await fs.copy(
path.join(sourceInstance.path, "saves", sourceName),
path.join(targetInstance.path, "saves", targetName),
fs.constants.COPYFILE_FICLONE
{ overwrite: true },
);
} else {
await fs.rename(
await fs.move(
path.join(sourceInstance.path, "saves", sourceName),
path.join(targetInstance.path, "saves", targetName),
{ overwrite: true },
);
}
} catch (err: any) {
@@ -1041,9 +1024,35 @@ export default class Host extends lib.Link {
let url = new URL(this.config.get("host.controller_url"));
url.pathname += `api/stream/${streamId}`;
let response = await fetch(url);
if (response.status !== 200 || !response.body) {
let body = Buffer.from(await response.arrayBuffer());
throw new lib.RequestError(`Stream returned ${response.status}: ${body.toString()}`);
}
let savesDir = path.join(instance.path, "saves");
const savedPath = await lib.downloadFile(url, path.join(savesDir, name), "rename");
name = path.basename(savedPath);
let tempFilename = name.replace(/(\.zip)?$/, ".tmp.zip");
let writeStream: NodeJS.WritableStream;
while (true) {
try {
writeStream = fs.createWriteStream(path.join(savesDir, tempFilename), { flags: "wx" });
await events.once(writeStream, "open");
break;
} catch (err: any) {
if (err.code === "EEXIST") {
tempFilename = await lib.findUnusedName(savesDir, tempFilename, ".tmp.zip");
} else {
throw err;
}
}
}
stream.Readable.fromWeb(response.body).pipe(writeStream);
await finished(writeStream);
name = await lib.findUnusedName(savesDir, name, ".zip");
await fs.rename(path.join(savesDir, tempFilename), path.join(savesDir, name));
await this.sendSaveListUpdate(instanceId, savesDir);
return name;
}
@@ -1053,9 +1062,11 @@ export default class Host extends lib.Link {
checkRequestSaveName(name);
let instance = this.getRequestInstance(instanceId);
let content;
let content = fs.createReadStream(path.join(instance.path, "saves", name));
try {
content = (await fs.open(path.join(instance.path, "saves", name))).createReadStream();
// TODO: Due to a bug in fs-extra the ready event is not emitted here. Replace
// "open" with "ready" when fs-extra is replaced with node's fs module.
await events.once(content, "open");
} catch (err: any) {
if (err.code === "ENOENT") {
throw new lib.RequestError(`${name} does not exist`);
@@ -1087,7 +1098,7 @@ export default class Host extends lib.Link {
this.discoveredInstances.delete(instanceId);
this.assignedInstances.delete(instanceId);
await fs.rm(instance.path, { recursive: true, maxRetries: 10 });
await fs.remove(instance.path);
}
async handleHostUpdateRequest(request: lib.HostUpdateRequest) {
+10 -28
View File
@@ -1,4 +1,4 @@
import fs from "node:fs/promises";
import fs from "fs-extra";
import path from "path";
import pidusage from "pidusage";
import util from "util";
@@ -642,21 +642,6 @@ end`.replace(/\r?\n/g, " ");
try {
const factorioVersions = await this.sendTo("controller", new lib.FactorioVersionsRequest());
await this._loadStats();
// Resolve a release channel target (e.g. "stable") to a concrete
// version using the controller's cached latest-releases data, so the
// existing download/lookup logic can fetch and run it.
const targetVersion = this.config.get("factorio.version") as string;
if (lib.isReleaseChannel(targetVersion)) {
const releases = await this.sendTo("controller", new lib.LatestReleasesRequest());
const resolved = lib.resolveReleaseChannel(releases, targetVersion);
if (!resolved) {
throw new Error(
`Cannot resolve Factorio release channel '${targetVersion}': ` +
"latest-releases unavailable and no cached copy on the controller."
);
}
this.server.setTargetVersion(resolved);
}
await this.server.checkForUpdates(factorioVersions);
await this.server.init();
} catch (err) {
@@ -770,8 +755,8 @@ end`.replace(/\r?\n/g, " ");
* @param instanceDir -
*/
static async populate_folders(instanceDir: string) {
await fs.mkdir(path.join(instanceDir, "script-output"), { recursive: true });
await fs.mkdir(path.join(instanceDir, "saves"), { recursive: true });
await fs.ensureDir(path.join(instanceDir, "script-output"));
await fs.ensureDir(path.join(instanceDir, "saves"));
}
/**
@@ -797,9 +782,11 @@ end`.replace(/\r?\n/g, " ");
}
this.activeModPack = modPack;
const mods = await this._host.fetchMods(modPack.mods.values(), modPack.factorioVersion);
// TODO validate factorioVersion
await fs.mkdir(this.path("mods"), { recursive: true });
const mods = await this._host.fetchMods(modPack.mods.values());
await fs.ensureDir(this.path("mods"));
// Remove all files
for (let entry of await fs.readdir(this.path("mods"), { withFileTypes: true })) {
@@ -838,12 +825,12 @@ end`.replace(/\r?\n/g, " ");
}
// Write mod-list.json
await fs.writeFile(this.path("mods", "mod-list.json"), JSON.stringify({
await fs.outputFile(this.path("mods", "mod-list.json"), JSON.stringify({
mods: [...this.activeModPack.mods.values()],
}, null, "\t"));
// Write mod-settings.dat
await fs.writeFile(this.path("mods", "mod-settings.dat"), this.activeModPack.toModSettingsDat());
await fs.outputFile(this.path("mods", "mod-settings.dat"), this.activeModPack.toModSettingsDat());
}
/**
@@ -934,12 +921,7 @@ end`.replace(/\r?\n/g, " ");
now.getUTCMinutes().toLocaleString("en", { minimumIntegerDigits: 2 }),
saveName,
);
// This will overwrite the save if this runs twice in the same minute.
await fs.copyFile(
this.path("saves", saveName),
this.path("saves", newName),
fs.constants.COPYFILE_FICLONE,
);
await fs.copy(this.path("saves", saveName), this.path("saves", newName));
saveName = newName;
}
+169 -134
View File
@@ -1,16 +1,19 @@
// Export of item icons and locale
import fs from "node:fs/promises";
import fs from "fs-extra";
import path from "path";
import { Jimp, JimpMime, type JimpInstance } from "jimp";
import Jimp 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";
interface Prototype {
[index: string]: unknown,
type: string,
name: string,
}
interface SimpleIconSpecification {
icons?: never;
icon: string;
icon_size?: number;
icon_mipmaps?: number;
@@ -29,9 +32,11 @@ interface LayeredIconSpecification {
icon_mipmaps?: number;
}
type IconSpecification = LayeredIconSpecification | SimpleIconSpecification;
type PrototypeWithIcon = lib.FactorioPrototypeBase & IconSpecification;
type ItemPrototype = Prototype & IconSpecification;
type Prototypes = Record<string, Record<string, Prototype>>
/**
* Generate the export mod needed for exportData
*
@@ -64,7 +69,7 @@ async function generateExportMod(server: FactorioServer) {
});
}
let zipCache = new Map<string, JSZip>();
let zipCache = new Map();
async function loadZip(server: FactorioServer, modVersions: Map<string, string>, mod: string) {
let modVersion = modVersions.get(mod);
if (!modVersion) {
@@ -78,7 +83,7 @@ async function loadZip(server: FactorioServer, modVersions: Map<string, string>,
zipCache.set(zipPath, zip);
}
return zip.folder(lib.findRoot(zip))!;
return zip.folder(lib.findRoot(zip));
}
/**
@@ -129,8 +134,7 @@ async function loadFile(server: FactorioServer, modVersions: Map<string, string>
return await file.async("nodebuffer");
}
type Image = JimpInstance;
type IconCache = Map<string, Image | null>;
type IconCache = Map<string, Jimp | null>;
async function loadIcon(
server: FactorioServer,
modVersions: Map<string, string>,
@@ -143,8 +147,8 @@ async function loadIcon(
if (icon === undefined) {
let fileContent = await loadFile(server, modVersions, iconPath);
if (fileContent) {
icon = await Jimp.fromBuffer(fileContent) as Image;
icon.crop({ x: 0, y: 0, w: iconSize, h: iconSize });
icon = await Jimp.read(fileContent);
icon.crop(0, 0, iconSize, iconSize);
iconCache.set(iconPath, icon);
} else {
icon = null;
@@ -180,7 +184,7 @@ async function loadLayeredIcon(
iconCache: IconCache,
) {
let baseLayerSize = (item.icons[0].icon_size || item.icon_size) ?? 64;
let icon = new Jimp({ width: size, height: size });
let icon = await Jimp.create(size, size);
// The scaling factor of the base layer
let baseLayerScale = item.icons[0].scale || 32 / baseLayerSize;
@@ -242,7 +246,7 @@ async function loadLayeredIcon(
return icon;
}
function fixIcons(item: PrototypeWithIcon) {
function fixIcons(item: ItemPrototype) {
const icons = item.icons;
if (typeof icons === "object" && !(icons instanceof Array) && icons !== null) {
// It's possible to specify icons as an object with arbitrary keys
@@ -254,28 +258,84 @@ function fixIcons(item: PrototypeWithIcon) {
return item;
}
const prototypesWithIcon = [
"item",
"fluid",
"recipe",
"virtual-signal",
"technology",
"space-location",
"quality",
"entity",
] as const;
/** Get the primary icon path from a prototype (first layer for layered, .icon for simple). */
function getPrimaryIconPath(item: ItemPrototype): string | undefined {
if (item.icons) {
return (item as LayeredIconSpecification).icons[0]?.icon;
}
return (item as SimpleIconSpecification).icon;
}
function filterAndFixPrototypesWithIcons(prototypes: lib.FactorioPrototypes, allowedTypes: Set<string>) {
const itemTypes = new Set([
"item",
"ammo",
"capsule",
"gun",
"item-with-entity-data",
"item-with-label",
"item-with-inventory",
"blueprint-book",
"item-with-tags",
"selection-tool",
"blueprint",
"copy-paste-tool",
"deconstruction-item",
"upgrade-item",
"module",
"rail-planner",
"spidertron-remote",
"tool",
"armor",
"mining-tool",
"repair-tool",
// XXX Bad hack to get icons for fluids. These should be treated as a
// separate namespace from items as they may be named the same as items.
"fluid",
]);
const recipeTypes = new Set(["recipe"]);
const signalTypes = new Set(["virtual-signal"]);
const technologyTypes = new Set(["technology"]);
const planetTypes = new Set(["planet", "space-location"]);
const qualityTypes = new Set(["quality"]);
// Entity types to export. Entities whose name matches an already-exported
const entityTypes = new Set([
"assembling-machine", "furnace", "inserter", "transport-belt", "underground-belt",
"splitter", "loader", "loader-1x1", "mining-drill", "container", "logistic-container",
"electric-pole", "pipe", "pipe-to-ground", "pump", "storage-tank", "boiler",
"generator", "solar-panel", "accumulator", "reactor", "heat-pipe", "lab", "beacon",
"roboport", "lamp", "radar", "wall", "gate", "land-mine", "turret", "ammo-turret",
"electric-turret", "fluid-turret", "artillery-turret", "rocket-silo", "car",
"spider-vehicle", "locomotive", "cargo-wagon", "fluid-wagon", "artillery-wagon",
"train-stop", "rail-signal", "rail-chain-signal", "straight-rail", "curved-rail-a",
"curved-rail-b", "half-diagonal-rail", "elevated-straight-rail", "elevated-curved-rail-a",
"elevated-curved-rail-b", "elevated-half-diagonal-rail", "rail-ramp", "rail-support",
"space-platform-hub", "cargo-bay", "asteroid-collector", "thruster", "cargo-pod",
"cargo-landing-pad", "asteroid",
"construction-robot", "logistic-robot", "combat-robot", "capture-robot",
"unit", "unit-spawner", "spider-unit", "segmented-unit",
"tree", "plant", "fish", "resource", "cliff", "simple-entity",
"simple-entity-with-owner", "simple-entity-with-force",
"arithmetic-combinator", "decider-combinator", "constant-combinator",
"selector-combinator", "programmable-speaker", "power-switch", "display-panel",
"character",
"offshore-pump", "valve", "lightning-attractor",
"fusion-generator", "fusion-reactor", "burner-generator",
"agricultural-tower", "market", "lane-splitter",
]);
function filterPrototypes(prototypes: Prototypes, allowedTypes: Set<string>): ItemPrototype[] {
return Object.entries(prototypes)
.filter(([type]) => allowedTypes.has(type))
.flatMap(([_, typePrototypes]) => Object.values(typePrototypes) as PrototypeWithIcon[])
.flatMap(([_, typePrototypes]) => Object.values(typePrototypes) as ItemPrototype[])
.map(fixIcons);
}
/** Shared state for building a unified spritesheet across all categories. */
interface SheetState {
sheet: Image;
metadata: Record<string, lib.ExportMetadataEntry[]>;
sheet: Jimp;
metadata: Map<string, { x: number, y: number, size: number, category: string, path?: string }>;
iconCache: IconCache;
simpleIcons: Map<string, number>;
pos: number;
@@ -286,8 +346,8 @@ const SHEET_WIDTH = 1024;
async function createSheetState(): Promise<SheetState> {
return {
sheet: new Jimp({ width: SHEET_WIDTH, height: SHEET_ICON_SIZE }),
metadata: Object.create(null),
sheet: await Jimp.create(SHEET_WIDTH, SHEET_ICON_SIZE),
metadata: new Map(),
iconCache: new Map(),
simpleIcons: new Map(),
pos: 0,
@@ -299,98 +359,79 @@ async function growSheet(state: SheetState) {
const row = Math.floor(state.pos / (SHEET_WIDTH / SHEET_ICON_SIZE));
const neededHeight = (row + 1) * SHEET_ICON_SIZE;
if (neededHeight > state.sheet.bitmap.height) {
const extended = new Jimp({ width: SHEET_WIDTH, height: neededHeight });
const extended = await Jimp.create(SHEET_WIDTH, neededHeight);
extended.composite(state.sheet, 0, 0);
(state.sheet as any).bitmap = (extended as any).bitmap;
}
}
async function exportIcon(
server: FactorioServer,
modVersions: Map<string, string>,
state: SheetState,
prototype: PrototypeWithIcon,
) {
let icon: Image | null = null;
let iconPos: number | undefined;
let iconData: lib.ExportMetadataEntry["icon"];
let iconPath: string | undefined;
if (prototype.icons) {
icon = await loadLayeredIcon(
server, modVersions, prototype, SHEET_ICON_SIZE, state.iconCache,
);
iconPos = state.pos;
iconPath = prototype.icons[0].icon;
} else if (prototype.icon) {
iconPos = state.simpleIcons.get(prototype.icon);
iconPath = prototype.icon;
if (iconPos === undefined) {
icon = await loadSimpleIcon(
server, modVersions, prototype, SHEET_ICON_SIZE, state.iconCache,
);
if (icon) {
iconPos = state.pos;
state.simpleIcons.set(prototype.icon, state.pos);
}
}
}
if (iconPos !== undefined) {
iconData = {
x: iconPos * SHEET_ICON_SIZE % SHEET_WIDTH,
y: Math.floor(iconPos / (SHEET_WIDTH / SHEET_ICON_SIZE)) * SHEET_ICON_SIZE,
size: SHEET_ICON_SIZE,
path: iconPath,
};
}
if (icon) {
await growSheet(state);
state.sheet.composite(
icon,
state.pos * SHEET_ICON_SIZE % SHEET_WIDTH,
Math.floor(state.pos / (SHEET_WIDTH / SHEET_ICON_SIZE)) * SHEET_ICON_SIZE,
);
state.pos += 1;
}
return iconData;
}
/**
* Export prototype icons for a single base prototype onto the shared spritesheet.
* Export item icons for a single category onto the shared spritesheet.
*
* @param server - The server to load icons from.
* @param modVersions - Mapping of mod name to version.
* @param prototypes - Array of prototypes with icon to export for this base type.
* @param baseName - name of base type (used in metadata).
* @param items - Array of item prototypes to export.
* @param category - Category name for metadata.
* @param state - Shared spritesheet state (mutated in place).
* @returns Number of icons exported for this base type.
* @returns Number of icons exported for this category.
* @internal
*/
async function exportIcons(
async function exportItems(
server: FactorioServer,
modVersions: Map<string, string>,
prototypes: PrototypeWithIcon[],
baseName: string,
items: ItemPrototype[],
category: string,
state: SheetState,
) {
const metadata = state.metadata[baseName] ?? [];
state.metadata[baseName] ??= metadata;
let count = 0;
for (let prototype of prototypes) {
const icon = await exportIcon(server, modVersions, state, prototype);
for (let item of items) {
// Skip prototypes with no icon data at all
if (!item.icons && !item.icon) {
continue;
}
let icon: Jimp | null = null;
let iconPos: number | undefined;
if (item.icons) {
icon = await loadLayeredIcon(
server, modVersions, item as LayeredIconSpecification, SHEET_ICON_SIZE, state.iconCache,
);
iconPos = state.pos;
} else {
iconPos = state.simpleIcons.get(item.icon as string);
if (iconPos === undefined) {
icon = await loadSimpleIcon(
server, modVersions, item as SimpleIconSpecification, SHEET_ICON_SIZE, state.iconCache,
);
if (icon) {
iconPos = state.pos;
state.simpleIcons.set(item.icon as string, state.pos);
}
}
}
if (iconPos !== undefined) {
const iconPath = getPrimaryIconPath(item);
state.metadata.set(`${category}.${item.name}`, {
x: iconPos * SHEET_ICON_SIZE % SHEET_WIDTH,
y: Math.floor(iconPos / (SHEET_WIDTH / SHEET_ICON_SIZE)) * SHEET_ICON_SIZE,
size: SHEET_ICON_SIZE,
category,
...(iconPath ? { path: iconPath } : {}),
});
}
if (icon) {
await growSheet(state);
state.sheet.composite(
icon,
state.pos * SHEET_ICON_SIZE % SHEET_WIDTH,
Math.floor(state.pos / (SHEET_WIDTH / SHEET_ICON_SIZE)) * SHEET_ICON_SIZE,
);
state.pos += 1;
count += 1;
}
metadata.push({
name: prototype.name,
type: prototype.type,
localised_name: prototype.localised_name,
icon,
});
}
return count;
@@ -464,7 +505,7 @@ async function exportLocale(
}
/**
* Export locale, icons and metadata for the given factorio server
* Export the locale and item icons for the given factorio server
*
* @param server - The server to export the data from.
* @returns zip file with exported data.
@@ -472,13 +513,12 @@ async function exportLocale(
export async function exportData(server: FactorioServer) {
await generateExportMod(server);
let settings: lib.ExportSettings = {};
let prototypes: lib.ExportPrototypes = {};
let settings: Prototypes = {};
let prototypes: Prototypes = {};
let modVersions = new Map();
let modOrder: string[] = [];
let defines: lib.FactorioDefines | undefined;
function add(obj: lib.FactorioPrototypes, prototype: lib.FactorioPrototypeBase) {
function add(obj: Prototypes, prototype: Prototype) {
if (!Object.prototype.hasOwnProperty.call(obj, prototype.type)) {
obj[prototype.type] = {};
}
@@ -497,7 +537,6 @@ export async function exportData(server: FactorioServer) {
server._logger.error(`Unable to find ${name} in settings prototypes`);
});
server.on("ipc-mod_list", data => { modVersions = new Map(Object.entries(data)); });
server.on("ipc-defines", data => { defines = data; });
server.on("output", parsed => {
if (parsed.format === "seconds" && parsed.type === "generic") {
let match = /^Checksum of (.*): \d+$/.exec(parsed.message);
@@ -518,15 +557,6 @@ export async function exportData(server: FactorioServer) {
throw new Error("No prototypes got exported");
}
if (!defines) {
throw new Error("No defines got exported");
}
// compat with versions prior to 1.1.31
if (!defines.prototypes) {
defines.prototypes = definesPrototypesFallback;
}
// Some mod authors put leading zeros into the versions of their zip files.
let splitter = /^(.*)_(\d+)\.(\d+)\.(\d+)\.zip?$/;
for (let entry of await fs.readdir(server.writePath("mods"))) {
@@ -549,40 +579,45 @@ export async function exportData(server: FactorioServer) {
}
}
const categories: { name: string, types: Set<string> }[] = [
{ name: "item", types: itemTypes },
{ name: "recipe", types: recipeTypes },
{ name: "signal", types: signalTypes },
{ name: "technology", types: technologyTypes },
{ name: "planet", types: planetTypes },
{ name: "quality", types: qualityTypes },
{ name: "entity", types: entityTypes },
];
let locale = await exportLocale(server, modVersions, modOrder, "en");
// Free up the memory used by zip files loaded during the export.
zipCache.clear();
let zip = new JSZip();
zip.file("export/settings.json", JSON.stringify(settings satisfies lib.ExportSettings));
zip.file("export/prototypes.json", JSON.stringify(prototypes satisfies lib.ExportPrototypes));
zip.file("export/locale.json", JSON.stringify([...locale.entries()] satisfies lib.ExportLocale));
zip.file("export/defines.json", JSON.stringify(defines satisfies lib.ExportDefines));
zip.file("export/settings.json", JSON.stringify(settings));
zip.file("export/prototypes.json", JSON.stringify(prototypes));
zip.file("export/locale.json", JSON.stringify([...locale.entries()]));
// Build a single unified spritesheet across all prototypes with icons.
// Build a single unified spritesheet across all prototype categories.
const state = await createSheetState();
for (const baseName of prototypesWithIcon) {
if (!Object.prototype.hasOwnProperty.call(defines.prototypes, baseName)) {
continue;
}
const subTypeNames = new Set(Object.keys(defines.prototypes[baseName]));
const prototypesWithIcons = filterAndFixPrototypesWithIcons(prototypes, subTypeNames);
for (const category of categories) {
const categoryItems = filterPrototypes(prototypes, category.types);
if (prototypesWithIcons.length === 0) {
if (categoryItems.length === 0) {
continue;
}
const count = await exportIcons(server, modVersions, prototypesWithIcons, baseName, state);
server._logger.info(`Exported ${count} ${baseName} icons`);
const count = await exportItems(server, modVersions, categoryItems, category.name, state);
server._logger.info(`Exported ${count} ${category.name} icons`);
}
// Crop sheet to actual used height and write single spritesheet + metadata.
const usedRows = Math.ceil(state.pos / (SHEET_WIDTH / SHEET_ICON_SIZE));
state.sheet.crop({ x: 0, y: 0, w: SHEET_WIDTH, h: Math.max(usedRows * SHEET_ICON_SIZE, 1) });
zip.file("export/spritesheet.png", await state.sheet.getBuffer(JimpMime.png));
zip.file("export/metadata.json", JSON.stringify(state.metadata satisfies lib.ExportMetadata));
state.sheet.crop(0, 0, SHEET_WIDTH, Math.max(usedRows * SHEET_ICON_SIZE, 1));
zip.file("export/spritesheet.png", await state.sheet.getBufferAsync(Jimp.MIME_PNG));
zip.file("export/metadata.json", JSON.stringify([...state.metadata.entries()]));
server._logger.info(`Export complete: ${state.pos} icons on ${usedRows} row(s)`);
server._logger.info(`Export complete: ${state.metadata.size} icons on ${usedRows} row(s)`);
return zip;
}
-294
View File
@@ -1,294 +0,0 @@
// defines.prototypes from Factorio 1.1.31, the first version that has this.
export const definesPrototypesFallback = {
"achievement": {
"achievement": 0,
"build-entity-achievement": 0,
"combat-robot-count": 0,
"construct-with-robots-achievement": 0,
"deconstruct-with-robots-achievement": 0,
"deliver-by-robots-achievement": 0,
"dont-build-entity-achievement": 0,
"dont-craft-manually-achievement": 0,
"dont-use-entity-in-energy-production-achievement": 0,
"finish-the-game-achievement": 0,
"group-attack-achievement": 0,
"kill-achievement": 0,
"player-damaged-achievement": 0,
"produce-achievement": 0,
"produce-per-hour-achievement": 0,
"research-achievement": 0,
"train-path-achievement": 0,
},
"ambient-sound": {
"ambient-sound": 0,
},
"ammo-category": {
"ammo-category": 0,
},
"animation": {
"animation": 0,
},
"autoplace-control": {
"autoplace-control": 0,
},
"custom-input": {
"custom-input": 0,
},
"damage-type": {
"damage-type": 0,
},
"decorative": {
"optimized-decorative": 0,
},
"editor-controller": {
"editor-controller": 0,
},
"entity": {
"accumulator": 0,
"ammo-turret": 0,
"arithmetic-combinator": 0,
"arrow": 0,
"artillery-flare": 0,
"artillery-projectile": 0,
"artillery-turret": 0,
"artillery-wagon": 0,
"assembling-machine": 0,
"beacon": 0,
"beam": 0,
"boiler": 0,
"burner-generator": 0,
"car": 0,
"cargo-wagon": 0,
"character": 0,
"character-corpse": 0,
"cliff": 0,
"combat-robot": 0,
"constant-combinator": 0,
"construction-robot": 0,
"container": 0,
"corpse": 0,
"curved-rail": 0,
"decider-combinator": 0,
"deconstructible-tile-proxy": 0,
"electric-energy-interface": 0,
"electric-pole": 0,
"electric-turret": 0,
"entity-ghost": 0,
"explosion": 0,
"fire": 0,
"fish": 0,
"flame-thrower-explosion": 0,
"fluid-turret": 0,
"fluid-wagon": 0,
"flying-text": 0,
"furnace": 0,
"gate": 0,
"generator": 0,
"heat-interface": 0,
"heat-pipe": 0,
"highlight-box": 0,
"infinity-container": 0,
"infinity-pipe": 0,
"inserter": 0,
"item-entity": 0,
"item-request-proxy": 0,
"lab": 0,
"lamp": 0,
"land-mine": 0,
"leaf-particle": 0,
"linked-belt": 0,
"linked-container": 0,
"loader": 0,
"loader-1x1": 0,
"locomotive": 0,
"logistic-container": 0,
"logistic-robot": 0,
"market": 0,
"mining-drill": 0,
"offshore-pump": 0,
"particle": 0,
"particle-source": 0,
"pipe": 0,
"pipe-to-ground": 0,
"player-port": 0,
"power-switch": 0,
"programmable-speaker": 0,
"projectile": 0,
"pump": 0,
"radar": 0,
"rail-chain-signal": 0,
"rail-remnants": 0,
"rail-signal": 0,
"reactor": 0,
"resource": 0,
"roboport": 0,
"rocket-silo": 0,
"rocket-silo-rocket": 0,
"rocket-silo-rocket-shadow": 0,
"simple-entity": 0,
"simple-entity-with-force": 0,
"simple-entity-with-owner": 0,
"smoke": 0,
"smoke-with-trigger": 0,
"solar-panel": 0,
"speech-bubble": 0,
"spider-leg": 0,
"spider-vehicle": 0,
"splitter": 0,
"sticker": 0,
"storage-tank": 0,
"straight-rail": 0,
"stream": 0,
"tile-ghost": 0,
"train-stop": 0,
"transport-belt": 0,
"tree": 0,
"turret": 0,
"underground-belt": 0,
"unit": 0,
"unit-spawner": 0,
"wall": 0,
},
"equipment": {
"active-defense-equipment": 0,
"battery-equipment": 0,
"belt-immunity-equipment": 0,
"energy-shield-equipment": 0,
"generator-equipment": 0,
"movement-bonus-equipment": 0,
"night-vision-equipment": 0,
"roboport-equipment": 0,
"solar-panel-equipment": 0,
},
"equipment-category": {
"equipment-category": 0,
},
"equipment-grid": {
"equipment-grid": 0,
},
"fluid": {
"fluid": 0,
},
"font": {
"font": 0,
},
"fuel-category": {
"fuel-category": 0,
},
"god-controller": {
"god-controller": 0,
},
"gui-style": {
"gui-style": 0,
},
"item": {
"ammo": 0,
"armor": 0,
"blueprint": 0,
"blueprint-book": 0,
"capsule": 0,
"copy-paste-tool": 0,
"deconstruction-item": 0,
"gun": 0,
"item": 0,
"item-with-entity-data": 0,
"item-with-inventory": 0,
"item-with-label": 0,
"item-with-tags": 0,
"mining-tool": 0,
"module": 0,
"rail-planner": 0,
"repair-tool": 0,
"selection-tool": 0,
"spidertron-remote": 0,
"tool": 0,
"upgrade-item": 0,
},
"item-group": {
"item-group": 0,
},
"item-subgroup": {
"item-subgroup": 0,
},
"map-gen-presets": {
"map-gen-presets": 0,
},
"map-settings": {
"map-settings": 0,
},
"module-category": {
"module-category": 0,
},
"mouse-cursor": {
"mouse-cursor": 0,
},
"noise-expression": {
"noise-expression": 0,
},
"noise-layer": {
"noise-layer": 0,
},
"particle": {
"optimized-particle": 0,
},
"recipe": {
"recipe": 0,
},
"recipe-category": {
"recipe-category": 0,
},
"resource-category": {
"resource-category": 0,
},
"shortcut": {
"shortcut": 0,
},
"sound": {
"sound": 0,
},
"spectator-controller": {
"spectator-controller": 0,
},
"sprite": {
"sprite": 0,
},
"technology": {
"technology": 0,
},
"tile": {
"tile": 0,
},
"tile-effect": {
"tile-effect": 0,
},
"tips-and-tricks-item": {
"tips-and-tricks-item": 0,
},
"tips-and-tricks-item-category": {
"tips-and-tricks-item-category": 0,
},
"trigger-target-type": {
"trigger-target-type": 0,
},
"trivial-smoke": {
"trivial-smoke": 0,
},
"tutorial": {
"tutorial": 0,
},
"utility-constants": {
"utility-constants": 0,
},
"utility-sounds": {
"utility-sounds": 0,
},
"utility-sprites": {
"utility-sprites": 0,
},
"virtual-signal": {
"virtual-signal": 0,
},
"wind-sound": {
"wind-sound": 0,
},
};
+7 -7
View File
@@ -1,7 +1,7 @@
// Library for patching Factorio saves with scenario code.
import events from "events";
import fs from "node:fs/promises";
import fs from "fs-extra";
import JSZip from "jszip";
import path from "path";
import semver from "semver";
@@ -93,9 +93,7 @@ export class SaveModule {
static async fromPlugin(plugin: BaseInstancePlugin) {
let pluginPackagePath = require.resolve(path.posix.join(plugin.info.requirePath, "package.json"));
let moduleDirectory = path.join(path.dirname(pluginPackagePath), "module");
try {
await fs.access(moduleDirectory);
} catch (err: any) {
if (!await fs.pathExists(moduleDirectory)) {
return null;
}
@@ -433,13 +431,15 @@ export async function patch(savePath: string, modules: SaveModule[]) {
root.file("clusterio.json", JSON.stringify(patchInfo, null, "\t"));
// Write back the save
let tempSavePath = savePath.replace(/(\.zip)?$/, ".tmp.zip");
let stream = zip.generateNodeStream({ compression: "DEFLATE" });
const [tempSavePath, writeStream] = await lib.createTempWriteStream(savePath);
let fd = await fs.open(tempSavePath, "w");
try {
let pipe = stream.pipe(writeStream);
let pipe = stream.pipe(fs.createWriteStream("", { fd, autoClose: false }));
await events.once(pipe, "finish");
await fs.fsync(fd);
} finally {
writeStream.destroy();
await fs.close(fd);
}
await fs.rename(tempSavePath, savePath);
}
+113 -76
View File
@@ -1,6 +1,5 @@
// Factorio Server interface
import fs from "node:fs/promises";
import { type FSWatcher, watch as fsWatch, writeFileSync } from "node:fs";
import fs from "fs-extra";
import child_process from "child_process";
import path from "path";
import JSZip from "jszip";
@@ -20,7 +19,7 @@ const execFileAsync = util.promisify(child_process.execFile);
* @param factorioDir - Path factorio dir containing data/changelog.txt.
*/
async function getFactorioVersion(factorioDir: string) {
let changelog;
let changelog: string;
const changelogPath = path.join(factorioDir, "data", "changelog.txt");
try {
changelog = await fs.readFile(changelogPath, "utf-8");
@@ -107,34 +106,40 @@ async function listFactorioVersions(factorioDir: string) {
* @throws Network error if status of request is not ok
*/
async function downloadAndExtractZip(url: string, targetDir: string) {
const res = await fetch(url);
if (!res.ok) {
throw new Error(`Failed to fetch ${url}: ${res.status} ${res.statusText}`);
}
const res = await fetchDownload(url);
// Load the ZIP with JSZip
// Load the ZIP with JSZip
const buffer = Buffer.from(await res.arrayBuffer());
const zip = await JSZip.loadAsync(buffer);
const tmpTargetDir = `${targetDir}.tmp-extract`;
const stripRootDir = hasSingleArchiveRoot(Object.values(zip.files));
// Extract each file/folder
await fs.mkdir(targetDir, { recursive: true });
await Promise.all(
Object.values(zip.files).map(async (entry) => {
const parts = entry.name.split("/").filter(Boolean);
const strippedPath = parts.slice(1).join("/");
if (!strippedPath) {
return; // This copies the behaviour of tar --strip-components 1
}
await fs.remove(tmpTargetDir);
await fs.ensureDir(tmpTargetDir);
try {
await Promise.all(
Object.values(zip.files).map(async (entry) => {
const parts = entry.name.split("/").filter(Boolean);
const strippedParts = stripRootDir ? parts.slice(1) : parts;
if (!strippedParts.length) {
return;
}
const entryPath = path.join(targetDir, strippedPath);
if (entry.dir) {
await fs.mkdir(entryPath, { recursive: true });
} else {
await fs.mkdir(path.dirname(entryPath), { recursive: true });
await fs.writeFile(entryPath, await entry.async("nodebuffer"));
}
})
);
const entryPath = validatedExtractPath(tmpTargetDir, strippedParts);
if (entry.dir) {
await fs.ensureDir(entryPath);
} else {
await fs.ensureDir(path.dirname(entryPath));
await fs.writeFile(entryPath, await entry.async("nodebuffer"));
}
})
);
await fs.remove(targetDir);
await fs.rename(tmpTargetDir, targetDir);
} catch (err) {
await fs.remove(tmpTargetDir);
throw err;
}
}
/**
@@ -145,20 +150,19 @@ async function downloadAndExtractZip(url: string, targetDir: string) {
* @throws Network error if status of request is not ok
*/
async function downloadAndExtractTar(url: string, targetDir: string) {
const res = await fetch(url);
if (!res.ok) {
throw new Error(`Failed to fetch ${url}: ${res.status} ${res.statusText}`);
}
const res = await fetchDownload(url);
// Write the buffer to a temp file
const tmpFilePath = `${targetDir}.tmp`;
const buffer = Buffer.from(await res.arrayBuffer());
await fs.writeFile(tmpFilePath, buffer);
await fs.mkdir(targetDir, { recursive: true });
await fs.ensureDir(targetDir);
// Execute tar CLI to extract
try {
await execFileAsync("tar", ["-xf", tmpFilePath, "-C", targetDir, "--strip-components", "1"]);
await execFileAsync("tar", [
"-xf", tarPath(tmpFilePath), "-C", tarPath(targetDir), "--strip-components", "1",
]);
} catch (err: any) {
if (err.code === "ENOENT") {
throw new Error("error executing tar command, do you have 'xz-utils' installed?");
@@ -166,10 +170,61 @@ async function downloadAndExtractTar(url: string, targetDir: string) {
throw err;
} finally {
// Clean up temp file
await fs.rm(tmpFilePath);
await fs.remove(tmpFilePath);
}
}
async function fetchDownload(url: string) {
const res = await fetch(url);
if (res.status === 202) {
let detail = "";
try {
detail = await res.text();
} catch {
}
throw new Error(`Download is not ready yet for ${url}${detail ? `: ${detail}` : ""}`);
}
if (!res.ok) {
throw new Error(`Failed to fetch ${url}: ${res.status} ${res.statusText}`);
}
return res;
}
function tarPath(filePath: string) {
if (process.platform === "win32") {
return filePath.replace(/\\/g, "/");
}
return filePath;
}
function hasSingleArchiveRoot(entries: JSZip.JSZipObject[]) {
const rootNames = new Set(
entries
.map(entry => entry.name.split("/").filter(Boolean)[0])
.filter((value): value is string => Boolean(value))
);
return rootNames.size === 1;
}
function validatedExtractPath(rootDir: string, parts: string[]) {
const entryPath = path.resolve(rootDir, ...parts);
const relative = path.relative(rootDir, entryPath);
if (relative.startsWith("..") || path.isAbsolute(relative)) {
throw new Error(`Refusing to extract unsafe ZIP entry ${parts.join("/")}`);
}
return entryPath;
}
function getHeadlessDownload(version: lib.ExternalFactorioVersion) {
if (process.platform === "linux") {
return { url: version.headlessUrl, extract: downloadAndExtractTar };
}
if (process.platform === "win32" && version.windowsHeadlessUrl) {
return { url: version.windowsHeadlessUrl, extract: downloadAndExtractZip };
}
return null;
}
/**
* Find factorio data directory of the given version
*
@@ -190,11 +245,7 @@ async function findVersion(factorioDir: string, targetVersion: lib.TargetVersion
if (targetVersion === "latest" || directVersion === targetVersion || directVersion.startsWith(targetVersion)) {
return [path.join(factorioDir, "data"), directVersion];
}
throw new Error(
`Unable to find Factorio version ${targetVersion}: ${factorioDir} is a direct (single-version) ` +
`install of ${directVersion}. Use a versioned layout, where ${factorioDir} contains a subdirectory ` +
"per version, to run other versions or download them automatically."
);
throw new Error(`Unable to find Factorio version ${targetVersion}`);
}
// Otherwise assume it is a directory of multiple versions
@@ -636,7 +687,7 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
// Due to inconsistencies in the factorio api, we must manually watch the whitelist
// https://forums.factorio.com/viewtopic.php?t=123673
_whitelistWatcher: FSWatcher | null = null;
_whitelistWatcher: fs.FSWatcher | null = null;
_whitelist = new Set<string>();
_logger: lib.Logger;
@@ -754,7 +805,7 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
;
let type = line.subarray(channelEnd + 1, channelEnd + 2).toString("utf-8");
let content;
let content: unknown;
if (type === "j") {
try {
content = JSON.parse(line.subarray(channelEnd + 2).toString("utf-8"));
@@ -890,7 +941,7 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
const filePath = this.writePath("server-whitelist.json");
try {
this._whitelistWatcher = fsWatch(filePath);
this._whitelistWatcher = fs.watch(filePath);
} catch (err: any) {
this._logger.warn(`Unable to watch whitelist, bidirectional sync will not be available:\n${err}`);
return;
@@ -910,9 +961,9 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
return;
}
let newWhitelistJson;
let newWhitelistJson: unknown;
try {
newWhitelistJson = JSON.parse(await fs.readFile(filePath, "utf8"));
newWhitelistJson = await fs.readJSON(filePath);
} catch (err: any) {
this._logger.error(`Unable to read whitelist, bidirectional sync will not be available:\n${err}`);
return;
@@ -956,18 +1007,6 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
}
}
/**
* Override the target version of Factorio to run
*
* Used to substitute a concrete version for a release channel target
* (e.g. "stable" or "experimental") before {@link checkForUpdates} and
* {@link init} run. Must be called before the server is initialized.
*/
setTargetVersion(version: lib.TargetVersion) {
this._check(["new"]);
this._targetVersion = version;
}
/**
* Initialize class instance
*
@@ -1002,29 +1041,27 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
const installedVersions = await listFactorioVersions(this._factorioDir);
if (!installedVersions.versions.has(latestVersion.version)) {
const v = latestVersion.version;
// Can't download if not linux or this is a direct install
if (installedVersions.direct) {
this._logger.info(
`A newer version of factorio is available (${v}) but cannot be downloaded automatically ` +
`because ${this._factorioDir} is a direct (single-version) Factorio install. Use a versioned ` +
`layout, where ${this._factorioDir} contains a subdirectory per version, to enable automatic ` +
"downloads."
);
return;
}
if (process.platform !== "linux") {
this._logger.info(
`A newer version of factorio is available (${v}) but must be manually downloaded ` +
"(automatic downloads are only supported on Linux)."
);
const download = getHeadlessDownload(latestVersion);
if (installedVersions.direct || !download) {
this._logger.info(`A newer version of factorio is available (${v}) but must be manually downloaded`);
return;
}
// Download the newer version
this._logger.info(`A newer version of factorio is available (${v}), starting download...`);
const dst = path.join(this._factorioDir, latestVersion.version);
await downloadAndExtractTar(latestVersion.headlessUrl, dst);
this._logger.info(`Downloaded factorio version ${v}`);
try {
await download.extract(download.url, dst);
this._logger.info(`Downloaded factorio version ${v}`);
} catch (err: unknown) {
if (installedVersions.versions.size > 0) {
this._logger.warn(
`Failed to download factorio version ${v}, continuing with installed versions: ${err}`
);
return;
}
throw err;
}
}
}
@@ -1080,7 +1117,7 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
}
} else if (this._unexpected.length === 0) {
let msg;
let msg: string;
if (code !== null) {
msg = `Factorio server unexpectedly shut down with code ${code}`;
} else {
@@ -1556,20 +1593,20 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
});
// Must be sync to allow process spawn without awaiting
// eslint-disable-next-line node/no-sync
writeFileSync(this.writePath("config.ini"), content);
fs.writeFileSync(this.writePath("config.ini"), content);
}
_writeMapSettingsSync(mapGenSettings?: object, mapSettings?: object) {
// Must be sync to allow process spawn without awaiting
if (mapGenSettings) {
// eslint-disable-next-line node/no-sync
writeFileSync(
fs.writeFileSync(
this.writePath("map-gen-settings.json"), JSON.stringify(mapGenSettings, null, "\t")
);
}
if (mapSettings) {
// eslint-disable-next-line node/no-sync
writeFileSync(
fs.writeFileSync(
this.writePath("map-settings.json"), JSON.stringify(mapSettings, null, "\t")
);
}
-2
View File
@@ -2,7 +2,6 @@ export * from "./src/api";
export * from "./src/config";
export * from "./src/data";
export * from "./src/errors";
export * from "./src/export";
export * from "./src/factorio/exchange_string";
export * from "./src/helpers";
export * from "./src/ini";
@@ -17,7 +16,6 @@ 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";
import { checkSingletonImport } from "./src/check_singleton_import";
checkSingletonImport(import.meta.url);
+26 -38
View File
@@ -1,7 +1,9 @@
/* eslint-disable no-console */
"use strict";
const fs = require("node:fs/promises");
const events = require("events");
const fs = require("fs-extra");
const JSZip = require("jszip");
const klaw = require("klaw");
const path = require("path");
const stream = require("stream");
const util = require("util");
@@ -12,36 +14,30 @@ const finished = util.promisify(stream.finished);
async function buildMod(args, info, modName) {
if (args.build) {
await fs.mkdir(args.outputDir, { recursive: true });
await fs.ensureDir(args.outputDir);
modName = args.modName ?? `${info.name}_${info.version}`;
if (args.pack) {
let zip = new JSZip();
for await (const dirent of await fs.opendir(args.sourceDir, { recursive: true })) {
if (dirent.isFile()) {
const itemPath = path.join(dirent.parentPath, dirent.name);
// On Windows the path created uses backslashes as the directory sepparator
// but the zip file needs to use forward slashes. We can't use the posix
// version of relative here as it doesn't work with Windows style paths.
let basePath = path.relative(args.sourceDir, itemPath).replace(/\\/g, "/");
// eslint-disable-next-line max-depth
if (basePath === "info.json") {
// The info.json file is overridden later.
continue;
let walker = klaw(args.sourceDir)
.on("data", item => {
if (item.stats.isFile()) {
// On Windows the path created uses backslashes as the directory sepparator
// but the zip file needs to use forward slashes. We can't use the posix
// version of relative here as it doesn't work with Windows style paths.
let basePath = path.relative(args.sourceDir, item.path).replace(/\\/g, "/");
zip.file(path.posix.join(modName, basePath), fs.createReadStream(item.path));
}
zip.file(path.posix.join(modName, basePath), (await fs.open(itemPath)).createReadStream());
}
}
})
;
await events.once(walker, "end");
for (let [fileName, pathParts] of Object.entries(info.additional_files || {})) {
let filePath = path.join(args.sourceDir, ...pathParts);
let fileStream;
try {
fileStream = (await fs.open(filePath)).createReadStream();
} catch (err) {
throw new Error(`Error reading additional file ${filePath}`, { cause: err });
if (!await fs.pathExists(filePath)) {
throw new Error(`Additional file ${filePath} does not exist`);
}
zip.file(path.posix.join(modName, fileName), fileStream);
zip.file(path.posix.join(modName, fileName), fs.createReadStream(filePath));
}
delete info.additional_files;
@@ -49,28 +45,20 @@ async function buildMod(args, info, modName) {
let modPath = path.join(args.outputDir, `${modName}.zip`);
console.log(`Writing ${modPath}`);
let writeStream = zip.generateNodeStream().pipe(
(await fs.open(modPath, "w")).createWriteStream()
);
let writeStream = zip.generateNodeStream().pipe(fs.createWriteStream(modPath));
await finished(writeStream);
} else {
let modDir = path.join(args.outputDir, modName);
console.log(`Removing potentially existing build ${modDir}`);
await fs.rm(modDir, { force: true, recursive: true, maxRetries: 10 });
if (await fs.exists(modDir)) {
console.log(`Removing existing build ${modDir}`);
await fs.remove(modDir);
}
console.log(`Building ${modDir}`);
await fs.cp(args.sourceDir, modDir, {
errorOnExists: true,
mode: fs.constants.COPYFILE_FICLONE,
recursive: true,
});
await fs.copy(args.sourceDir, modDir);
for (let [fileName, pathParts] of Object.entries(info.additional_files) || []) {
let filePath = path.join(...pathParts);
await fs.copyFile(
filePath,
path.join(modDir, fileName),
fs.constants.COPYFILE_EXCL | fs.constants.COPYFILE_FICLONE,
);
await fs.copy(filePath, path.join(modDir, fileName));
}
delete info.additional_files;
@@ -99,7 +87,7 @@ async function build(args) {
if (name === info.name) {
let modPath = path.join(args.outputDir, entry);
console.log(`Removing ${modPath}`);
await fs.rm(modPath, { force: true, recursive: true, maxRetries: 10 });
await fs.remove(modPath);
}
}
}
-2
View File
@@ -9,7 +9,6 @@ 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";
@@ -39,7 +38,6 @@ 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";
import { checkSingletonImport } from "./src/check_singleton_import";
checkSingletonImport(__filename);
+11 -8
View File
@@ -1,7 +1,7 @@
{
"name": "@clusterio/lib",
"description": "Shared library for Clusterio",
"version": "2.0.0-alpha.27",
"version": "2.0.0-alpha.23",
"repository": "https://github.com/clusterio/clusterio",
"license": "MIT",
"scripts": {
@@ -19,25 +19,28 @@
},
"dependencies": {
"@sinclair/typebox": "catalog:",
"ajv": "^8.17.1",
"chalk": "catalog:",
"fast-deep-equal": "^3.1.3",
"jszip": "catalog:",
"set-blocking": "catalog:",
"triple-beam": "^1.4.1",
"fs-extra": "catalog:",
"jszip": "catalog:",
"chalk": "catalog:",
"ws": "catalog:",
"winston": "catalog:",
"winston-transport": "catalog:",
"ws": "catalog:",
"fast-deep-equal": "^3.1.3",
"triple-beam": "^1.4.1",
"ajv": "^8.17.1",
"klaw": "^4.1.0",
"yargs": "^17.7.2"
},
"publishConfig": {
"access": "public"
},
"devDependencies": {
"@types/fs-extra": "catalog:",
"@types/node": "catalog:",
"@types/ws": "catalog:",
"@types/set-blocking": "catalog:",
"@types/triple-beam": "^1.3.2",
"@types/ws": "catalog:",
"@types/yargs": "^17.0.33",
"typescript": "catalog:"
}
+3 -3
View File
@@ -1,5 +1,5 @@
"use strict";
const fs = require("node:fs");
const fs = require("fs-extra");
const path = require("path");
const Ajv = require("ajv");
const standaloneCode = require("ajv/dist/standalone").default;
@@ -7,7 +7,7 @@ const standaloneCode = require("ajv/dist/standalone").default;
/* 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.outputFileSync(path.join(__dirname, "../dist/node/src/data/message_validate.js"), "");
const { Message } = require("../dist/node/src/data/messages_core");
@@ -23,7 +23,7 @@ const ajv = new Ajv({
const validate = ajv.compile(Message.jsonSchema);
let moduleCode = standaloneCode(ajv, validate);
function write(output) {
fs.writeFileSync(
fs.outputFileSync(
path.join(__dirname, output),
`// generated by scripts/compile_validator.js\n// @ts-nocheck\n/* eslint-disable */\n${moduleCode}`
);
+2 -2
View File
@@ -1,5 +1,5 @@
import { FileHandle, open, unlink, readFile } from "node:fs/promises";
import { unlinkSync } from "node:fs";
import { FileHandle, open, unlink, readFile } from "fs/promises";
import { unlinkSync } from "fs";
import assert from "assert";
/**
+51 -23
View File
@@ -1,11 +1,12 @@
import fs from "node:fs/promises";
import fs from "fs-extra";
import path from "path";
import events from "events";
import { Static } from "@sinclair/typebox";
import { logger } from "./logging";
import { downloadFile, safeOutputFile } from "./file_ops";
import { safeOutputFile } from "./file_ops";
import { Writable } from "stream";
import { ModPortalReleaseSchema, ModPortalDetailsSchema, ModNameVersionPair } from "./data/messages_mod";
import { ModInfo, ModPack, MajorMinorVersion, ModVersionEquality, SourceVersion } from "./data";
import { ModInfo, ModPack, FullVersion, ApiVersion, ModVersionEquality } from "./data";
export interface ModStoreEvents {
/** A stored mod was created, updated or deleted */
@@ -44,7 +45,7 @@ export default class ModStore extends events.EventEmitter<ModStoreEvents> {
super();
}
getMod(name: string, version: SourceVersion, sha1?: string) {
getMod(name: string, version: FullVersion, sha1?: string) {
const mod = this.files.get(ModInfo.filename(name, version));
if (!mod || sha1 && sha1 !== mod.sha1) {
return undefined;
@@ -86,11 +87,11 @@ export default class ModStore extends events.EventEmitter<ModStoreEvents> {
this.emit("change", modInfo);
}
async loadMod(name: string, version: SourceVersion) {
async loadMod(name: string, version: FullVersion) {
return await this.loadFile(ModInfo.filename(name, version));
}
async deleteMod(name: string, version: SourceVersion) {
async deleteMod(name: string, version: FullVersion) {
return await this.deleteFile(ModInfo.filename(name, version));
}
@@ -154,6 +155,33 @@ export default class ModStore extends events.EventEmitter<ModStoreEvents> {
return new this(modsDirectory, files);
}
/**
* Stream a url's content into a file.
*
* @param url - Web path to stream from.
* @param filePath - File path to stream to.
*/
private static async downloadFile(url: string | URL, filePath: string) {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`);
}
if (!response.body) {
throw new Error("Response body is missing.");
}
const fileStream = fs.createWriteStream(filePath);
const writer = Writable.toWeb(fileStream);
await response.body.pipeTo(writer);
} catch (error) {
logger.error("Error downloading file:", error);
throw error;
}
}
/**
* Download a matching version from a selection of available versions.
*
@@ -174,16 +202,27 @@ export default class ModStore extends events.EventEmitter<ModStoreEvents> {
}
try {
const url = new URL(selectedRelease.download_url, "https://mods.factorio.com/");
const url = new URL(`https://mods.factorio.com/${selectedRelease.download_url}`);
url.searchParams.set("username", username);
url.searchParams.set("token", token);
const filePath = path.join(this.modsDirectory, selectedRelease.file_name);
const tempFileName = `${this.modsDirectory}/${selectedRelease.file_name}.tmp`;
const finalFileName = `${this.modsDirectory}/${selectedRelease.file_name}`;
await downloadFile(url, filePath, "overwrite");
await ModStore.downloadFile(url, tempFileName);
await fs.rename(tempFileName, finalFileName);
await this.loadFile(selectedRelease.file_name);
} catch (err: any) {
logger.error(`Failed to download or load mod ${selectedRelease.file_name} (${version}): ${err.message}`);
// Attempt to clean up temp file if it exists
try {
const tempFileName = `${this.modsDirectory}/${selectedRelease.file_name}.tmp`;
await fs.unlink(tempFileName);
} catch (unlinkErr: any) {
if (unlinkErr.code !== "ENOENT") { // Ignore if file doesn't exist
logger.warn(`Failed to clean up temp file ${selectedRelease.file_name}.tmp: ${unlinkErr.message}`);
}
}
}
}
@@ -198,7 +237,7 @@ export default class ModStore extends events.EventEmitter<ModStoreEvents> {
private async downloadModsChunk(
mods: ModNameVersionPair[],
username: string, token: string,
factorioVersion: MajorMinorVersion,
factorioVersion: ApiVersion,
) {
const url = new URL("https://mods.factorio.com/api/mods");
url.searchParams.set("page_size", "max");
@@ -209,18 +248,7 @@ export default class ModStore extends events.EventEmitter<ModStoreEvents> {
});
if (response.status !== 200) {
// Surface the portal's own error (e.g. an unsupported version) rather
// than a generic status line.
let detail = `${response.status} ${response.statusText}`;
try {
const body = await response.json() as { message?: string };
if (body && typeof body.message === "string") {
detail = body.message;
}
} catch {
// Response body was not JSON; keep the status line.
}
throw new Error(`Mod portal request to ${url} failed: ${detail}`);
throw Error(`Fetch: ${url} returned ${response.status} ${response.statusText}`);
}
const modReleases = (await response.json() as ModsInfoResponse).results;
@@ -247,7 +275,7 @@ export default class ModStore extends events.EventEmitter<ModStoreEvents> {
* @param factorioVersion - Factorio version to filter for (mods are not guaranteed to work between Middle versions)
*/
async downloadMods(
mods: ModNameVersionPair[], username: string, token: string, factorioVersion: MajorMinorVersion = "1.1"
mods: ModNameVersionPair[], username: string, token: string, factorioVersion: ApiVersion = "1.1"
) {
const chunkSize = 500;
const futures: Promise<void>[] = [];
+51 -353
View File
@@ -1,6 +1,6 @@
// Configuration classes
import { Type, Static } from "@sinclair/typebox";
import * as fs from "node:fs/promises";
import * as fs from "fs-extra";
import events from "events";
import isDeepStrictEqual from "../is_deep_strict_equal";
@@ -8,8 +8,6 @@ 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";
const ConfigLocation = StringEnum(["controller", "host", "control"]);
export type ConfigLocation = Static<typeof ConfigLocation>;
@@ -22,7 +20,7 @@ export type ConfigLocation = Static<typeof ConfigLocation>;
*
* @extends Error
*/
export class InvalidAccess extends RequestError { };
export class InvalidAccess extends Error { };
/**
* Invalid Value exception
@@ -32,7 +30,7 @@ export class InvalidAccess extends RequestError { };
*
* @extends Error
*/
export class InvalidValue extends RequestError { };
export class InvalidValue extends Error { };
/**
* Invalid Field exception
@@ -42,7 +40,7 @@ export class InvalidValue extends RequestError { };
*
* @extends Error
*/
export class InvalidField extends RequestError { };
export class InvalidField extends Error { };
const OldConfigGroupSchema = Type.Object({
@@ -65,24 +63,14 @@ export const ConfigSchema = Type.Record(Type.String(), Type.Union([
]));
export type ConfigSchema = Static<typeof ConfigSchema>;
type FieldType = "boolean" | "string" | "number" | "object";
type FieldValue = null | boolean | string | number | object;
type FieldType<T extends FieldValue> =
T extends boolean ? "boolean" :
T extends string ? "string" :
T extends number ? "number" :
T extends object ? "object" :
never;
export interface FieldDefinition<
Type extends FieldValue = any,
Fields extends Record<keyof Fields, FieldValue> = any,
> {
export interface FieldDefinition {
/**
* string declaring the type of the config value, supports boolean,
* string, number and object.
*/
type: FieldType<Type>;
type: FieldType;
/**
* Text used to identify the config field in user interfaces.
*/
@@ -91,16 +79,6 @@ export interface FieldDefinition<
* Text used to describe the config field in user interfaces.
*/
description?: string;
/**
* Validation function ran to confirm the value is consistent with other config values.
* Should throw an error if the value in invalid / inconsistent
*/
validator?: (value: Type, config: Config<Fields>) => void;
/**
* Names the config fields that the validator depends on.
* This will ensure it is checked when these fields are changed.
*/
dependsOn?: string[];
/**
* Value to use for the autocomplete attribute in the Web UI's input element.
* Useful for making browsers behave properly with credential inputs.
@@ -115,7 +93,7 @@ export interface FieldDefinition<
/**
* True if this field can be set to null. Defaults to false.
*/
optional?: Type extends null ? true : false;
optional?: boolean;
/**
* Value this config field should take in a newly initialized config.
* This can also be an function returning the value to use.
@@ -159,10 +137,8 @@ export interface FieldDefinition<
hidden?: boolean;
}
export type ConfigDefs<
Fields extends Record<keyof Fields, FieldValue>
> = {
[Prop in keyof Fields]: FieldDefinition<Fields[Prop], Fields>;
export type ConfigDefs<Fields> = {
[Prop in keyof Fields]: FieldDefinition;
}
function defaultValue(def: FieldDefinition) {
@@ -201,17 +177,14 @@ export enum ConfigAccess {
* @extends events.EventEmitter
*/
export class Config<
Fields extends Record<keyof Fields, FieldValue>
Fields extends { [Field in keyof Fields]: FieldValue },
> extends events.EventEmitter<ConfigEvents> {
/**
* Mapping of config name to field meta data
* Note that mutating this object will lead to unexpected behaviour.
*/
declare static fieldDefinitions: ConfigDefs<any>;
declare ["constructor"]: typeof Config<Fields>;
/** A set of common validators and validator factories */
static validators = validators;
declare ["constructor"]: typeof Config;
/**
* Handle migration between clusterio versions
@@ -231,8 +204,6 @@ export class Config<
private fields: Fields;
private _unknownFields: Record<string, FieldValue> = {};
/** Set to true during validators to prevent edits */
private _readonly = false; // TODO replace with "using config.asReadonly()"
/** Set to true when a field in the config is changed. */
private dirty = false;
/** Set to true when a 'restart required' field in the config is changed. */
@@ -251,7 +222,7 @@ export class Config<
constructor(
public location: ConfigLocation,
fields?: Static<typeof Config.jsonSchema>,
private _filepath?: string,
public filepath?: string,
) {
if (typeof location !== "string") {
throw new Error("location must be a string");
@@ -273,119 +244,6 @@ export class Config<
}
}
get filepath() {
return this._filepath;
}
set filepath(filepath: string | undefined) {
this.dirty = true;
this._filepath = filepath;
}
/** A staging version of the config with changes tracked, invalided by revertStaging */
private _staging?: { config: Config<Fields>, changes: Set<string>};
/** Get the latest version of staging */
// TODO replace with "using config.startStating()"
get staging(): Config<Fields> {
if (this._staging) { return this._staging.config; }
const config = new this.constructor(this.location, this.fields);
const changes = new Set<string>();
config.on("fieldChanged", name => changes.add(name));
this._staging = { config, changes };
return config;
}
private static _fieldDependents: Map<string, {
name: string,
def: FieldDefinition & { validator: NonNullable<FieldDefinition["validator"]> }
}[]>;
/** Map of all fields whose validators depend on the given field */
static get fieldDependents(): typeof this._fieldDependents {
if (this._fieldDependents) {
return this._fieldDependents;
}
const dependents = new Map<string, { name: string, def: FieldDefinition }[]>();
for (const [name, def] of Object.entries(this.fieldDefinitions)) {
if (!def.validator || !def.dependsOn) { continue; }
for (const otherName of def.dependsOn) {
const fieldDependents = dependents.get(otherName);
if (fieldDependents) {
fieldDependents.push({name, def});
} else {
dependents.set(otherName, [{name, def}]);
}
}
}
// The type is correct because of "!def.validator", not clear why this doesn't infer
this._fieldDependents = dependents as any;
return this._fieldDependents;
}
/**
* Coerce and validate a value according to a field definition.
*
* @param name - Name of the field being validated.
* @param def - Definition describing the field type and constraints.
* @param value - Value to validate and coerce.
* @throws {InvalidValue} if the value cannot be coerced to the correct type
* @returns The validated and coerced value.
* @private
*/
static _coerceValue<T extends FieldDefinition>(name: string, def: T, value: unknown) {
// Empty strings are treated as null
if (value === "") {
value = null;
}
// If def is an enum then it must be a value within it
if (def.enum && !def.enum.includes(value)) {
throw new InvalidValue(`Expected one of [${def.enum.join(", ")}], not ${value}`);
}
// If it is a string, then attempt to coerce it
if (typeof value === "string") {
if (def.type === "boolean") {
if (value === "true") {
value = true;
} else if (value === "false") {
value = false;
}
} else if (def.type === "number") {
const numberRegExp = /^[+-]?(Infinity|\d+\.(\d+)?([eE][+-]?\d+)?|\.?\d+([eE][+-]?\d+)?)$/;
if (numberRegExp.test(value.trim())) {
value = Number.parseFloat(value);
}
} else if (def.type === "object") {
try {
value = JSON.parse(value);
} catch (err: any) {
throw new InvalidValue(`Error parsing value for ${name}: ${err.message}`);
}
}
}
// Check if it is allowed to be null
if (value === null) {
if (!def.optional) {
throw new InvalidValue(`Field ${name} cannot be null`);
}
// Check if is the correct type
} else if (basicType(value) !== def.type) {
throw new InvalidValue(`Expected type of ${name} to be ${def.type}, not ${basicType(value)}`);
}
return value as FieldType<T["type"]>;
}
/**
* Check access for a field
*
@@ -430,14 +288,8 @@ export class Config<
* @param notify - False to skip setting dirty flags and emitting events
*/
_set(name: keyof Fields & string, value: FieldValue, notify: boolean = true) {
if (this._readonly) {
throw new Error("config is readonly and cannot have values changed");
}
const prev = this.fields[name];
this.fields[name] = value as any;
this.revertStaging();
if (notify && !isDeepStrictEqual(value, prev)) {
const def = this.constructor.fieldDefinitions[name];
if (def.restartRequired) {
@@ -456,7 +308,7 @@ export class Config<
* @returns Instance of this config
*/
static async fromFile(location: ConfigLocation, filepath: string) {
const fields = JSON.parse(await fs.readFile(filepath, { encoding: "utf8" }));
const fields = await fs.readJson(filepath, { encoding: "utf8" });
return this.fromJSON(fields, location, filepath);
}
@@ -656,52 +508,57 @@ export class Config<
* @throws {InvalidValue} if value is not allowed for the field.
*/
set<Field extends keyof Fields & string>(name: Field, newValue: Fields[Field], remote = this.location) {
const def = this.constructor.fieldDefinitions[name];
let value: FieldValue = newValue;
let def = this.constructor.fieldDefinitions[name];
if (!def) {
throw new InvalidField(`No field named '${name}'`);
}
this._checkAccess(name, def, remote, ConfigAccess.write, true);
if (remote !== this.location) {
this._checkAccess(name, def, this.location, ConfigAccess.write, true);
}
const value = Config._coerceValue(name, def, newValue);
const _readonly = this._readonly;
this.staging._readonly = true;
this._readonly = true;
try {
if (def.validator) {
try {
def.validator(value, this);
} catch (err: any) {
throw new InvalidValue(`Failed validation of ${name}: ${err.message}`);
}
}
} finally {
this._readonly = _readonly;
this.staging._readonly = _readonly;
// Empty strings are treates as null
if (value === "") {
value = null;
}
const dependents = this.constructor.fieldDependents.get(name);
if (dependents) {
// This mimics stage + commit but optimised for a single value
this.staging._set(name, value);
this.staging._readonly = true;
try {
for (const dependent of dependents) {
try {
dependent.def.validator(this.get(dependent.name as any, remote), this.staging);
} catch (err: any) {
throw new InvalidValue(`Failed validation of dependent ${dependent.name}: ${err.message}`);
}
if (def.enum && !def.enum.includes(value)) {
throw new InvalidValue(`Expected one of [${def.enum.join(", ")}], not ${value}`);
}
if (typeof value === "string") {
if (def.type === "boolean") {
if (value === "true") {
value = true;
} else if (value === "false") {
value = false;
}
} else if (def.type === "number") {
let numberRegExp = /^[+-]?(Infinity|\d+\.(\d+)?([eE][+-]?\d+)?|\.?\d+([eE][+-]?\d+)?)$/;
if (numberRegExp.test(value.trim())) {
value = Number.parseFloat(value);
}
} else if (def.type === "object") {
try {
value = JSON.parse(value);
} catch (err: any) {
throw new InvalidValue(`Error parsing value for ${name}: ${err.message}`);
}
} finally {
this.revertStaging();
}
}
if (value === null) {
if (!def.optional) {
throw new InvalidValue(`Field ${name} cannot be null`);
}
} else if (basicType(value) !== def.type) {
throw new InvalidValue(`Expected type of ${name} to be ${def.type}, not ${basicType(value)}`);
}
this._set(name, value);
}
@@ -728,7 +585,6 @@ export class Config<
if (!def) {
throw new InvalidField(`No field named '${name}'`);
}
this._checkAccess(name, def, remote, ConfigAccess.write, true);
if (remote !== this.location) {
this._checkAccess(name, def, this.location, ConfigAccess.write, true);
@@ -747,164 +603,6 @@ export class Config<
delete updated[prop];
}
const _readonly = this._readonly;
this.staging._readonly = true;
this._readonly = true;
try {
if (def.validator) {
try {
def.validator(updated, this);
} catch (err: any) {
throw new InvalidValue(`Failed validation of ${name}: ${err.message}`);
}
}
} finally {
this._readonly = _readonly;
this.staging._readonly = _readonly;
}
const dependents = this.constructor.fieldDependents.get(name);
if (dependents) {
// This mimics stage + commit but optimised for a single value
this.staging._set(name, updated);
this.staging._readonly = true;
try {
for (const dependent of dependents) {
try {
dependent.def.validator(this.get(dependent.name as any, remote), this.staging);
} catch (err: any) {
throw new InvalidValue(`Failed validation of dependent ${dependent.name}: ${err.message}`);
}
}
} finally {
this.revertStaging();
}
}
this._set(name, updated);
}
/**
* Similar to set but only validates the field type and not other constraints.
* The value is stored in staging and must be committed for the change to take effect.
*
* @param name - Name of field to set.
* @param newValue - Value to set for field.
* @param remote - Location this field is being set from.
* @throws {InvalidField} if field is not defined.
* @throws {InvalidValue} if value is not allowed for the field.
*/
stage<Field extends keyof Fields & string>(name: Field, newValue: Fields[Field], remote = this.location) {
if (this._readonly) {
throw new Error("config is readonly and cannot have values changed");
}
const def = this.constructor.fieldDefinitions[name];
if (!def) {
throw new InvalidField(`No field named '${name}'`);
}
this._checkAccess(name, def, remote, ConfigAccess.write, true);
if (remote !== this.location) {
this._checkAccess(name, def, this.location, ConfigAccess.write, true);
}
const value = Config._coerceValue(name, def, newValue);
this.staging._set(name, value);
}
/**
* Similar to setProp but only validates the field type and not other constraints.
* The value is stored in staging and must be committed for the change to take effect.
*
* @param name - Name of field to set property on.
* @param prop - Name of property to set on field.
* @param value - the value to set the property to.
* @param remote - Location this property is being set from
* @throws {InvalidField} if field is not defined.
* @throws {InvalidValue} if field is not an object.
*/
stageProp<Field extends keyof Fields & string>(
name: Field,
prop: string,
value?: unknown,
remote = this.location,
) {
if (this._readonly) {
throw new Error("config is readonly and cannot have values changed");
}
const def = this.constructor.fieldDefinitions[name];
if (!def) {
throw new InvalidField(`No field named '${name}'`);
}
this._checkAccess(name, def, remote, ConfigAccess.write, true);
if (remote !== this.location) {
this._checkAccess(name, def, this.location, ConfigAccess.write, true);
}
if (def.type !== "object") {
throw new InvalidValue(`Cannot set property on non-object field '${name}'`);
}
const prev = this.staging.get(name) as Record<string, unknown>;
const updated = {...prev || {}};
if (value !== undefined) {
updated[prop] = value;
} else {
delete updated[prop];
}
this.staging._set(name, updated);
}
/**
* Commit all changes in stating after they have been validated.
*
* @throws {InvalidValue} if a value is not allowed for its field.
*/
commitStaging() {
if (this._readonly) {
throw new Error("config is readonly and cannot have values changed");
}
const staging = this._staging;
if (!staging) {
return; // Nothing to commit
}
// Validate all fields using the current state of staging
const config = staging.config;
const _readonly = this._readonly;
config._readonly = true;
try {
for (const [fieldName, fieldDef] of Object.entries(this.constructor.fieldDefinitions)) {
if (fieldDef.validator) {
try {
fieldDef.validator(config.fields[fieldName as keyof Fields], config);
} catch (err: any) {
throw new InvalidValue(`Failed validation of ${fieldName}: ${err.message}`);
}
}
}
} finally {
config._readonly = _readonly;
}
// Validation success, set all values which have changed
for (const fieldName of staging.changes) {
const field = fieldName as keyof Fields & string;
this._set(field, config.fields[field]);
}
}
/** Revert all changes in staging */
revertStaging() {
if (this._readonly) {
throw new Error("config is readonly and cannot have values changed");
}
this._staging = undefined;
}
}
+26 -125
View File
@@ -1,9 +1,7 @@
// Core definitions for the configuration system
import * as classes from "./classes";
import * as validators from "./validators";
import type { PluginNodeEnvInfo, PluginWebpackEnvInfo } from "../plugin";
import { Static } from "@sinclair/typebox";
import { isTargetVersion } from "../data/version";
type configFromJSON<T> = (...args: Parameters<typeof classes.Config.fromJSON>) => T;
type configFromFile<T> = (...args: Parameters<typeof classes.Config.fromFile>) => Promise<T>;
@@ -18,10 +16,9 @@ export interface ControllerConfigFields {
"controller.bind_address": string | null;
"controller.trusted_proxies": string | null;
"controller.public_url": string | null;
"controller.grafana_url": string | null;
"controller.tls_certificate": string | null;
"controller.tls_private_key": string | null;
"controller.auth_secret": string | null;
"controller.auth_secret": string;
"controller.heartbeat_interval": number;
"controller.session_timeout": number;
"controller.metrics_timeout": number;
@@ -76,21 +73,18 @@ export class ControllerConfig extends classes.Config<ControllerConfigFields> {
restartRequired: true,
type: "string",
initialValue: "mods",
validator: validators.filePath,
},
"controller.database_directory": {
title: "Database directory",
description: "Directory where item and configuration data is stored.",
type: "string",
initialValue: "database",
validator: validators.filePath,
},
"controller.autosave_interval": {
title: "Autosave Interval",
description: "Interval in seconds to autosave data in memory to disk.",
type: "number",
initialValue: 60,
validator: validators.greaterThanZero,
},
"controller.http_port": {
title: "HTTP Port",
@@ -99,10 +93,6 @@ export class ControllerConfig extends classes.Config<ControllerConfigFields> {
type: "number",
optional: true,
initialValue: 8080,
validator: validators.optional(validators.all(
validators.integer,
validators.greaterThanZero
)),
},
"controller.https_port": {
title: "HTTPS Port",
@@ -110,10 +100,6 @@ export class ControllerConfig extends classes.Config<ControllerConfigFields> {
restartRequired: true,
type: "number",
optional: true,
validator: validators.optional(validators.all(
validators.integer,
validators.greaterThanZero
)),
},
"controller.bind_address": {
title: "Bind Address",
@@ -121,7 +107,6 @@ export class ControllerConfig extends classes.Config<ControllerConfigFields> {
restartRequired: true,
type: "string",
optional: true,
// TODO extendedValidation, valid addresses including IPV4, IPV6, and localhost
},
"controller.trusted_proxies": {
title: "Trusted Proxies",
@@ -129,20 +114,12 @@ export class ControllerConfig extends classes.Config<ControllerConfigFields> {
"Comma separated list of IP addresses and/or CIDR blocks to trust the X-Forwarded-For header on",
type: "string",
optional: true,
// TODO extendedValidation, see parseTrustedProxies
},
"controller.public_url": {
title: "Public URL",
description: "Public facing URL the controller is hosted on, including the protocol.",
type: "string",
optional: true,
// TODO extendedValidation, valid url including the protocol
},
"controller.grafana_url": {
title: "Grafana URL",
description: "URL of a Grafana dashboard to link from the overview page, including the protocol.",
type: "string",
optional: true,
},
"controller.tls_certificate": {
title: "TLS Certificate",
@@ -150,7 +127,6 @@ export class ControllerConfig extends classes.Config<ControllerConfigFields> {
restartRequired: true,
type: "string",
optional: true,
validator: validators.optional(validators.filePath),
},
"controller.tls_private_key": {
title: "TLS Private Key",
@@ -158,7 +134,6 @@ export class ControllerConfig extends classes.Config<ControllerConfigFields> {
restartRequired: true,
type: "string",
optional: true,
validator: validators.optional(validators.filePath),
},
"controller.auth_secret": {
access: ["controller"],
@@ -176,35 +151,30 @@ export class ControllerConfig extends classes.Config<ControllerConfigFields> {
description: "Interval heartbeats are sent out on WebSocket connections.",
type: "number",
initialValue: 15,
validator: validators.greaterThanZero,
},
"controller.session_timeout": {
title: "Session Timeout",
description: "Time in seconds before giving up resuming a dropped WebSocket session.",
type: "number",
initialValue: 60,
validator: validators.greaterThanZero,
},
"controller.metrics_timeout": {
title: "Metrics Timeout",
description: "Timeout in seconds for metrics gathering from hosts.",
type: "number",
initialValue: 8,
validator: validators.greaterThanZero,
},
"controller.system_metrics_interval": {
title: "System Metrics Interval",
description: "Interval in seconds to collect and update system metrics for the Web UI",
type: "number",
initialValue: 10,
validator: validators.greaterThanEqualZero,
},
"controller.proxy_stream_timeout": {
title: "Proxy Stream Timeout",
description: "Timeout in seconds for proxy streams to start flowing.",
type: "number",
initialValue: 15,
validator: validators.greaterThanZero,
},
"controller.factorio_username": {
title: "Factorio Username",
@@ -249,14 +219,12 @@ export class ControllerConfig extends classes.Config<ControllerConfigFields> {
description: "Duration in minutes to cache mod portal API responses.",
type: "number",
initialValue: 30,
validator: validators.greaterThanEqualZero, // Zero is allowed here to disable cache
},
"controller.mod_portal_page_size": {
title: "Mod Portal Page Size",
description: "Maximum number of results per page when querying the Factorio mod portal API.",
type: "number",
initialValue: 1000,
validator: validators.greaterThanZero,
},
"controller.allow_remote_updates": {
description: "When true, allows a remote event to trigger a clusterio update via npm",
@@ -331,7 +299,6 @@ export class HostConfig extends classes.Config<HostConfigFields> {
description: "Path to directory to look for factorio installs",
type: "string",
initialValue: "factorio",
validator: validators.filePath,
},
"host.mods_directory": {
title: "Mods Directory",
@@ -339,21 +306,18 @@ export class HostConfig extends classes.Config<HostConfigFields> {
restartRequired: true,
type: "string",
initialValue: "mods",
validator: validators.filePath,
},
"host.instances_directory": {
description: "Path to directory to store instances in.",
restartRequired: true,
type: "string",
initialValue: "instances",
validator: validators.filePath,
},
"host.controller_url": {
description: "URL to connect to the controller at",
restartRequired: true,
type: "string",
initialValue: "http://localhost:8080/",
// TODO extendedValidation, valid url including the protocol
},
"host.controller_token": {
access: ["host"],
@@ -361,7 +325,6 @@ export class HostConfig extends classes.Config<HostConfigFields> {
restartRequired: true,
type: "string",
initialValue: "enter token here",
// TODO extendedValidation, valid JWT with matching host id
},
"host.public_address": {
description: "Public facing address players should connect to in order to join instances on this host",
@@ -375,7 +338,6 @@ export class HostConfig extends classes.Config<HostConfigFields> {
"ranges separated with a dash.",
type: "string",
initialValue: "34100-34199",
// TODO extendedValidation, port range
},
"host.factorio_username": {
title: "Factorio Username",
@@ -401,7 +363,6 @@ export class HostConfig extends classes.Config<HostConfigFields> {
description: "Maximum delay to wait before attempting to reconnect WebSocket",
type: "number",
initialValue: 60,
validator: validators.greaterThanZero,
},
"host.allow_remote_updates": {
description: "When true, allows a remote event to trigger a clusterio update via npm",
@@ -495,7 +456,6 @@ export class InstanceConfig extends classes.Config<InstanceConfigFields> {
type: "number",
optional: true,
hidden: true,
// Internal value, extendedValidation not used
},
"instance.auto_start": {
description: "Automatically start this instance when the host hosting it is started up",
@@ -508,18 +468,11 @@ export class InstanceConfig extends classes.Config<InstanceConfigFields> {
initialValue: false,
},
"factorio.version": {
description:
"Version of the game to run. Use 'latest' for the latest installed version, a release " +
"channel such as 'stable' or 'experimental', or a specific X.Y or X.Y.Z version.",
description: "Version of the game to run, use latest to run the latest installed version",
restartRequired: true,
type: "string",
initialValue: "latest",
inputComponent: "target_version",
validator: function(value) {
if (!isTargetVersion(value)) {
throw new Error("Value must be 'latest', a release channel, or match X.Y or X.Y.Z");
}
},
},
"factorio.executable_path": {
description:
@@ -528,7 +481,6 @@ export class InstanceConfig extends classes.Config<InstanceConfigFields> {
restartRequired: true,
type: "string",
optional: true,
validator: validators.optional(validators.filePath),
},
"factorio.shutdown_timeout": {
description:
@@ -536,27 +488,24 @@ export class InstanceConfig extends classes.Config<InstanceConfigFields> {
"the process. Set to 0 to disable.",
type: "number",
initialValue: 300,
validator: validators.greaterThanEqualZero,
optional: true,
},
"factorio.game_port": {
description: "UDP port to run game on, uses a port in host.factorio_port_range if null",
restartRequired: true,
type: "number",
optional: true,
validator: validators.optional(validators.greaterThanEqualZero),
},
"factorio.host_assigned_game_port": {
access: ["host"],
type: "number",
optional: true,
// Internal value, extendedValidation not used
},
"factorio.rcon_port": {
description: "TCP port to run RCON on, uses a random port if null",
restartRequired: true,
type: "number",
optional: true,
validator: validators.optional(validators.greaterThanEqualZero),
},
"factorio.rcon_password": {
credential: ["host", "controller"],
@@ -571,14 +520,6 @@ export class InstanceConfig extends classes.Config<InstanceConfigFields> {
"autosave pool with this many slots. Requires autosaves to be enabled to work. Set to 0 to disable.",
type: "number",
initialValue: 5,
dependsOn: ["factorio.settings", "factorio.settings.autosave_slots"],
validator: function(value, config) {
const factorioSettings = config.get("factorio.settings");
const autosaveSlots = factorioSettings.autosave_slots;
if (typeof autosaveSlots === "number" && value < autosaveSlots) {
throw new Error("Value cannot be less than the number of autosave slots");
}
},
},
"factorio.mod_pack_id": {
title: "Mod Pack",
@@ -627,7 +568,6 @@ export class InstanceConfig extends classes.Config<InstanceConfigFields> {
],
type: "object",
initialValue: {}, // See create instance handler in controller.
// TODO extendedValidation, we could check keys and types here if we wanted
},
"factorio.verbose_logging": {
description: "Enable verbose logging on the Factorio server",
@@ -670,7 +610,6 @@ export class InstanceConfig extends classes.Config<InstanceConfigFields> {
restartRequired: true,
type: "number",
initialValue: 5,
validator: validators.greaterThanZero,
},
};
}
@@ -705,13 +644,12 @@ export class ControlConfig extends classes.Config<ControlConfigFields> {
description: "Maximum delay to wait before attempting to reconnect WebSocket",
type: "number",
initialValue: 60,
validator: validators.greaterThanEqualZero,
},
};
}
function validateFieldNames(
function validateFields(
pluginName: string,
fields: Record<string, classes.FieldDefinition>,
) {
@@ -725,73 +663,36 @@ function validateFieldNames(
}
/**
* Add the config fields defined by the by a specific plugin infos
*/
export function addPluginFieldDefinitions(
pluginInfo: PluginNodeEnvInfo | PluginWebpackEnvInfo,
kind: "controllerConfigFields" | "hostConfigFields" | "instanceConfigFields",
Config: typeof classes.Config<any>
) {
// Define plugin.load_plugin
const fieldDef: classes.FieldDefinition = {
title: "Load Plugin",
type: "boolean",
initialValue: true,
restartRequired: true,
};
// The validator is dynamic based on the feature requirements of the plugin
if (kind === "instanceConfigFields" && pluginInfo.features) {
fieldDef.dependsOn = [];
const savePatching = pluginInfo.features.includes("SavePatching");
if (savePatching) {
fieldDef.dependsOn.push("factorio.enable_save_patching");
}
const scriptCommands = pluginInfo.features.includes("ScriptCommands");
if (scriptCommands) {
fieldDef.dependsOn.push("factorio.enable_script_commands");
}
if (scriptCommands || savePatching) {
fieldDef.validator = (enabled, config) => {
if (!enabled) {
return;
}
if (savePatching && !config.get("factorio.enable_save_patching")) {
throw new Error(`Plugin ${pluginInfo.name} requires save patching`);
}
if (scriptCommands && !config.get("factorio.enable_script_commands")) {
throw new Error(`Plugin ${pluginInfo.name} requires script commands`);
}
};
}
}
Config.fieldDefinitions[`${pluginInfo.name}.load_plugin`] = fieldDef;
// Merge in other fields from the plugin
const fields = pluginInfo[kind];
if (fields) {
validateFieldNames(pluginInfo.name, fields);
Object.assign(Config.fieldDefinitions, fields);
}
}
/**
* Add all config fields defined by the provided plugin infos
* Add config fields defined by the provided plugin infos
*
* @param {Array<Object>} pluginInfos - Array of plugin info objects.
*/
export function addPluginConfigFields(pluginInfos: PluginNodeEnvInfo[] | PluginWebpackEnvInfo[]) {
function pluginConfig(
pluginInfo: PluginNodeEnvInfo | PluginWebpackEnvInfo,
kind: "controllerConfigFields" | "hostConfigFields" | "instanceConfigFields",
Config: typeof ControllerConfig | typeof HostConfig | typeof InstanceConfig,
) {
(Config.fieldDefinitions as any)[`${pluginInfo.name}.load_plugin`] = {
title: "Load Plugin",
restartRequired: true,
type: "boolean",
initialValue: true,
};
const fields = pluginInfo[kind];
if (fields) {
validateFields(pluginInfo.name, fields);
Object.assign(Config.fieldDefinitions, fields);
}
}
for (let pluginInfo of pluginInfos) {
addPluginFieldDefinitions(pluginInfo, "controllerConfigFields", ControllerConfig);
pluginConfig(pluginInfo, "controllerConfigFields", ControllerConfig);
if (pluginInfo.hostEntrypoint || pluginInfo.instanceEntrypoint) {
addPluginFieldDefinitions(pluginInfo, "hostConfigFields", HostConfig);
pluginConfig(pluginInfo, "hostConfigFields", HostConfig);
}
if (pluginInfo.instanceEntrypoint) {
addPluginFieldDefinitions(pluginInfo, "instanceConfigFields", InstanceConfig);
pluginConfig(pluginInfo, "instanceConfigFields", InstanceConfig);
}
}
}
-66
View File
@@ -1,66 +0,0 @@
import path from "path";
/** Combine multiple validators into a single one. */
export function all<V, C> (
...validators: ((value: V, config: C) => void)[]
) {
return function (value: V, config: C) {
for (const validator of validators) {
validator(value, config);
}
};
}
/** Skip validation of optional fields when null */
export function optional<V, C> (
validator: (value: V, config: C) => void
) {
return function (value: V | null, config: C) {
if (value === null) { return; }
validator(value, config);
};
}
/** Value must be greater than the minimum value */
export function greaterThan(min: number) {
return function (value: number) {
if (value <= min) {
throw new Error(`Value must be greater than ${min}`);
}
};
}
/** Value must be greater than 0 */
export const greaterThanZero = greaterThan(0);
/** Value must be greater than or equal to the minimum value */
export function greaterThanEqual(min: number) {
return function (value: number) {
if (value < min) {
throw new Error(`Value must be greater than ${min}`);
}
};
}
/** Value must be greater than or equal to 0 */
export const greaterThanEqualZero = greaterThanEqual(0);
/** Value must be an integer value */
export function integer(value: number) {
if (!Number.isInteger(value)) {
throw new Error("Value must be an integer");
}
}
/**
* Value must be a valid file path, it does not need to exist
* On posix this will only error for non string values, which isn't very useful
* However, this could be expanded in the future to check system OS
*/
export function filePath(value: string) {
try {
path.resolve(value);
} catch (err: any) {
throw new Error(`Value must be a valid path: ${err.message}`);
}
}
+22 -46
View File
@@ -1,4 +1,4 @@
import fs from "node:fs/promises";
import fs from "fs-extra";
import JSZip from "jszip";
import { Type, Static } from "@sinclair/typebox";
@@ -8,13 +8,14 @@ import { findRoot } from "../zip_ops";
import { ModRecord } from "./ModPack";
import {
MajorMinorVersion, MajorMinorVersionSchema, normaliseMajorMinorVersion, integerMajorMinorVersion,
SourceVersion, SourceVersionSchema, isSourceVersion, normaliseSourceVersion, integerSourceVersion,
ApiVersion,
ApiVersionSchema,
FullVersion, FullVersionSchema, integerFullVersion,
integerPartialVersion,
ModVersionEquality,
} from "./version";
type ModDependencyType = "incompatible" | "optional" | "hidden" | "unordered" | "required" | "recommended";
type ModDependencyType = "incompatible" | "optional" | "hidden" | "unordered" | "required";
export type ModDependencyUnsatisfiedReason = "incompatible" | "missing_dependency" | "wrong_version";
const UnsatisfiedSeverity: Record<ModDependencyUnsatisfiedReason, number> = {
@@ -39,8 +40,6 @@ export class ModDependency {
return "hidden";
case "~":
return "unordered";
case "+":
return "recommended";
case "":
return "required";
default:
@@ -88,10 +87,6 @@ export class ModDependency {
return this.type === "incompatible";
}
get recommended() {
return this.type === "recommended";
}
get required() {
return this.type === "unordered" || this.type === "required";
}
@@ -134,23 +129,16 @@ export default class ModInfo {
name = "";
/**
* Version of the mod, exactly as it appears in info.json and the mod's file
* name. This is the raw, un-normalised version the game reads leniently, so
* it is only safe to use for building file names and identifiers; use
* {@link integerVersion} to compare it.
* Version of the mod.
* Sourced from info.json.
*/
version = "0.0.0" as SourceVersion;
version = "0.0.0" as FullVersion;
/**
* Integer representation of the version, normalised the lenient way the
* game reads it so it can be compared and sorted.
* Integer representation of the version
*/
get integerVersion() {
return integerSourceVersion(this.version);
}
get normalisedVersion() {
return normaliseSourceVersion(this.version);
return integerFullVersion(this.version);
}
/**
@@ -184,17 +172,17 @@ export default class ModInfo {
description = "";
/**
* Major version of Factorio this mod supports, reduced to major.minor.
* Sourced from info.json and normalised when read in fromJSON.
* Major version of Factorio this mod supports.
* Sourced from info.json.
*/
factorioVersion = "0.12" as MajorMinorVersion;
factorioVersion = "0.12" as "0.12" | ApiVersion;
/**
* Integer representation of the factorioVersion
* @type {number}
*/
get integerFactorioVersion() {
return integerMajorMinorVersion(this.factorioVersion);
return integerPartialVersion(this.factorioVersion);
}
/**
@@ -233,7 +221,7 @@ export default class ModInfo {
* @param version - Mod's version
* @returns string containing {name}_{version}.zip
*/
static filename(name: string, version: SourceVersion) {
static filename(name: string, version: FullVersion) {
return `${name}_${version}.zip`;
}
@@ -260,17 +248,16 @@ export default class ModInfo {
*/
isDeleted = false;
// Content of info.json found in mod files. The version fields use the same
// lenient format the game accepts; they are normalised when read in fromJSON.
// Content of info.json found in mod files
static infoJsonSchema = Type.Object({
"name": Type.String(),
"version": SourceVersionSchema,
"version": FullVersionSchema,
"title": Type.String(),
"author": Type.String(),
"contact": Type.Optional(Type.String()),
"homepage": Type.Optional(Type.String()),
"description": Type.Optional(Type.String()),
"factorio_version": Type.Optional(SourceVersionSchema),
"factorio_version": Type.Optional(ApiVersionSchema),
"dependencies": Type.Optional(Type.Array(Type.String())),
});
@@ -292,24 +279,13 @@ export default class ModInfo {
// info.json fields
if (json.name) { modInfo.name = json.name; }
if (json.version) {
if (!isSourceVersion(json.version)) {
throw new Error(`Invalid mod version "${json.version}"`);
}
modInfo.version = json.version;
}
if (json.version) { modInfo.version = json.version; }
if (json.title) { modInfo.title = json.title; }
if (json.author) { modInfo.author = json.author; }
if (json.contact) { modInfo.contact = json.contact; }
if (json.homepage) { modInfo.homepage = json.homepage; }
if (json.description) { modInfo.description = json.description; }
if (json.factorio_version) {
const factorioVersion = normaliseSourceVersion(json.factorio_version);
if (factorioVersion === undefined) {
throw new Error(`Invalid factorio_version "${json.factorio_version}"`);
}
modInfo.factorioVersion = normaliseMajorMinorVersion(factorioVersion);
}
if (json.factorio_version) { modInfo.factorioVersion = json.factorio_version; }
// Parse the dependencies
try {
@@ -338,7 +314,7 @@ export default class ModInfo {
if (this.contact) { json.contact = this.contact; }
if (this.homepage) { json.homepage = this.homepage; }
if (this.description) { json.description = this.description; }
if (this.factorioVersion !== "0.12") { json.factorio_version = this.factorioVersion as SourceVersion; }
if (this.factorioVersion !== "0.12") { json.factorio_version = this.factorioVersion; }
if (this.dependencies.length !== 1 || this.dependencies[0].name !== "base") {
json.dependencies = this.dependencySpecifications;
}
+14 -33
View File
@@ -5,14 +5,13 @@ import * as libSchema from "../schema";
import * as libLuaTools from "../lua_tools";
import ExportManifest from "./ExportManifest";
import type { ExportSettings } from "../export";
import type { Logger } from "../logging";
import ModInfo, { ModDependencyUnsatisfiedReason } from "./ModInfo";
import {
PartialVersion, PartialVersionSchema, integerPartialVersion,
SourceVersion, SourceVersionSchema, normaliseFullVersion,
FullVersion, FullVersionSchema, normaliseFullVersion,
} from "./version";
@@ -46,8 +45,8 @@ export interface ModRecord {
name: string,
/** if mod is to be loaded. */
enabled: boolean,
/** version of the mod, verbatim as it appears in the mod's file name. */
version: SourceVersion,
/** version of the mod. */
version: FullVersion,
/** SHA1 hash of the zip file. */
sha1?: string,
/** Used inside packages\web_ui\src\components\ModPackViewPage.tsx to define an error type. */
@@ -61,7 +60,7 @@ export interface ModRecord {
const ModRecordJsonSchema = Type.Object({
"name": Type.String(),
"enabled": Type.Boolean(),
"version": SourceVersionSchema,
"version": FullVersionSchema,
"sha1": Type.Optional(Type.String()),
});
@@ -358,7 +357,7 @@ export default class ModPack {
* Setting prototypes exported from the game.
* @param logger - Logger used to report warnings on.
*/
fillDefaultSettings(settingPrototypes: ExportSettings, logger: Logger) {
fillDefaultSettings(settingPrototypes: Record<string, object>, logger: Logger) {
const knownTypes = ["bool-setting", "int-setting", "double-setting", "string-setting", "color-setting"];
let prototypes = Object.entries(settingPrototypes)
.filter(([type, _]) => knownTypes.includes(type))
@@ -371,7 +370,7 @@ export default class ModPack {
logger.warn(`Ignoring ${prototype.name} with unknown setting_type ${settingType}`);
continue;
}
if ((prototype as any).default_value === undefined) {
if (prototype.default_value === undefined) {
logger.warn(`Ignoring ${prototype.name} with missing default_value`);
continue;
}
@@ -390,13 +389,13 @@ export default class ModPack {
if (type === "color-setting") {
value = libLuaTools.normalizeColor(value as object);
}
this.settings[settingType].set(prototype.name, { value } as ModSetting);
this.settings[settingType].set(prototype.name, { value });
}
}
/** Array of default mod packs which should exist on a newly installed cluster */
static defaultModPacks = [
...(["0.17", "0.18", "1.0", "1.1", "2.0", "2.1"] as const)
...(["0.17", "0.18", "1.0", "1.1", "2.0"] as const)
.map(version => this.fromJSON({
name: `Base Game ${version}`,
description: `Factorio ${version} with no extra mods.`,
@@ -413,18 +412,6 @@ export default class ModPack {
{ name: "space-age", enabled: true, version: normaliseFullVersion(version) },
],
} as any)),
...(["2.1"] as const)
.map(version => this.fromJSON({
name: `Space Age ${version}`,
description: `Factorio ${version} with Space Age expansion.`,
factorio_version: version,
mods: [
{ name: "elevated-rails", enabled: true, version: normaliseFullVersion(version) },
{ name: "quality", enabled: true, version: normaliseFullVersion(version) },
{ name: "space-age", enabled: true, version: normaliseFullVersion(version) },
{ name: "recycler", enabled: true, version: normaliseFullVersion(version) },
],
} as any)),
];
/**
@@ -434,24 +421,18 @@ export default class ModPack {
* @return Built in mods for the given version
*/
static getBuiltinMods(factorioVersion: PartialVersion) {
const version = normaliseFullVersion(factorioVersion) as SourceVersion;
const integerVersion = integerPartialVersion(factorioVersion);
factorioVersion = normaliseFullVersion(factorioVersion);
let defaultMods: ModRecord[] = [
// "core" not included because core cannot be disabled
{ name: "base", enabled: true, version: version },
{ name: "base", enabled: true, version: factorioVersion },
];
const integerVersion = integerPartialVersion(factorioVersion);
if (integerVersion >= integerPartialVersion("1.2")) {
defaultMods = defaultMods.concat([
{ name: "elevated-rails", enabled: false, version: version },
{ name: "quality", enabled: false, version: version },
{ name: "space-age", enabled: false, version: version },
]);
}
if (integerVersion >= integerPartialVersion("2.1")) {
defaultMods = defaultMods.concat([
{ name: "recycler", enabled: false, version: version },
{ name: "elevated-rails", enabled: false, version: factorioVersion },
{ name: "quality", enabled: false, version: factorioVersion },
{ name: "space-age", enabled: false, version: factorioVersion },
]);
}
+2 -3
View File
@@ -8,17 +8,16 @@ export default class Permission {
public name: string,
public title: string,
public description: string,
public grantByDefault: boolean = false,
public grantByDefault?: boolean,
) { }
static jsonSchema = Type.Object({
name: Type.String(),
title: Type.String(),
description: Type.String(),
grantByDefault: Type.Optional(Type.Boolean()),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.name, json.title, json.description, Boolean(json.grantByDefault));
return new this(json.name, json.title, json.description);
}
}
+1 -45
View File
@@ -2,7 +2,7 @@ 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 { ExternalFactorioVersionSchema } from "../external";
export class ControllerStopRequest {
declare ["constructor"]: typeof ControllerStopRequest;
@@ -37,28 +37,6 @@ export class ControllerConfigGetRequest {
static Response = plainJson(ControllerConfig.jsonSchema);
}
export class ControllerConfigSetRequest {
declare ["constructor"]: typeof ControllerConfigSetRequest;
static type = "request" as const;
static src = "control" as const;
static dst = "controller" as const;
static permission = "core.controller.update_config" as const;
constructor(
public fields: Record<string, string | Record<string, unknown>>,
) { }
static jsonSchema = Type.Object({
"fields": Type.Record(Type.String(), Type.Union([
Type.String(), Type.Record(Type.String(), Type.Unknown()),
])),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.fields);
}
}
export class ControllerConfigSetFieldRequest {
declare ["constructor"]: typeof ControllerConfigSetFieldRequest;
static type = "request" as const;
@@ -442,25 +420,3 @@ export class FactorioVersionsRequest {
static Response = plainJson(Type.Array(ExternalFactorioVersionSchema));
}
export class LatestReleasesRequest {
declare ["constructor"]: typeof LatestReleasesRequest;
static type = "request" as const;
static src = ["control", "instance"] as const;
static dst = "controller" as const;
static permission = "core.external.get_latest_releases" as const;
constructor(
public maxAgeMs: number = 5 * 60 * 1000, // Default 5 minutes
) {}
static jsonSchema = Type.Object({
"maxAgeMs": Type.Number(),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.maxAgeMs);
}
static Response = plainJson(LatestReleasesSchema);
}
+2 -10
View File
@@ -102,16 +102,8 @@ export class Address {
* Returns a string suitable for indexing the address in a Map
* @returns unique string for this address
*/
requestIndex() {
return `${this.type}:${this.id}:${this.requestId}` as const;
}
/**
* Returns a string suitable for indexing the address in a Map
* @returns unique string for this address, excluding required id
*/
addressIndex() {
return `${this.type}:${this.id}` as const;
index() {
return `${this.type}:${this.id}:${this.requestId}`;
}
/**
-22
View File
@@ -37,28 +37,6 @@ export class HostConfigGetRequest {
static Response = plainJson(HostConfig.jsonSchema);
}
export class HostConfigSetRequest {
declare ["constructor"]: typeof HostConfigSetRequest;
static type = "request" as const;
static src = ["control", "controller"] as const;
static dst = "host" as const;
static permission = "core.host.update_config" as const;
constructor(
public fields: Record<string, string | Record<string, unknown>>,
) { }
static jsonSchema = Type.Object({
"fields": Type.Record(Type.String(), Type.Union([
Type.String(), Type.Record(Type.String(), Type.Unknown()),
])),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.fields);
}
}
export class HostConfigSetFieldRequest {
declare ["constructor"]: typeof HostConfigSetFieldRequest;
static type = "request" as const;
@@ -102,30 +102,6 @@ export class InstanceConfigGetRequest {
}
}
export class InstanceConfigSetRequest {
declare ["constructor"]: typeof InstanceConfigSetRequest;
static type = "request" as const;
static src = "control" as const;
static dst = "controller" as const;
static permission = "core.instance.update_config" as const;
constructor(
public instanceId: number,
public fields: Record<string, string | Record<string, unknown>>,
) { }
static jsonSchema = Type.Object({
"instanceId": Type.Integer(),
"fields": Type.Record(Type.String(), Type.Union([
Type.String(), Type.Record(Type.String(), Type.Unknown()),
])),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.instanceId, json.fields);
}
}
export class InstanceConfigSetFieldRequest {
declare ["constructor"]: typeof InstanceConfigSetFieldRequest;
static type = "request" as const;
+20 -20
View File
@@ -4,8 +4,8 @@ import ModPack from "./ModPack";
import { JsonString, jsonArray } from "./composites";
import {
MajorMinorVersion, MajorMinorVersionSchema, normaliseMajorMinorVersion,
SourceVersion, SourceVersionSchema,
FullVersion, FullVersionSchema,
ApiVersion, ApiVersionSchema, normaliseApiVersion,
ModVersionEquality,
} from "./version";
@@ -119,13 +119,13 @@ export class ModGetRequest {
constructor(
public name: string,
public version: SourceVersion,
public version: FullVersion,
public sha1?: string,
) { }
static jsonSchema = Type.Object({
"name": Type.String(),
"version": SourceVersionSchema,
"version": FullVersionSchema,
"sha1": Type.Optional(Type.String()),
});
@@ -154,7 +154,7 @@ export class ModSearchRequest {
constructor(
public query: string,
public factorioVersion: MajorMinorVersion,
public factorioVersion: ApiVersion,
public page: number,
public pageSize?: number,
public sort?: string,
@@ -163,7 +163,7 @@ export class ModSearchRequest {
static jsonSchema = Type.Object({
"query": Type.String(),
"factorioVersion": MajorMinorVersionSchema,
"factorioVersion": ApiVersionSchema,
"page": Type.Integer(),
"pageSize": Type.Optional(Type.Integer()),
"sort": Type.Optional(Type.String()),
@@ -205,9 +205,9 @@ export class ModSearchRequest {
// Define the structure for the latest release info from the portal
export const ModPortalReleaseSchema = Type.Object({
version: SourceVersionSchema,
version: FullVersionSchema,
// Match the structure from ModStore's ModRelease/ModDetails
info_json: Type.Object({ factorio_version: SourceVersionSchema }),
info_json: Type.Object({ factorio_version: ApiVersionSchema }),
released_at: Type.String(), // ISO 8601 date string
download_url: Type.String(),
file_name: Type.String(),
@@ -236,12 +236,12 @@ export class ModPortalGetAllRequest {
static permission = "core.mod.search_portal" as const;
constructor(
public factorioVersion: MajorMinorVersion,
public factorioVersion: ApiVersion,
public hide_deprecated?: boolean,
) { }
static jsonSchema = Type.Object({
"factorioVersion": MajorMinorVersionSchema,
"factorioVersion": ApiVersionSchema,
"hide_deprecated": Type.Optional(Type.Boolean()),
});
@@ -276,13 +276,13 @@ export class ModDownloadRequest {
constructor(
public name: string,
public version: SourceVersion,
public version: FullVersion,
public sha1?: string,
) { }
static jsonSchema = Type.Object({
"name": Type.String(),
"version": SourceVersionSchema,
"version": FullVersionSchema,
"sha1": Type.Optional(Type.String()),
});
@@ -302,12 +302,12 @@ export class ModDeleteRequest {
constructor(
public name: string,
public version: SourceVersion,
public version: FullVersion,
) { }
static jsonSchema = Type.Object({
"name": Type.String(),
"version": SourceVersionSchema,
"version": FullVersionSchema,
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
@@ -385,12 +385,12 @@ export class ModPortalDownloadRequest {
constructor(
public mods: ModNameVersionPair[],
public factorioVersion: MajorMinorVersion,
public factorioVersion: ApiVersion,
) { }
static jsonSchema = Type.Object({
"mods": Type.Array(ModNameVersionPairSchema),
"factorioVersion": MajorMinorVersionSchema,
"factorioVersion": ApiVersionSchema,
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
@@ -422,13 +422,13 @@ export class ModDependencyResolveRequest {
constructor(
public mods: ModDependency[],
public factorioVersion: MajorMinorVersion,
public factorioVersion: ApiVersion,
public checkForUpdates: boolean = false,
) { }
static jsonSchema = Type.Object({
"mods": Type.Array(ModDependency.jsonSchema),
"factorioVersion": MajorMinorVersionSchema,
"factorioVersion": ApiVersionSchema,
"checkForUpdates": Type.Boolean(),
});
@@ -441,7 +441,7 @@ export class ModDependencyResolveRequest {
return new this(
[...modPack.mods.values()]
.map(mod => new ModDependency(`${mod.name} ${equality} ${mod.version}`)),
normaliseMajorMinorVersion(modPack.factorioVersion),
normaliseApiVersion(modPack.factorioVersion),
checkForUpdates,
);
}
@@ -452,7 +452,7 @@ export class ModDependencyResolveRequest {
[...modPack.mods.values()]
.filter(mod => mod.enabled)
.map(mod => new ModDependency(`${mod.name} ${equality} ${mod.version}`)),
normaliseMajorMinorVersion(modPack.factorioVersion),
normaliseApiVersion(modPack.factorioVersion),
checkForUpdates,
);
}
+21 -101
View File
@@ -24,55 +24,24 @@ export function isVersionEquality(input: string): input is VersionEquality {
}
/**
* Known Factorio major versions, offered as fallback options in the web
* interface before the live version list has been fetched. Versions are no
* longer validated against this list; the mod portal is the source of truth.
* Matches valid factorio versions accepts by the web API
* https://wiki.factorio.com/Mod_portal_API#/api/mods (see version enum)
*/
export const ApiVersions = ["0.13", "0.14", "0.15", "0.16", "0.17", "0.18", "1.0", "1.1", "2.0", "2.1"] as const;
export const ApiVersions = ["0.13", "0.14", "0.15", "0.16", "0.17", "0.18", "1.0", "1.1", "2.0"] as const;
export const ApiVersionSchema = Type.Union(ApiVersions.map(v => Type.Literal(v)));
export type ApiVersion = Static<typeof ApiVersionSchema>;
/**
* Matches a version string the same lenient way the game reads it: each number
* may be preceded by whitespace, the major and minor are required, the patch is
* optional, and any trailing content is ignored. The pattern is intentionally
* not anchored at the end.
*/
const sourceVersionRegExp = /^\s*(\d+)\.\s*(\d+)(?:\.\s*(\d+))?/;
/**
* A raw, un-normalised version string exactly as the game reads it, such as a
* mod's version taken from info.json or a mod file name.
*/
declare const sourceVersionSymbol: unique symbol;
export type SourceVersion = string & { [sourceVersionSymbol]: void };
export const SourceVersionSchema = Type.Unsafe<SourceVersion>(
Type.String({ pattern: sourceVersionRegExp.source })
);
export function isSourceVersion(input: string): input is SourceVersion {
return sourceVersionRegExp.test(input);
export function isApiVersion(input: string): input is ApiVersion {
return ApiVersions.includes(input as any);
}
export function integerSourceVersion(version: SourceVersion) {
return integerFullVersion(normaliseSourceVersion(version));
}
/**
* Normalise a version string the same lenient way the game reads it. Numbers
* are read as integers, so leading whitespace and zeros are dropped and any
* content after the patch is ignored.
*
* @returns The normalised full version, or undefined if no major and minor
* could be read.
*/
export function normaliseSourceVersion(input: SourceVersion): FullVersion;
export function normaliseSourceVersion(input: string): FullVersion | undefined;
export function normaliseSourceVersion(input: string | SourceVersion): FullVersion | undefined {
const match = sourceVersionRegExp.exec(input);
if (match === null) {
return undefined;
export function normaliseApiVersion(version: PartialVersion) {
const parts = version.split(".");
const apiVersion = `${parts[0]}.${parts[1]}`;
if (!isApiVersion(apiVersion)) {
throw new Error(`Factorio version not supported by mod portal: ${apiVersion}`);
}
return `${Number(match[1])}.${Number(match[2])}.${Number(match[3] ?? "0")}` as FullVersion;
return apiVersion;
}
/**
@@ -99,38 +68,11 @@ export function normaliseFullVersion(version: PartialVersion) {
return (parts.length === 3 ? version : `${version}.0`) as FullVersion;
}
/**
* Matches a major.minor version, as used by the mod portal's version filter.
*/
const majorMinorVersionRegExp = /^\d+\.\d+$/;
export type MajorMinorVersion = `${number}.${number}`;
export const MajorMinorVersionSchema = Type.Unsafe<MajorMinorVersion>(
Type.String({ pattern: majorMinorVersionRegExp.source })
);
export function isMajorMinorVersion(input: string): input is MajorMinorVersion {
return majorMinorVersionRegExp.test(input);
}
export function integerMajorMinorVersion(version: MajorMinorVersion) {
const [major, minor] = version.split(".").map(n => Number.parseInt(n, 10));
// Can't use bitwise here because this is 48-bits. sub is always 0 so is omitted.
return major * 0x100000000 + minor * 0x10000 as IntegerVersion;
}
/**
* Reduce a version to its major.minor, as used by the mod portal's version filter.
*/
export function normaliseMajorMinorVersion(version: PartialVersion): MajorMinorVersion {
const [major, minor] = version.split(".");
return `${major}.${minor}` as MajorMinorVersion;
}
/**
* Matches valid factorio versions and mod dependencies specifications where 2 or 3 parts are specified.
*/
const partialVersionRegExp = /^\d+\.\d+(?:\.\d+)?$/;
export type PartialVersion = FullVersion | MajorMinorVersion;
export type PartialVersion = FullVersion | `${number}.${number}`;
export const PartialVersionSchema = Type.Unsafe<PartialVersion>(
Type.String({ pattern: partialVersionRegExp.source })
);
@@ -146,35 +88,16 @@ export function integerPartialVersion(version: PartialVersion) {
}
/**
* Matches a release channel name from the latest-releases API, such as
* "stable" or "experimental". Excludes "latest", which is handled separately.
* The set of channels is dynamic, so any lowercase identifier is accepted here
* and resolved against the live API when the instance starts.
* Matches valid factorio target versions, this is the same as partial but also accepts "latest".
*/
const releaseChannelRegExp = /^[a-z][a-z0-9-]*$/;
declare const releaseChannelSymbol: unique symbol;
export type ReleaseChannel = string & { [releaseChannelSymbol]: void };
export const ReleaseChannelSchema = Type.Unsafe<ReleaseChannel>(
Type.String({ pattern: releaseChannelRegExp.source })
);
export function isReleaseChannel(input: string): input is ReleaseChannel {
return input !== "latest" && releaseChannelRegExp.test(input);
}
/**
* Matches valid factorio target versions: a partial version, "latest", or a
* release channel name (e.g. "stable" or "experimental").
*/
export type TargetVersion = PartialVersion | ReleaseChannel | "latest";
export type TargetVersion = PartialVersion | "latest";
export const TargetVersionSchema = Type.Union([
PartialVersionSchema,
ReleaseChannelSchema,
Type.Literal("latest"),
]);
export function isTargetVersion(input: string): input is TargetVersion {
return input === "latest" || releaseChannelRegExp.test(input) || partialVersionRegExp.test(input);
return input === "latest" || partialVersionRegExp.test(input);
}
/**
@@ -213,8 +136,8 @@ export class ModVersionEquality {
}
}
testVersion(version: SourceVersion) {
return this.testIntegerVersion(integerSourceVersion(version));
testVersion(version: PartialVersion) {
return this.testIntegerVersion(integerPartialVersion(version));
}
toString() {
@@ -276,11 +199,8 @@ export class ModVersionRange {
return this.minVersion.testIntegerVersion(other) && this.maxVersion.testIntegerVersion(other);
}
testVersion(version: PartialVersion | SourceVersion) {
if (isPartialVersion(version)) {
return this.testIntegerVersion(integerPartialVersion(version));
}
return this.testIntegerVersion(integerSourceVersion(version));
testVersion(version: PartialVersion) {
return this.testIntegerVersion(integerPartialVersion(version));
}
combineVersion(other: ModVersionEquality) {
+1 -1
View File
@@ -4,7 +4,7 @@
* @module lib/database
* @author Hornwitser
*/
import fs from "node:fs/promises";
import fs from "fs-extra";
import * as libFileOps from "./file_ops";
import { basicType } from "./helpers";
+1 -1
View File
@@ -1,5 +1,5 @@
import path from "path";
import fs from "node:fs/promises";
import fs from "fs-extra";
import { EventEmitter } from "events";
import { safeOutputFile } from "./file_ops";
-129
View File
@@ -1,129 +0,0 @@
import { FactorioColor } from "./lua_tools";
/**
* A localised string.
* @see [LocalisedString](https://lua-api.factorio.com/stable/types/LocalisedString.html)
*/
export type FactorioLocalisedString = string | [string, ...FactorioLocalisedString[]];
/**
* Shared properties of all prototypes.
* @see [PrototypeBase](https://lua-api.factorio.com/stable/types/PrototypeBase.html)
*/
export interface FactorioPrototypeBase {
[index: string]: unknown,
type: string,
name: string,
order?: string,
localised_name?: FactorioLocalisedString,
localised_description?: FactorioLocalisedString,
factoriopedia_description?: FactorioLocalisedString,
subgroup?: string,
hidden?: boolean,
hidden_in_factoriopedia?: boolean,
parameter?: boolean,
factoriopedia_simulation?: unknown,
}
/**
* Stage the mod setting is part of.
* @see [settings_type property](https://wiki.factorio.com/Tutorial:Mod_settings#The_setting_type_property)
*/
export type FactorioModSettingType = "startup" | "runtime-global" | "runtime-per-user";
/**
* Boolean mod setting prototype.
* @see [bool-setting](https://wiki.factorio.com/Tutorial:Mod_settings#bool-setting)
*/
export interface FactorioBoolSettingPrototype extends FactorioPrototypeBase{
type: "bool-setting",
setting_type?: FactorioModSettingType,
default_value: boolean,
forced_value?: boolean,
}
/**
* Integer mod setting prototype.
* @see [int-setting](https://wiki.factorio.com/Tutorial:Mod_settings#int-setting)
*/
export interface FactorioIntSettingPrototype extends FactorioPrototypeBase{
type: "int-setting",
setting_type?: FactorioModSettingType,
default_value: number,
minimum_value?: number,
maximum_value?: number,
allowed_values?: number[],
}
/**
* Real number mod setting prototype.
* @see [double-setting](https://wiki.factorio.com/Tutorial:Mod_settings#double-setting)
*/
export interface FactorioDoubleSettingPrototype extends FactorioPrototypeBase{
type: "double-setting",
setting_type?: FactorioModSettingType,
default_value: number,
minimum_value?: number,
maximum_value?: number,
allowed_values?: number[],
}
/**
* String mod setting prototype.
* @see [string-setting](https://wiki.factorio.com/Tutorial:Mod_settings#string-setting)
*/
export interface FactorioStringSettingPrototype extends FactorioPrototypeBase{
type: "string-setting",
setting_type?: FactorioModSettingType,
default_value: string,
allow_black?: boolean,
auto_trim?: string,
allowed_values?: string[],
}
/**
* Color mod setting prototype.
* @see [color-setting](https://wiki.factorio.com/Tutorial:Mod_settings#color-setting)
*/
export interface FactorioColorSettingPrototype extends FactorioPrototypeBase{
type: "color-setting",
setting_type?: FactorioModSettingType,
default_value: FactorioColor,
forced_value?: boolean,
}
export type FactorioModSettingPrototype =
| FactorioBoolSettingPrototype
| FactorioIntSettingPrototype
| FactorioDoubleSettingPrototype
| FactorioStringSettingPrototype
| FactorioColorSettingPrototype
;
/**
* A list of prototypes as it appears in Factorio's Lua API.
* @see [Data.raw](https://lua-api.factorio.com/stable/types/Data.html#raw)
*/
export type FactorioPrototypes<Prototype = FactorioPrototypeBase> = Record<string, Record<string, Prototype>>
export type FactorioDefinesValue = number | string | { [index: string]: FactorioDefinesValue };
export type FactorioDefines = {
[index: string]: FactorioDefinesValue,
prototypes: Record<string, Record<string, number>>,
};
export type ExportSettings = FactorioPrototypes<FactorioModSettingPrototype>;
export type ExportPrototypes = FactorioPrototypes;
export type ExportLocale = [string, string][];
export interface ExportMetadataEntry {
name: string,
type: string,
localised_name?: FactorioLocalisedString,
icon?: {
x: number,
y: number,
size: number,
path?: string,
},
}
export type ExportMetadata = Record<string, ExportMetadataEntry[]>;
export type ExportDefines = FactorioDefines;
+9 -5
View File
@@ -2,6 +2,7 @@ import { Static, Type } from "@sinclair/typebox";
import { FullVersionSchema, integerFullVersion, isFullVersion } from "../data/version";
const ARCHIVE_URL = "https://factorio.com/download/archive/";
const WINDOWS_HEADLESS_BASE_URL = "https://factorio-repackager.fly.dev/get-download";
/**
* Represents the parsed result of a factorio version
@@ -10,9 +11,10 @@ export const ExternalFactorioVersionSchema = Type.Object({
stable: Type.Boolean(),
version: FullVersionSchema,
headlessUrl: Type.String(),
windowsHeadlessUrl: Type.Optional(Type.String()),
});
export type ExternalFactorioVersion = Static<typeof ExternalFactorioVersionSchema>
export type ExternalFactorioVersion = Static<typeof ExternalFactorioVersionSchema>;
/**
* Fetch all factorio versions that support headless
@@ -21,7 +23,7 @@ export type ExternalFactorioVersion = Static<typeof ExternalFactorioVersionSchem
* @throws Network errors and non-ok status responses
*/
export async function fetchFactorioVersions(): Promise<ExternalFactorioVersion[]> {
let res;
let res: Response;
try {
res = await fetch(ARCHIVE_URL);
@@ -48,11 +50,11 @@ function parseFactorioVersions(html: string): ExternalFactorioVersion[] {
// Match <a class=" slot-button-inline version-button-stable " href="/download/archive/2.0.72">2.0.72</a>
// eslint-disable-next-line max-len
const anchorRegex = /<a[^>]*class="[^"]*version-button-(?<kind>stable|experimental)[^"]*"[^>]*href="[^"]*\/download\/archive\/(?<version>\d+\.\d+.\d+)"[^>]*>/g;
const anchorRegex = /<a[^>]*class="[^"]*version-button-(?<kind>stable|experimental)[^"]*"[^>]*href="[^"]*\/download\/archive\/(?<version>\d+\.\d+\.\d+)"[^>]*>/g;
let match: RegExpExecArray | null;
let match = anchorRegex.exec(html);
const minimumMultiplayerVersion = integerFullVersion("0.12.35");
while ((match = anchorRegex.exec(html)) !== null) {
while (match !== null) {
const kind = match.groups?.kind;
const version = match.groups?.version;
@@ -61,8 +63,10 @@ function parseFactorioVersions(html: string): ExternalFactorioVersion[] {
version: version,
stable: kind === "stable",
headlessUrl: `https://www.factorio.com/get-download/${version}/headless/linux64`,
windowsHeadlessUrl: `${WINDOWS_HEADLESS_BASE_URL}/${version}/headless/win64`,
});
}
match = anchorRegex.exec(html);
}
return [...versions.values()];
-62
View File
@@ -1,62 +0,0 @@
import { Static, Type } from "@sinclair/typebox";
import { FullVersion, FullVersionSchema } from "../data/version";
const LATEST_RELEASES_URL = "https://factorio.com/api/latest-releases";
/**
* The set of builds (e.g. alpha, demo, headless, expansion) and their version
* for a single release channel.
*/
export const LatestReleaseBuildsSchema = Type.Record(Type.String(), FullVersionSchema);
/**
* The latest released version of each build, keyed by release channel name
* (e.g. "stable" and "experimental").
*/
export const LatestReleasesSchema = Type.Record(Type.String(), LatestReleaseBuildsSchema);
export type LatestReleases = Static<typeof LatestReleasesSchema>
/**
* Fetch the latest stable and experimental factorio releases
*
* @returns The latest version of each build keyed by release channel
* @throws Network errors and non-ok status responses
*/
export async function fetchLatestReleases(): Promise<LatestReleases> {
let res;
try {
res = await fetch(LATEST_RELEASES_URL);
} catch (err) {
throw new Error(`Failed to fetch Factorio releases from ${LATEST_RELEASES_URL}: ${err}`);
}
if (!res.ok) {
throw new Error(
`Failed to fetch Factorio releases from ${LATEST_RELEASES_URL}: HTTP ${res.status} ${res.statusText}`
);
}
return await res.json() as LatestReleases;
}
/**
* Resolve a release channel to a concrete factorio version
*
* @param releases Releases data as returned by {@link fetchLatestReleases}
* @param channel Release channel name, e.g. "stable" or "experimental"
* @param build Build to read the version of, defaults to the headless build
* @returns The version for the channel, or undefined if it cannot be resolved
*/
export function resolveReleaseChannel(
releases: LatestReleases,
channel: string,
build = "headless",
): FullVersion | undefined {
const builds = releases[channel];
if (!builds) {
return undefined;
}
return (builds[build] ?? Object.values(builds)[0]) as FullVersion | undefined;
}
-1
View File
@@ -3,4 +3,3 @@
* @module lib/external
*/
export * from "./FactorioVersions";
export * from "./LatestReleases";
+17 -52
View File
@@ -4,10 +4,7 @@ class MapReaderState {
pos = 0;
last_position = { x: 0, y: 0 };
/** True when a version greater than 2.0.0 is detected */
gt_v2_0 = false;
/** True when a version greater than 2.1.0 is detected */
gt_v2_1 = false;
// If more versions are required then we should expose version directly
v2 = false;
constructor(
public buf: Buffer
) { }
@@ -164,7 +161,7 @@ function readBoundingBox(state: MapReaderState) {
}
function readCliffSettings(state: MapReaderState) {
return state.gt_v2_0 ? {
return state.v2 ? {
name: readString(state),
control: readString(state), // v2
cliff_elevation_0: readFloat(state),
@@ -189,7 +186,7 @@ function readTerritorySettings(state: MapReaderState) {
}
function readMapGenSettings(state: MapReaderState) {
return state.gt_v2_0 ? {
return state.v2 ? {
autoplace_controls: Object.fromEntries(readDict(state, readString, readFrequencySizeRichness)),
autoplace_settings: Object.fromEntries(readDict(state, readString, readAutoplaceSetting)),
default_enable_all_autoplace_controls: readBool(state),
@@ -265,23 +262,7 @@ function readEnemyEvolution(state: MapReaderState) {
}
function readEnemyExpansion(state: MapReaderState) {
return state.gt_v2_1 ? {
enabled: readOptional(state, readBool),
max_expansion_distance: readOptional(state, readUInt32),
min_expansion_distance: readOptional(state, readUInt32), // v2.1
friendly_base_influence_radius: readOptional(state, readUInt32),
enemy_building_influence_radius: readOptional(state, readUInt32),
building_coefficient: readOptional(state, readDouble),
other_base_coefficient: readOptional(state, readDouble),
neighbouring_chunk_coefficient: readOptional(state, readDouble),
neighbouring_base_chunk_coefficient: readOptional(state, readDouble),
max_colliding_tiles_coefficient: readOptional(state, readDouble),
settler_group_min_size: readOptional(state, readUInt32),
settler_group_max_size: readOptional(state, readUInt32),
evolution_group_size_factor: readOptional(state, readDouble), // v2.1
min_expansion_cooldown: readOptional(state, readUInt32),
max_expansion_cooldown: readOptional(state, readUInt32),
} : {
return {
enabled: readOptional(state, readBool),
max_expansion_distance: readOptional(state, readUInt32),
friendly_base_influence_radius: readOptional(state, readUInt32),
@@ -355,7 +336,7 @@ function readPathFinder(state: MapReaderState) {
}
function readDifficultySettings(state: MapReaderState) {
return state.gt_v2_0 ? {
return state.v2 ? {
technology_price_multiplier: readDouble(state),
spoil_time_modifier: readDouble(state), // v2
} : {
@@ -374,32 +355,17 @@ function readAsteroids(state: MapReaderState) {
}
function readMapSettings(state: MapReaderState) {
if (state.gt_v2_1) {
return {
pollution: readPollution(state),
// steering removed v2.1
enemy_evolution: readEnemyEvolution(state),
enemy_expansion: readEnemyExpansion(state),
unit_group: readUnitGroup(state),
path_finder: readPathFinder(state),
max_failed_behavior_count: readUInt32(state),
difficulty_settings: readDifficultySettings(state),
asteroids: readAsteroids(state), // v2
};
} else if (state.gt_v2_0) {
return {
pollution: readPollution(state),
steering: readSteering(state),
enemy_evolution: readEnemyEvolution(state),
enemy_expansion: readEnemyExpansion(state),
unit_group: readUnitGroup(state),
path_finder: readPathFinder(state),
max_failed_behavior_count: readUInt32(state),
difficulty_settings: readDifficultySettings(state),
asteroids: readAsteroids(state), // v2
};
}
return {
return state.v2 ? {
pollution: readPollution(state),
steering: readSteering(state),
enemy_evolution: readEnemyEvolution(state),
enemy_expansion: readEnemyExpansion(state),
unit_group: readUnitGroup(state),
path_finder: readPathFinder(state),
max_failed_behavior_count: readUInt32(state),
difficulty_settings: readDifficultySettings(state),
asteroids: readAsteroids(state), // v2
} : {
pollution: readPollution(state),
steering: readSteering(state),
enemy_evolution: readEnemyEvolution(state),
@@ -460,8 +426,7 @@ export function readMapExchangeString(exchangeString: string) {
try {
const version = readVersion(state);
state.gt_v2_0 = version[0] >= 2;
state.gt_v2_1 = version[0] >= 2 && version[1] >= 1;
state.v2 = version[0] >= 2;
data = {
version: version,
unknown: readUInt8(state),
+24 -128
View File
@@ -1,12 +1,9 @@
/**
* @module lib/file_ops
*/
import crypto from "crypto";
import events from "events";
import fs from "node:fs/promises";
import fs from "fs-extra";
import path from "path";
import stream from "stream";
import crypto from "crypto"; // needed for getTempFile
/**
* Returns the newest file in a directory
@@ -46,7 +43,7 @@ export async function getNewestFile(directory: string, filter = (_name: string)
* @returns The size in bytes of all files in the directory.
*/
export async function directorySize(directory: string) {
let dirEntries;
let dirEntries: fs.Dirent[];
try {
dirEntries = await fs.readdir(directory, { withFileTypes: true });
} catch (err: any) {
@@ -91,12 +88,8 @@ export async function findUnusedName(directory: string, name: string, extension
}
while (true) {
try {
await fs.access(path.join(directory, `${name}${extension}`));
} catch (err: any) {
if (err.code === "ENOENT") {
return `${name}${extension}`;
}
if (!await fs.pathExists(path.join(directory, `${name}${extension}`))) {
return `${name}${extension}`;
}
let match = /^(.*?)(-(\d+))?$/.exec(name)!;
@@ -129,13 +122,12 @@ export async function getTempFile(prefix = "tmp.", suffix = "", tmpdir = "./") {
}
/**
* Safely overwrite file data
* Safely write data to a file
*
* Writes the passed data to a temporary file and rename it over the target
* file. If the operation fails because the parent directory does not exist, it
* attempts to create the directory, including any ancestors and then retries
* the operation. The name of the temporary file is the same as the target file
* with the suffix `.tmp` added before the extension.
* Same as fs-extra.outputFile except the data is written to a temporary
* file that's renamed over the target file. The name of the temporary file
* is the same as the target file with the suffix `.tmp` added before the
* extension.
*
* If the operation fails it may leave behind the temporary file. This
* should not be too much of an issue as the next time the same file is
@@ -143,32 +135,26 @@ export async function getTempFile(prefix = "tmp.", suffix = "", tmpdir = "./") {
*
* @param file - Path to file to write.
* @param data - Content to write.
* @param options - see fs.writeFile, `flag` and `flush` may not be used.
* @param options - see fs.writeFile, `flag` must not be set.
*/
export async function safeOutputFile(
file: string,
data: string | Buffer,
options?: Omit<
Extract<Parameters<typeof fs.writeFile>[2], { flush?: boolean }>,
"flag" | "flush"
>,
) {
export async function safeOutputFile(file: string, data: string | Buffer, options: fs.WriteFileOptions ={}) {
let { dir, name, ext } = path.parse(file);
let temporary = path.join(dir, `${name}.tmp${ext}`);
try {
await fs.writeFile(temporary, data, { ...options, flush: true });
} catch (err: any) {
if (err.code === "ENOENT") {
// Try creating the folder and then retry the operation.
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(temporary, data, { ...options, flush: true });
} else {
throw err;
}
if (dir && !await fs.pathExists(dir)) {
await fs.mkdirs(dir);
}
let temporary = path.join(dir, `${name}.tmp${ext}`);
let fd = await fs.open(temporary, "w");
try {
await fs.writeFile(fd, data, options);
await fs.fsync(fd);
} finally {
await fs.close(fd);
}
await fs.rename(temporary, file);
}
// Reserved names by almost all filesystems
const badNames = [".", ".."];
@@ -249,93 +235,3 @@ export function cleanFilename(name: string) {
return name;
}
/**
* Creates a write stream to a guaranteed newly created temporary file in the
* same folder as the target `filePath`.
*
* @param filePath - The target file to create a temporary stream for.
* @returns The path to the temporary file that was opened and the write stream to it.
*/
export async function createTempWriteStream(filePath: string) {
const { dir, name: fileName, ext } = path.parse(filePath);
let increment = 1;
let writeStream;
let tempFilePath = path.format({ dir, name: `${fileName}.tmp`, ext });
while (true) {
try {
writeStream = (await fs.open(tempFilePath, "wx")).createWriteStream({ flush: true });
break;
} catch (err: any) {
if (err.code === "EEXIST") {
increment += 1;
tempFilePath = path.format({ dir, name: `${fileName}-${increment}.tmp`, ext });
} else {
throw err;
}
}
}
return [tempFilePath, writeStream] as const;
}
/**
* Download a file by fetching the given url and streaming the body content to it.
*
* @param url - The resource to download
* @param filePath - Path to the file store the resource data in.
* @param overwriteMode - What to do if the target filePath already exists. Use
* overwrite to clobber it, use rename to write to a differently named file,
* and error to throw an EEXIST error.
* @returns file path that was written to. May be different from `filePath` if
* overwriteMode is set to rename.
*/
export async function downloadFile(
url: URL | string,
filePath: string,
overwriteMode: "overwrite" | "rename" | "error",
) {
// Prepare file to stream content to.
let writeStream;
let tempFilePath;
if (overwriteMode === "error") {
tempFilePath = filePath;
writeStream = (await fs.open(tempFilePath, "wx")).createWriteStream({ flush: true });
} else {
[tempFilePath, writeStream] = await createTempWriteStream(filePath);
}
// Fetch stream bytes
const response = await fetch(url);
if (!response.ok || !response.body) {
writeStream.end();
await events.once(writeStream, "close");
await fs.unlink(tempFilePath);
throw new Error(`Failed to download ${url}: ${response.status} ${response.statusText}`);
}
const writer = stream.Writable.toWeb(writeStream);
await response.body.pipeTo(writer);
let targetFilePath = filePath;
if (overwriteMode === "overwrite") {
await fs.rename(tempFilePath, targetFilePath);
} else if (overwriteMode === "rename") {
let increment = 1;
let { dir, name, ext } = path.parse(targetFilePath);
while (true) {
try {
await fs.writeFile(targetFilePath, Buffer.alloc(0), { flag: "wx" });
break;
} catch (err: any) {
if (err.code === "EEXIST") {
increment += 1;
targetFilePath = path.format({ dir, name: `${name}-${increment}`, ext });
} else {
throw err;
}
}
}
await fs.rename(tempFilePath, targetFilePath);
}
return targetFilePath;
}
+3 -3
View File
@@ -3,7 +3,7 @@
* @module lib/hash
*/
import crypto from "crypto";
import fs from "node:fs/promises";
import fs from "fs-extra";
/**
@@ -35,6 +35,6 @@ export function hashStream(stream: NodeJS.ReadableStream): Promise<string> {
* @param path - Path to the file to hash.
* @returns hash of the file given.
*/
export async function hashFile(path: string) {
return hashStream((await fs.open(path)).createReadStream());
export function hashFile(path: string) {
return hashStream(fs.createReadStream(path));
}
-29
View File
@@ -3,9 +3,6 @@
* @module lib/helpers
*/
import type { Logger } from "./logging";
import setBlocking from "set-blocking";
/**
* Return a string describing the type of the value passed
*
@@ -54,32 +51,6 @@ export async function timeout<T>(promise: Promise<T>, limitMs: number, timeoutRe
}
}
/**
* Catch process signals, force exit on second signal
*/
export function createShutdownGuard(logger: Logger, signalName: string, callback: () => Promise<unknown>): () => void {
let called = false;
return () => {
if (called) {
setBlocking(true);
logger.fatal(`Caught second ${signalName}, terminating immediately`);
process.exit(1);
}
called = true;
logger.info(`Caught ${signalName}, shutting down`);
callback().catch(err => {
setBlocking(true);
logger.fatal("Error during shutdown, terminating immediately");
logger.fatal(err.stack);
process.exit(1);
});
};
}
/**
* Helper to serialise and merge waiting calls to an async callback.
*
+2 -2
View File
@@ -219,7 +219,7 @@ export class Link {
if (!message.dst.addressedTo(this.connector.src)) {
if (message.type === "response" || message.type === "responseError") {
this._forwardedRequests.delete(message.dst.requestIndex());
this._forwardedRequests.delete(message.dst.index());
}
this._routeMessage(message, entry);
return;
@@ -566,7 +566,7 @@ export class Link {
src: message.src,
dst: message.dst,
};
this._forwardedRequests.set(message.src.requestIndex(), pending);
this._forwardedRequests.set(message.src.index(), pending);
this.connector.forward(message);
}
-4
View File
@@ -23,7 +23,6 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
controller.ControllerRestartRequest,
controller.ControllerUpdateRequest,
controller.ControllerConfigGetRequest,
controller.ControllerConfigSetRequest,
controller.ControllerConfigSetFieldRequest,
controller.ControllerConfigSetPropRequest,
controller.HostGenerateTokenRequest,
@@ -37,7 +36,6 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
controller.DebugDumpWsRequest,
controller.DebugWsMessageEvent,
controller.FactorioVersionsRequest,
controller.LatestReleasesRequest,
subscriptions.SubscriptionRequest,
@@ -45,7 +43,6 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
host.HostRestartRequest,
host.HostUpdateRequest,
host.HostConfigGetRequest,
host.HostConfigSetRequest,
host.HostConfigSetFieldRequest,
host.HostConfigSetPropRequest,
host.HostListRequest,
@@ -62,7 +59,6 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
instance.InstanceDetailsUpdatesEvent,
instance.InstanceCreateRequest,
instance.InstanceConfigGetRequest,
instance.InstanceConfigSetRequest,
instance.InstanceConfigSetFieldRequest,
instance.InstanceConfigSetPropRequest,
instance.InstanceAssignRequest,
+5 -8
View File
@@ -1,4 +1,4 @@
import fs from "node:fs/promises";
import fs from "fs-extra";
import path from "path";
import { logger } from "./logging";
import * as libFileOps from "./file_ops";
@@ -174,15 +174,12 @@ export async function loadPluginList(pluginListPath: string, findLocal = true):
*/
async function checkPackageJson(pluginPath: string): Promise<boolean> {
const packageJsonPath = path.join(pluginPath, "package.json");
// Read and parse package.json
let content;
try {
content = await fs.readFile(packageJsonPath, { encoding: "utf8" });
} catch (err: any) {
if (!await fs.exists(packageJsonPath)) {
return false;
}
const packageJson = JSON.parse(content);
// Read and parse package.json
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, { encoding: "utf8" }));
// Check if package has the clusterio-plugin keyword
return packageJson.keywords?.includes("clusterio-plugin");
+4 -5
View File
@@ -4,8 +4,7 @@
* @author Hornwitser
* @module lib/logging_utils
*/
import fs from "node:fs/promises";
import type { ReadStream } from "node:fs";
import fs from "fs-extra";
import path from "path";
import stream from "stream";
import util from "util";
@@ -321,7 +320,7 @@ export class LogIndex {
async indexFile(file: string) {
let filePath = path.join(this.logDirectory, file);
let lineStream = new libStream.LineSplitter({ readableObjectMode: true });
let fileStream = (await fs.open(filePath)).createReadStream();
let fileStream = fs.createReadStream(filePath);
fileStream.pipe(lineStream);
let entry = {
levels: new Set() as Set<keyof typeof levels>,
@@ -479,11 +478,11 @@ export async function queryLog(logDirectory: string, filter: QueryLogFilter, ind
continue;
}
let filePath = path.join(logDirectory, file);
let fileStream: ReadStream;
let fileStream: fs.ReadStream;
let lineStream: libStream.LineSplitter;
if (filter.order === "asc") {
lineStream = new libStream.LineSplitter({ readableObjectMode: true });
fileStream = (await fs.open(filePath)).createReadStream();
fileStream = fs.createReadStream(filePath);
} else {
lineStream = new libStream.ReverseLineSplitter({ readableObjectMode: true });
fileStream = await libStream.createReverseReadStream(filePath);
-6
View File
@@ -524,9 +524,3 @@ definePermission({
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",
});
-10
View File
@@ -7,14 +7,6 @@ import type { Logger } from "./logging";
import type { FieldDefinition } from "./config";
import type { PlayerStats } from "./data";
export const PluginFeatureFlags = [
/** The plugin requires module code to be patched into the save */
"SavePatching",
/** The plugin requires access to script commands over rcon */
"ScriptCommands",
] as const;
/** Used to define the plugin export in plugins */
export type PluginDeclaration = {
name: string;
@@ -34,8 +26,6 @@ export type PluginDeclaration = {
webEntrypoint?: string;
controlConfigFields?: Record<string, FieldDefinition>;
features?: (typeof PluginFeatureFlags)[number][];
messages?: any[];
routes?: string[];
}
+1 -1
View File
@@ -4,7 +4,7 @@
* @module lib/plugin_loader
*/
import path from "path";
import fs from "node:fs/promises";
import fs from "fs/promises";
import * as libErrors from "./errors";
import * as libPlugin from "./plugin";
import { logger } from "./logging";
+8 -9
View File
@@ -1,10 +1,6 @@
import assert from "assert";
import fs from "node:fs"; // Need the non-promise api for reverse stream
import fs from "fs-extra";
import stream from "stream";
import util from "node:util";
const fsOpen = util.promisify(fs.open);
const fsFstat = util.promisify(fs.fstat);
/**
@@ -17,7 +13,7 @@ const fsFstat = util.promisify(fs.fstat);
* ```ts
* let lineStream = new lib.LineSplitter({ readableObjectMode: true });
* lineStream.on("data", line => { console.log(line.toString("utf8")); });
* let fileStream = (await fs.open(path)).createReadStream();
* let fileStream = fs.createReadStream(path);
* fileStream.pipe(lineStream);
* ```
*/
@@ -145,8 +141,8 @@ export async function createReverseReadStream(path: string, options?: ReadStream
if (typeof options === "string") {
options = { encoding: options };
}
const fileFd = await fsOpen(path, "r");
const fileSize = (await fsFstat(fileFd)).size;
const fileFd = await fs.open(path, "r");
const fileSize = (await fs.fstat(fileFd)).size;
let filePosition = fileSize;
const reverseFs = {
read(
@@ -166,5 +162,8 @@ export async function createReverseReadStream(path: string, options?: ReadStream
open() { assert(false); },
close(fd: number, callback: () => void) { return fs.close(fd, callback); },
};
return fs.createReadStream("", { ...options, fd: fileFd, fs: reverseFs });
return fs.createReadStream(
"",
{ ...options, fd: fileFd, fs: reverseFs } as ReadStreamOptions, // type specification is missing fs prop.
);
}
+152 -400
View File
@@ -1,144 +1,13 @@
import { Type, Static } from "@sinclair/typebox";
import { Link, Event, EventClass, RequestHandler, WebSocketBaseConnector } from "./link";
import { Address, MessageRequest, IUser, JsonBoolean, StringEnum, AccountDetails } from "./data";
import { Address, MessageRequest, IUser, plainJson, JsonBoolean } from "./data";
import isDeepStrictEqual from "./is_deep_strict_equal";
import { RequestError } from "./errors";
import { RequestError, SessionLost } from "./errors";
import { logger } from "./logging";
export type SubscriptionRequestHandler<T> = RequestHandler<SubscriptionRequest, Event<T> | null>;
export type EventSubscriberCallback<T> = (event: T | null, synced: boolean) => void
/**
* Represents a set of string subscription filters.
* When isAll() is true, all strings match this filter.
* When isEmpty() is true, no strings match this filter.
* Otherwise, literal string matching is performed against a set.
*/
export class SubscriptionFilters extends Set<string> {
private constructor(
private _all: boolean,
_filters: Iterable<string> = [],
) {
super(_filters);
}
static jsonSchema = Type.Array(Type.String());
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(false, json);
}
toJSON() {
return [...this];
}
/** Creates a subscription filter accepting all strings */
static all() {
return new this(true);
}
isAll() {
return this._all;
}
/** Creates a subscription filter rejecting all strings */
static empty() {
return new this(false);
}
isEmpty() {
return !this._all && this.size === 0;
}
/**
* Creates a subscription filter from undefined, string, or array of strings.
* Strings work as expected, undefined will produce a filter accepting all values.
* @param filters Shorthand representation
*/
static fromShorthand(filters?: string | string[]) {
if (filters === undefined) {
return this.all();
}
if (Array.isArray(filters)) {
return new this(false, new Set(filters));
}
return new this(false, new Set([filters]));
}
toString() {
if (this._all) {
return "[SubscriptionFilters All]";
}
if (this.size === 0) {
return "[SubscriptionFilters Empty]";
}
return `[SubscriptionFilters Set<${this.size}>]`;
}
/** Returns true if this filter accepts the given string */
accepts(value: string) {
return this._all || this.has(value);
}
/** Returns true if this filter extends the other */
extends(other: this) {
return this._all || (!other._all && [...other.values()].every(value => this.has(value)));
}
/** Returns true if there is overlap between two filters */
intersects(other: this) {
if (this._all) {
return !other.isEmpty();
}
if (other._all) {
return !this.isEmpty();
}
for (const filter of other) {
if (this.has(filter)) {
return true;
}
}
return false;
}
/** Add filters from a filter into this one. */
union(other: this) {
if (this._all) {
return; // Already all
}
if (other._all) {
this._all = true;
this.clear();
return;
}
for (const filter of other) {
this.add(filter);
}
}
/**
* Remove filters in a filter from this one.
* You cannot subtract filters from a filter accepting all.
* Subtracting a filter accepting all produces an empty filter.
*/
subtract(other: this) {
if (other._all) {
this._all = false;
this.clear();
return;
}
if (this._all) {
return; // Cannot subtract from all
}
for (const filter of other) {
this.delete(filter);
}
}
}
const SubscriptionActionSchema = StringEnum(["subscribe", "unsubscribe", "replace"]);
type SubscriptionAction = Static<typeof SubscriptionActionSchema>;
/**
* A subscription request sent by a subscriber, this updates what events the subscriber will be sent
* The permission for this request copies the permission from the event being subscribed to
@@ -151,7 +20,7 @@ export class SubscriptionRequest {
static dst = "controller" as const;
static permission(user: IUser, message: MessageRequest) {
if (typeof message.data === "object" && message.data !== null) {
const data = message.data as Static<typeof this.jsonSchema>;
const data = message.data as Static<typeof SubscriptionRequest.jsonSchema>;
const entry = Link._eventsByName.get(data[0]);
if (entry && entry.Event.permission) {
if (typeof entry.Event.permission === "string") {
@@ -165,86 +34,55 @@ export class SubscriptionRequest {
/** Indicates if updates have been sent */
static Response = JsonBoolean;
public filters: SubscriptionFilters;
public action: SubscriptionAction;
/** @deprecated Use SubscriptionFilters and SubscriptionAction instead */
constructor(
eventName: string,
subscribe: boolean,
lastRequestTimeMs: number | undefined,
filters?: string | string[],
);
constructor(
eventName: string,
action: SubscriptionAction,
lastRequestTimeMs: number | undefined,
filters?: SubscriptionFilters,
);
constructor(
public eventName: string,
action: boolean | SubscriptionAction,
public subscribe: boolean,
public lastRequestTimeMs: number = 0,
filters: undefined | string | string[] | SubscriptionFilters,
public filters: string | string[] | undefined = undefined,
) {
if (!Link._eventsByName.has(eventName)) {
throw new Error(`Unregistered Event class ${eventName}`);
}
if (filters instanceof SubscriptionFilters) { // Easier migration for plugins
this.filters = filters;
} else {
this.filters = SubscriptionFilters.fromShorthand(filters);
}
if (typeof action === "boolean") { // Easier migration for plugins
this.action = action ? "subscribe" : "unsubscribe";
} else {
this.action = action;
}
}
static jsonSchema = Type.Union([
Type.Tuple([
Type.String(),
SubscriptionActionSchema,
Type.Boolean(),
Type.Number(),
]),
Type.Tuple([
Type.String(),
SubscriptionActionSchema,
Type.Boolean(),
Type.Number(),
SubscriptionFilters.jsonSchema,
Type.Union([Type.String(), Type.Array(Type.String())]),
]),
]);
toJSON() {
if (!this.filters.isAll()) {
return [this.eventName, this.action, this.lastRequestTimeMs, this.filters.toJSON()];
if (this.filters !== undefined) {
return [this.eventName, this.subscribe, this.lastRequestTimeMs, this.filters];
}
return [this.eventName, this.action, this.lastRequestTimeMs];
return [this.eventName, this.subscribe, this.lastRequestTimeMs];
}
static fromJSON(json: Static<typeof this.jsonSchema>) {
const [eventName, action, lastRequestTimeMs, filters] = json;
return new this(
eventName,
action,
lastRequestTimeMs,
filters ? SubscriptionFilters.fromJSON(filters) : SubscriptionFilters.all()
);
static fromJSON(json: Static<typeof SubscriptionRequest.jsonSchema>): SubscriptionRequest {
const [eventName, subscribe, lastRequestTimeMs, filters] = json;
return new this(eventName, subscribe, lastRequestTimeMs, filters);
}
}
type Subscriber = {
link: Link,
dst: Address,
filters: SubscriptionFilters,
/** Optional filters this subscriber is interested in. Undefined means all events */
filters?: string[],
}
type EventData = {
subscriptionUpdate?: SubscriptionRequestHandler<unknown>,
subscriptions: Map<ReturnType<Address["addressIndex"]>, Subscriber>,
subscriptions: Array<Subscriber>,
};
/**
@@ -254,6 +92,13 @@ type EventData = {
export class SubscriptionController {
_events = new Map<string, EventData>();
private static normalizeFilters(value?: string | string[]): string[] | undefined {
if (value === undefined) {
return undefined;
}
return Array.isArray(value) ? value : [value];
}
/**
* Allow clients to subscribe to an event by telling the subscription controller to accept them
* Has an optional subscription update handler which is called when a client subscribes
@@ -271,7 +116,7 @@ export class SubscriptionController {
}
this._events.set(entry.name, {
subscriptionUpdate: subscriptionUpdate,
subscriptions: new Map(),
subscriptions: [],
});
}
@@ -279,7 +124,7 @@ export class SubscriptionController {
* Broadcast an event to all subscribers of that event
* @param event - Event to broadcast.
*/
broadcast<T extends Event<T>>(event: T & { filters?: string | string[] }, filters?: string | string[]) {
broadcast<T>(event: Event<T>, filter?: string | string[]) {
const entry = Link._eventsByClass.get(event.constructor);
if (!entry) {
throw new Error(`Unregistered Event class ${event.constructor.name}`);
@@ -288,17 +133,24 @@ export class SubscriptionController {
if (!eventData) {
throw new Error(`Event ${entry.name} is not a registered as subscribable`);
}
const toRemove: Subscriber[] = [];
const filtersToMatch = SubscriptionController.normalizeFilters(filter);
const broadcastFilters = SubscriptionFilters.fromShorthand(filters ?? event.filters);
for (const [addressIndex, subscriber] of eventData.subscriptions) {
for (const subscriber of eventData.subscriptions) {
if ((subscriber.link.connector as WebSocketBaseConnector).closing) {
eventData.subscriptions.delete(addressIndex);
toRemove.push(subscriber);
continue;
}
if (subscriber.filters.intersects(broadcastFilters)) {
if (
filtersToMatch === undefined
|| subscriber.filters === undefined
|| subscriber.filters.some(f => filtersToMatch.includes(f))
) {
subscriber.link.sendTo(subscriber.dst, event);
}
}
eventData.subscriptions = eventData.subscriptions.filter(subscriber => !toRemove.includes(subscriber));
}
/**
@@ -307,9 +159,10 @@ export class SubscriptionController {
* @param address - Address to stop sending events to.
*/
unsubscribeAddress(address: Address) {
const addressIndex = address.addressIndex();
for (const eventData of this._events.values()) {
eventData.subscriptions.delete(addressIndex);
for (let eventData of this._events.values()) {
eventData.subscriptions = eventData.subscriptions.filter(
subscriber => !subscriber.dst.addressedTo(address)
);
}
}
@@ -319,76 +172,69 @@ export class SubscriptionController {
* @param link - Link to stop sending events to.
*/
unsubscribeLink(link: Link) {
for (const eventData of this._events.values()) {
for (const [addressIndex, subscriber] of eventData.subscriptions) {
if (subscriber.link === link) {
eventData.subscriptions.delete(addressIndex);
}
}
for (let eventData of this._events.values()) {
eventData.subscriptions = eventData.subscriptions.filter(
subscriber => subscriber.link !== link
);
}
}
/**
* Handle incoming subscription requests on a link
* @param link - Link message was received on
* @param request - incoming request.
* @param event - incomming event.
* @param src - Source address of incomming request.
* @param dst - destination address of incomming request.
*/
async handleRequest(link: Link, request: SubscriptionRequest, src: Address, dst: Address) {
if (!Link._eventsByName.has(request.eventName)) {
throw new Error(`Event ${request.eventName} is not a registered event`);
async handleRequest(link: Link, event: SubscriptionRequest, src: Address, dst: Address) {
if (!Link._eventsByName.has(event.eventName)) {
throw new Error(`Event ${event.eventName} is not a registered event`);
}
const eventData = this._events.get(request.eventName);
const eventData = this._events.get(event.eventName);
if (!eventData) {
throw new Error(`Event ${request.eventName} is not a registered as subscribable`);
throw new Error(`Event ${event.eventName} is not a registered as subscribable`);
}
const addressIndex = src.addressIndex();
const subscriber = eventData.subscriptions.get(addressIndex);
switch (request.action) {
case "unsubscribe":
if (!subscriber) {
return false;
if (event.subscribe === false) {
// Unsubscribe logic
const subscriber = eventData.subscriptions.find(sub => sub.dst.addressedTo(src));
if (!subscriber) {
return false;
}
if (event.filters === undefined || subscriber.filters === undefined) {
// Remove entire subscription for this address
eventData.subscriptions = eventData.subscriptions.filter(sub => !sub.dst.addressedTo(src));
} else {
const filtersToRemove = Array.isArray(event.filters) ? event.filters : [event.filters];
subscriber.filters = subscriber.filters.filter(f => !filtersToRemove.includes(f));
if (subscriber.filters.length === 0) {
// No filters left, remove subscription
eventData.subscriptions = eventData.subscriptions.filter(sub => sub !== subscriber);
}
subscriber.filters.subtract(request.filters);
if (subscriber.filters.isEmpty()) {
eventData.subscriptions.delete(addressIndex);
}
break;
case "subscribe":
if (!subscriber) {
eventData.subscriptions.set(addressIndex, { link: link, dst: src, filters: request.filters });
} else {
subscriber.filters.union(request.filters);
}
break;
case "replace":
if (request.filters.isEmpty()) {
if (!subscriber) {
return false;
}
} else {
// Subscribe logic
let subscriber = eventData.subscriptions.find(sub => sub.dst.addressedTo(src));
const newFilters = SubscriptionController.normalizeFilters(event.filters);
if (!subscriber) {
eventData.subscriptions.push({ link: link, dst: src, filters: newFilters });
} else if (subscriber.filters === undefined || newFilters === undefined) {
// Either was already subscribed to all or new request wants all
subscriber.filters = undefined;
} else {
for (const f of newFilters) {
if (!subscriber.filters.includes(f)) {
subscriber.filters.push(f);
}
eventData.subscriptions.delete(addressIndex);
} else if (!subscriber) {
eventData.subscriptions.set(addressIndex, { link: link, dst: src, filters: request.filters });
} else {
subscriber.filters = request.filters;
}
break;
default:
throw new Error(`unreachable case: ${String(request.action)}`);
}
if (eventData.subscriptionUpdate) {
const eventReplay = await eventData.subscriptionUpdate(request, src, dst);
if (eventReplay) {
link.sendTo(src, eventReplay);
return true;
}
if (eventData.subscriptionUpdate) {
const eventReplay = await eventData.subscriptionUpdate(event, src, dst);
if (eventReplay) {
link.sendTo(src, eventReplay);
return true;
}
}
}
return false;
}
}
@@ -399,29 +245,36 @@ export interface SubscribableValue {
isDeleted: boolean,
}
export type EventSubscribable<T, V extends SubscribableValue> = Event<T> & Partial<{
updates: V[],
}>
/**
* Component for subscribing to and tracking updates of a remote resource
* Multiple handlers can be subscribed at the same time
*/
export class EventSubscriber<E, S = null> {
export class EventSubscriber<
T extends EventSubscribable<T, V>,
K extends string | number = NonNullable<T["updates"]>[number]["id"],
V extends SubscribableValue = NonNullable<T["updates"]>[number],
> {
/** The time at which an event was last received, is less than 0 when there have been no events */
lastUpdatedMs = -1;
lastResponseTimeMs = -1;
/** Values of the subscribed resource */
values = new Map<K, V>();
/** True if this subscriber is currently synced with the source */
synced = false;
/** Error value from last attempted subscription event */
error = null as Error | null;
/** True if this subscriber is expecting to receive updates from the source */
_syncing = false;
/** Repeat calls to getSnapshot will return the same readonly copy unless values has updated */
private _snapshot: readonly [S, boolean, Error | null] = [this.makeSnapshot(), false, null];
private _snapshotLastUpdatedMs = -1;
_snapshot: readonly [ReadonlyMap<K, Readonly<V>>, boolean] = [new Map<K, V>(), false];
_snapshotLastUpdatedMs = -1;
/** Callbacks will be called when an event is received or the synced state changes */
private _callbacks = new Array<EventSubscriberCallback<E>>();
/** Filters applied to the filtered handler, if empty the filtered handler will not be called */
private _filters = SubscriptionFilters.empty();
_callbacks = new Array<EventSubscriberCallback<T>>();
constructor(
protected Event: EventClass<E>,
private Event: EventClass<T>,
public control: Link,
protected filteredHandler?: EventSubscriberCallback<E>,
) {
control.handle(Event, this._handleEvent.bind(this));
// The events below exist only on websocket connectors, we can't early return because of test mocking
@@ -441,10 +294,12 @@ export class EventSubscriber<E, S = null> {
*/
handleConnectionEvent(event: "connect" | "drop" | "resume" | "close") {
if (event === "connect" || event === "resume") {
this._sendRequest("replace", this._callbacks.length > 0 ? SubscriptionFilters.all() : this._filters);
this._updateSubscription();
} else if (this.synced) {
this.synced = false;
this._notify(null);
for (let callback of this._callbacks) {
callback(null, false);
}
}
}
@@ -453,35 +308,33 @@ export class EventSubscriber<E, S = null> {
* @param event - event from subscribed resource
* @internal
*/
private async _handleEvent(event: E) {
const eventTimeMs = this.getLastUpdatedTimeMs(event);
if (eventTimeMs > this.lastUpdatedMs) {
this.lastUpdatedMs = eventTimeMs;
async _handleEvent(event: T) {
// We support the automatic maintaining of a map for events with the updates property
const updates = event.updates ?? [];
for (const value of updates) {
this.lastResponseTimeMs = Math.max(this.lastResponseTimeMs, value.updatedAtMs);
// Warn about updates which changes content but don't update updatedAtMs
const existing = this.values.get(value.id as K);
if (existing && existing.updatedAtMs === value.updatedAtMs && !isDeepStrictEqual(existing, value)) {
logger.warn(
`${this.Event.name} contains update for value with id ${value.id} that has an identical ` +
"updatedAtMs timestamp but differing content"
);
}
if (value.isDeleted) {
this.values.delete(value.id as K);
} else {
this.values.set(value.id as K, value);
}
}
// Event handling logic
if (this._syncing && !this.synced) {
this._snapshotLastUpdatedMs = -1;
this.synced = true;
}
this.synced = this._hasSubscriptions();
this.processEvent(event);
this._notify(event);
}
private _notify(event: E | null) {
for (const callback of this._callbacks) {
callback(event, this.synced);
}
if (this.filteredHandler && !this._filters.isEmpty()) {
this.filteredHandler(event, this.synced);
}
}
protected getLastUpdatedTimeMs(event: E) {
return Date.now();
}
protected processEvent(event: E) {
// Noop for EventSubscriber
}
protected makeSnapshot(): S {
return null as S;
}
/**
@@ -491,10 +344,10 @@ export class EventSubscriber<E, S = null> {
* synced property changes, in which case the event will be null.
* @returns function that will unsubscribe from notifications
*/
subscribe(handler: EventSubscriberCallback<E>) {
subscribe(handler: EventSubscriberCallback<T>) {
this._callbacks.push(handler);
if (this._callbacks.length === 1) {
this._sendRequest("subscribe", SubscriptionFilters.all());
this._updateSubscription();
}
return () => {
// During a page transition the components currently rendered
@@ -515,153 +368,52 @@ export class EventSubscriber<E, S = null> {
}
this._callbacks.splice(index, 1);
if (this._callbacks.length === 0) {
this._sendRequest("replace", this._filters);
this._updateSubscription();
}
});
};
}
/** Returns true if all filters are present */
hasFilters(filters: string | string[] | SubscriptionFilters) {
if (!(filters instanceof SubscriptionFilters)) {
filters = SubscriptionFilters.fromShorthand(filters);
}
return this._filters.extends(filters);
}
/** Add filters to the filtered handler */
addFilters(filters: string | string[] | SubscriptionFilters) {
if (!(filters instanceof SubscriptionFilters)) {
filters = SubscriptionFilters.fromShorthand(filters);
}
this._filters.union(filters);
this._sendRequest("subscribe", filters);
}
/** Remove filters from the filtered handler */
removeFilters(filters: string | string[] | SubscriptionFilters) {
if (!(filters instanceof SubscriptionFilters)) {
filters = SubscriptionFilters.fromShorthand(filters);
}
this._filters.subtract(filters);
this._sendRequest("unsubscribe", filters);
}
/** Clear all filters from the filtered handler, it will no longer be called */
clearFilters() {
this._filters = SubscriptionFilters.empty();
if (!this._hasSubscriptions()) {
this._sendRequest("unsubscribe", SubscriptionFilters.all());
}
}
/**
* Obtain a snapshot of the current state of the tracked resource
* @returns tuple of values map snapshot and synced property.
*/
getSnapshot(): readonly [S, boolean, Error | null] {
if (
this._snapshotLastUpdatedMs !== this.lastUpdatedMs
|| this._snapshot[1] !== this.synced
|| this._snapshot[2] !== this.error
) {
this._snapshotLastUpdatedMs = this.lastUpdatedMs;
this._snapshot = [this.makeSnapshot(), this.synced, this.error];
getSnapshot() {
if (this._snapshotLastUpdatedMs !== this.lastResponseTimeMs) {
this._snapshotLastUpdatedMs = this.lastResponseTimeMs;
this._snapshot = [new Map(this.values), this.synced];
}
return this._snapshot;
}
/** Returns true if there are any active subscription callbacks */
private _hasSubscriptions() {
return this._callbacks.length > 0 || !this._filters.isEmpty();
return this._snapshot!;
}
/**
* Update the subscription with the controller based on current handler counts
*/
private async _sendRequest(action: SubscriptionAction, filters: SubscriptionFilters) {
async _updateSubscription() {
if (!this.control.connector.valid) {
return;
}
const entry = Link._eventsByClass.get(this.Event)!;
try {
this._syncing = this._callbacks.length > 0;
const updatesSent = await this.control.sendTo("controller", new SubscriptionRequest(
entry.name, action, this.lastUpdatedMs, filters
entry.name,
this._syncing,
this.lastResponseTimeMs
));
this.error = null;
if (!updatesSent) {
this.synced = this._hasSubscriptions();
this._notify(null);
this.synced = this._syncing;
for (const callback of this._callbacks) {
callback(null, this.synced);
}
}
} catch (err: any) {
if (!(err instanceof RequestError) || err.code !== "SessionLost") {
logger.error(`Unexpected error updating ${entry.name} subscription:\n${err.stack}`);
}
if (err instanceof Error) {
this.error = err;
}
}
}
}
export class ValueSubscriber<
E extends { value: V },
V extends SubscribableValue = E["value"],
> extends EventSubscriber<E, Readonly<V> | null> {
/** Current value of the subscribed resource */
value: V | null = null;
protected override getLastUpdatedTimeMs(event: E) {
return event.value.updatedAtMs;
}
protected override processEvent(event: E) {
this.value = event.value;
}
protected override makeSnapshot() {
return this.value;
}
}
export class MapSubscriber<
E extends { updates: V[] },
K extends string | number = E["updates"][number]["id"],
V extends SubscribableValue = E["updates"][number],
> extends EventSubscriber<E, ReadonlyMap<K, Readonly<V>>> {
/** Current value of the subscribed resource */
values = new Map<K, V>();
protected override getLastUpdatedTimeMs(event: E) {
let maxTime = this.lastUpdatedMs;
for (const update of event.updates) {
if (update.updatedAtMs > maxTime) {
maxTime = update.updatedAtMs;
}
}
return maxTime;
}
protected override processEvent(event: E) {
for (const value of event.updates) {
// Warn about updates which changes content but don't update updatedAtMs
const existing = this.values.get(value.id as K);
if (existing && existing.updatedAtMs === value.updatedAtMs && !isDeepStrictEqual(existing, value)) {
logger.warn(
`${this.Event.name} contains update for value with id ${value.id} that has an identical ` +
"updatedAtMs timestamp but differing content"
logger.error(
`Unexpected error updating ${entry.name} subscription:\n${err.stack}`
);
}
if (value.isDeleted) {
this.values.delete(value.id as K);
} else {
this.values.set(value.id as K, value);
}
}
}
protected override makeSnapshot() {
return new Map(this.values);
}
}
+25 -9
View File
@@ -1,4 +1,4 @@
import fs from "node:fs/promises";
import fs from "fs-extra";
import os from "os";
import util from "util";
import { Gauge } from "./prometheus";
@@ -92,7 +92,14 @@ export const systemDiskCapacityBytes = new Gauge(
"Size of the filesystem of the current working directory Node.js runs on",
{
callback: async function(collector) {
const stats = await fs.statfs(".");
// statfs was added in Node.js v18.15.0 and may not be present.
// TODO: remove this check once minimum supported Node.js >= v18.15.0.
if (!fs.statfs) {
collector.set(0);
return;
}
const statFsAsync = util.promisify(fs.statfs);
const stats = await statFsAsync(".");
collector.set(stats.blocks * stats.bsize);
},
},
@@ -103,7 +110,13 @@ export const systemDiskAvailableBytes = new Gauge(
"Available space on the filesystem of the current working directory Node.js runs on",
{
callback: async function(collector) {
const stats = await fs.statfs(".");
// TODO: remove this check once minimum supported Node.js >= v18.15.0.
if (!fs.statfs) {
collector.set(0);
return;
}
const statFsAsync = util.promisify(fs.statfs);
const stats = await statFsAsync(".");
collector.set(stats.bavail * stats.bsize);
},
},
@@ -132,14 +145,17 @@ export async function gatherSystemInfo(
const currentIdleCpuMs = cpus.map(({ times }) => times.idle);
const deltaTotalCpuMs = minZip(previousTotalCpuMs, currentTotalCpuMs).map(([prev, curr]) => curr - prev);
const deltaIdleCpuMs = minZip(previousIdleCpuMs, currentIdleCpuMs).map(([prev, curr]) => curr - prev);
const cpuUsage = minZip(deltaTotalCpuMs, deltaIdleCpuMs).map(
([total, idle]) => (total <= 0 ? 0 : (total - idle) / total)
);
const cpuUsage = minZip(deltaTotalCpuMs, deltaIdleCpuMs).map(([total, idle]) => (total - idle) / total);
previousTotalCpuMs = currentTotalCpuMs;
previousIdleCpuMs = currentIdleCpuMs;
const stats = await fs.statfs(".");
let diskCapacity = stats.blocks * stats.bsize;
let diskAvailable = stats.bavail * stats.bsize;
let diskCapacity = 0;
let diskAvailable = 0;
if (fs.statfs) { // TODO: remove this check once minimum supported Node.js >= v18.15.0.
const statFsAsync = util.promisify(fs.statfs);
const stats = await statFsAsync(".");
diskCapacity = stats.blocks * stats.bsize;
diskAvailable = stats.bavail * stats.bsize;
}
const now = Date.now();
return new SystemInfo(
-1
View File
@@ -10,7 +10,6 @@
"./src/check_singleton_import.ts",
"./src/datastore.ts",
"./src/errors.ts",
"./src/export.ts",
"./src/factorio/exchange_string.ts",
"./src/helpers.ts",
"./src/hash.ts",
-10
View File
@@ -3,16 +3,6 @@ declare module "*.png" {
export default value;
}
declare module "*.svg" {
const value: string;
export default value;
}
declare module "*.md" {
const value: string;
export default value;
}
declare const webRoot: string;
declare const staticRoot: string;
+2 -3
View File
@@ -3,7 +3,7 @@
"description": "Clusterio web interface implementation",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.27",
"version": "2.0.0-alpha.23",
"browser": "src/index.ts",
"main": "dist/browser/src/index.js",
"scripts": {
@@ -19,7 +19,7 @@
},
"dependencies": {
"@ant-design/icons": "^5.6.1",
"@clusterio/lib": "workspace:^",
"@clusterio/lib": "workspace:*",
"@sinclair/typebox": "^0.30.4",
"@swc/core": "^1.13.3",
"antd": "^5.24.2",
@@ -37,7 +37,6 @@
"rc-field-form": "^1.37.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-markdown": "catalog:",
"react-router-dom": "^6.29.0",
"stream-browserify": "^3.0.0",
"style-loader": "^3.3.3",
+2 -2
View File
@@ -9,7 +9,8 @@ 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 InputTargetVersion from "./components/InputTargetVersion";
import InputPartialVersion from "./components/InputPartialVersion";
import { Control, ControlConnector } from "./util/websocket";
const { ConsoleTransport, WebConsoleFormat, logger } = lib;
@@ -110,7 +111,6 @@ async function loadPlugins(pluginInfos: lib.PluginWebpackEnvInfo[], control: Con
function inputComponentsFromPlugins(plugins: Map<string, BaseWebPlugin>) {
const inputComponents: Record<string, InputComponent> = {
"full_version": InputFullVersion,
"partial_version": InputPartialVersion,
"target_version": InputTargetVersion,
"mod_pack": InputModPack,
@@ -1,189 +0,0 @@
import React, { useContext, useEffect, useMemo, useState } from "react";
import { Modal, Button, Space, Typography, message, Row, Col, Card } from "antd";
import { ReloadOutlined, CopyOutlined, InfoCircleOutlined } from "@ant-design/icons";
import { PluginWebApi } from "@clusterio/lib";
import webUiPackage from "../../package.json";
import DiscordLogo from "../images/discord.svg";
import GitHubLogo from "../images/github.svg";
import ControlContext from "./ControlContext";
import { useAccount } from "../model/account";
import { useHosts } from "../model/host";
import { useInstances } from "../model/instance";
import { useUsers } from "../model/user";
const { Text } = Typography;
function formatOS(os?: string) {
switch (os) {
case "win32": return "Windows";
case "darwin": return "macOS";
case "linux": return "Linux";
default: return os;
}
}
export default function AboutModal({ open, onClose, onOpenChangelog }: any) {
const control = useContext(ControlContext);
const account = useAccount();
const [hosts] = useHosts();
const [instances] = useInstances();
const [users] = useUsers();
const [pluginList, setPluginList] = useState<PluginWebApi[]>([]);
useEffect(() => {
(async () => {
let response = await fetch(`${webRoot}api/plugins`);
if (response.ok) {
setPluginList(await response.json());
}
})();
}, [webRoot]);
const browserInfo = useMemo(() => ({
userAgent: navigator.userAgent,
platform: navigator.platform,
}), []);
const debugText = useMemo(() => {
const permissions = new Set(
account.roles.flatMap(role => role.permissions)
);
const date = new Date();
const lines: string[] = [
"Clusterio Debug Info",
`- Timestamp: ${date.toISOString()}`,
`- Local Time: ${date.toString()}`,
"",
"Build",
`- Version: ${webUiPackage.version}`,
`- Build Date: ${process.env.BUILD_DATE}`,
`- Commit Hash: ${process.env.COMMIT_HASH}`,
`- Environment: ${process.env.BUILD_ENV}`,
`- System: ${process.env.BUILD_OS}`,
"",
"Runtime",
`- Platform: ${browserInfo.platform}`,
`- User Agent: ${browserInfo.userAgent}`,
`- Screen: ${window.screen.width}x${window.screen.height}`,
`- Viewport: ${window.innerWidth}x${window.innerHeight}`,
"",
"Cluster",
`- Url: ${new URL(webRoot, window.location.origin)}`,
`- Hosts: ${hosts.size}`,
`- Instances: ${instances.size}`,
`- Plugins: ${pluginList.length}`,
`- Users: ${users.size}`,
"",
"Account",
`- Name: ${account.name}`,
`- Roles: ${account.roles.map(r => r.name).sort().join(", ") || "None"}`,
`- Permissions: ${permissions.size}`,
"",
"Permission List",
...[...permissions].sort().map(p => `- ${p}`),
"",
];
lines.push("Plugin List");
for (let plugin of pluginList) {
lines.push(`- ${plugin.name}`);
lines.push(` Version: ${plugin.version ?? "unknown"}`);
lines.push(` Loaded: ${plugin.loaded ? "yes" : "no"}`);
lines.push(` Controller: ${plugin.enabled ? "enabled" : "disabled"}`);
lines.push(` Web: ${control.plugins.get(plugin.name) ? "enabled" : "disabled"}`);
if (plugin.npmPackage) {
lines.push(` Package: ${plugin.npmPackage}`);
}
}
return lines.join("\n");
}, [account, hosts, instances, users, pluginList, browserInfo]);
async function copyDebug() {
await navigator.clipboard.writeText(debugText);
message.success("Copied debug info");
}
function forceRefresh() {
window.location.reload();
}
return <Modal open={open} onCancel={onClose} footer={null} title="About Clusterio" width={800}>
<Space direction="vertical" style={{ width: "100%" }} size="large">
<Row gutter={[24, 16]}>
<Col xs={24} md={12}>
<Card title="Build Info">
<Space direction="vertical">
<Text>Version: {`${webUiPackage.version} (${process.env.COMMIT_HASH})`}</Text>
<Text>Date: {new Date(process.env.BUILD_DATE ?? "").toLocaleString()}</Text>
<Text>Environment: {process.env.BUILD_ENV}</Text>
<Text>System: {formatOS(process.env.BUILD_OS)}</Text>
</Space>
</Card>
</Col>
<Col xs={24} md={12}>
<Card title="Cluster Info">
<Space direction="vertical">
<Text>Hosts: {hosts.size}</Text>
<Text>Instances: {instances.size}</Text>
<Text>Plugins: {pluginList.length}</Text>
<Text>Users: {users.size}</Text>
</Space>
</Card>
</Col>
</Row>
<Row>
<Col span={24}>
<Space style={{ width: "100%", justifyContent: "space-between" }} wrap>
<Space wrap>
<Button icon={<InfoCircleOutlined />} onClick={onOpenChangelog}>
Changelog
</Button>
<Button
target="_blank"
href="https://github.com/clusterio/clusterio/issues"
icon={<span style={{ display: "flex", alignItems: "center" }}>
<img src={GitHubLogo} style={{ width: 16 }} />
</span>}
>
GitHub Issues
</Button>
<Button
target="_blank"
href="https://discord.gg/mzAsgnm"
icon={<span style={{ display: "flex", alignItems: "center" }}>
<img src={DiscordLogo} style={{ width: 16}} />
</span>}
>
Support Server
</Button>
</Space>
<Space wrap>
<Button icon={<ReloadOutlined />} onClick={forceRefresh}>
Reload
</Button>
<Button icon={<CopyOutlined />} onClick={copyDebug} type="primary">
Copy Debug
</Button>
</Space>
</Space>
</Col>
</Row>
</Space>
</Modal>;
}
@@ -67,7 +67,7 @@ export default function AssignInstanceModal(props: AssignInstanceModalProps) {
confirmLoading={applying}
onOk={handleAssign}
onCancel={handleCancel}
destroyOnHidden
destroyOnClose
>
<Paragraph style={{ maxWidth: "30em" }}>
Select a Host to assign this instance to. Assignment
@@ -9,7 +9,6 @@ import * as lib from "@clusterio/lib";
import ControlContext from "./ControlContext";
import { InputComponent, InputComponentProps } from "../BaseWebPlugin";
import type { Control } from "../util/websocket";
import notify from "../util/notify";
const { Title } = Typography;
@@ -68,7 +67,8 @@ function renderInput(inputComponents: Record<string, InputComponent>, def: lib.F
type BaseConfigTreeProps = {
ConfigClass: typeof lib.ControllerConfig | typeof lib.HostConfig | typeof lib.InstanceConfig;
retrieveConfig: () => Promise<lib.ConfigSchema>;
setConfig: (fields: Record<string, string | Record<string, string>>) => Promise<void>;
setField: (field:string, value:any) => Promise<void>;
setProp: (field:string, prop:string, value:any) => Promise<void>;
id?: number;
onApply?: () => void;
available?: boolean;
@@ -157,33 +157,32 @@ export default function BaseConfigTree(props: BaseConfigTreeProps) {
loading={applying}
onClick={async () => {
setApplying(true);
const fields: Record<string, string | Record<string, string>> = {};
for (const field of changedFields) {
const value = form.getFieldValue(field);
for (let field of changedFields) {
let value = form.getFieldValue(field);
let request;
if (propsMap.has(field)) {
const [fieldName, prop] = propsMap.get(field)!;
const fieldValue = (fields[fieldName] ?? {}) as Record<string, string>;
fields[fieldName] = fieldValue;
try {
fieldValue[prop] = value ? JSON.parse(value) : undefined;
} catch (err: any) {
setErrorFields(errors => {
const newErrors = new Map(errors);
newErrors.set(fieldName, err.message);
return newErrors;
});
}
let [fieldName, prop] = propsMap.get(field);
request = props.setProp(fieldName, prop, value);
} else {
fields[field] = value === null ? "" : String(value);
request = props.setField(field, value === null ? "" : String(value));
}
}
try {
await props.setConfig(fields);
setChangedFields(new Set());
} catch (err: any) {
notify(err.message, "error");
try {
await request;
setChangedFields(changed => {
let newChanged = new Set(changed);
newChanged.delete(field);
return newChanged;
});
} catch (err: any) {
setErrorFields(errors => {
let newErrors = new Map(errors);
newErrors.set(field, err.message);
return newErrors;
});
}
}
await updateConfig();
@@ -255,7 +254,7 @@ function computeTreeData(
};
let treeData = [];
const propsMap = new Map<string, [fieldName: string, prop: string]>();
let propsMap = new Map();
const groups = new Map<string, { [name: string]: lib.FieldDefinition }>();
for (let [fieldName, def] of Object.entries(ConfigClass.fieldDefinitions)) {
const [groupName, field] = lib.splitOn(".", fieldName);
@@ -313,37 +312,17 @@ function computeTreeData(
{`"${prop}"`}
{restart && restartTip}
</>}
dependencies={def.dependsOn}
rules={[({getFieldValue}) => ({ validator: async (_, fieldValue) => {
rules={[{ validator: (_, fieldValue) => {
if (!fieldValue.length) {
throw new Error("Will be removed");
return Promise.reject(new Error("Will be removed"));
}
try {
fieldValue = JSON.parse(fieldValue);
JSON.parse(fieldValue);
} catch (err) {
throw new Error("Must be valid json");
return Promise.reject(new Error("Must be valid json"));
}
if (!def.validator) { return; }
config.stageProp(fieldName, prop, fieldValue);
for (const dependsOn of def.dependsOn ?? []) {
const dependsOnDef = ConfigClass.fieldDefinitions[dependsOn as any] as typeof def;
try {
if (propsMap.has(dependsOn)) {
const [name, otherProp] = propsMap.get(dependsOn)!;
config.stageProp(name, otherProp, JSON.parse(getFieldValue(dependsOn)));
} else if (dependsOnDef.type !== "object") {
config.stage(dependsOn, getFieldValue(dependsOn));
}
} catch (err) {
throw new Error(`Failed to fetch field: ${dependsOn}`);
}
}
def.validator(fieldValue, config.staging);
}})]}
return Promise.resolve();
}}]}
>
<Input className="json-input" disabled={!canWrite} />
</Form.Item>,
@@ -411,30 +390,7 @@ function computeTreeData(
validateStatus={errorFields.has(fieldName) ? "error" : undefined}
help={errorFields.get(fieldName)}
tooltip={def.description}
dependencies={def.dependsOn}
valuePropName={def.type === "boolean" ? "checked" : "value"}
rules={[({getFieldValue}) => ({
validator: async (_, fieldValue) => {
if (!def.validator) { return; }
config.stage(fieldName, fieldValue);
for (const dependsOn of def.dependsOn ?? []) {
const dependsOnDef = ConfigClass.fieldDefinitions[dependsOn as any] as typeof def;
try {
if (propsMap.has(dependsOn)) {
const [name, prop] = propsMap.get(dependsOn)!;
config.stageProp(name, prop, JSON.parse(getFieldValue(dependsOn)));
} else if (dependsOnDef.type !== "object") {
config.stage(dependsOn, getFieldValue(dependsOn));
}
} catch (err) {
throw new Error(`Failed to fetch field: ${dependsOn}`);
}
}
def.validator(fieldValue, config.staging);
},
})]}
>
{renderInput(control.inputComponents, def, !canWrite)}
</Form.Item>;
@@ -1,26 +0,0 @@
import React from "react";
import { Modal } from "antd";
import ReactMarkdown from "react-markdown";
import changelog from "../../../../CHANGELOG.md";
const cleanedChangelog = changelog
.replace(/<!--[\s\S]*?-->/g, "") // remove comments
.replace(/^#\s+Changelog\s*\n?/i, ""); // remove top-level title
/**
* Displays a modal element which contains the change log
*/
export default function ChangeLogModal({ open, onClose }: any) {
return <Modal
title="Changelog"
open={open}
onCancel={onClose}
footer={null}
width={800}
>
<ReactMarkdown>
{cleanedChangelog}
</ReactMarkdown>
</Modal>;
}
@@ -13,13 +13,30 @@ export default function ControllerConfigTree() {
return await control.send(new lib.ControllerConfigGetRequest());
}
async function setConfig(fields: Record<string, string | Record<string, string>>) {
await control.send(new lib.ControllerConfigSetRequest(fields));
async function setField(field: string, value: any) {
await control.send(new lib.ControllerConfigSetFieldRequest(
field,
String(value),
));
}
async function setProp(field: string, prop: string, value: any) {
if (value) {
try {
value = JSON.parse(value);
} catch (err) {
return;
}
} else {
value = undefined;
}
await control.send(new lib.ControllerConfigSetPropRequest(field, prop, value));
}
return <BaseConfigTree
ConfigClass={lib.ControllerConfig}
retrieveConfig={retrieveConfig}
setConfig={setConfig}
setField={setField}
setProp={setProp}
/>;
}
@@ -106,7 +106,7 @@ export default function CreateSaveModal(props: CreateSaveModalProps) {
confirmLoading={creatingSave}
onOk={createSave}
onCancel={handleCancel}
destroyOnHidden
destroyOnClose
>
<Form form={form} layout="vertical">
<Form.Item name="saveName" label="Save Name">
+2 -5
View File
@@ -2,9 +2,6 @@ import React from "react";
import { Button, Card, Flex, Image, Typography } from "antd";
import example from "../images/console-error-edge.png";
import DiscordLogo from "../images/discord.svg";
import GitHubLogo from "../images/github.svg";
import PageLayout from "./PageLayout";
import type { ErrorProps } from "./App";
@@ -41,12 +38,12 @@ export default function ErrorPage(props: ErrorProps & { throw?: boolean }) {
<SupportLinkCard
name="Github Issues"
href="https://github.com/clusterio/clusterio/issues"
image={GitHubLogo}
image="https://github.githubassets.com/favicons/favicon-dark.svg"
/>
<SupportLinkCard
name="Support Server"
href="https://discord.gg/mzAsgnm"
image={DiscordLogo}
image="https://cdn.prod.website-files.com/6257adef93867e50d84d30e2/66e3d7f4ef6498ac018f2c55_Symbol.svg"
/>
</Flex>
<br/>
@@ -1,11 +0,0 @@
import { PrototypeMetadataEntry } from "../store/export_prototype_metadata_store";
export default function FactorioIcon(props: { modPackId: number | undefined, prototype?: PrototypeMetadataEntry }) {
return <span
className="factorio-icon"
data-mod-pack={props.modPackId}
data-type={props.prototype?.base_type}
data-name={props.prototype?.name}
data-unknown={props.prototype ? undefined : "true"}
/>;
}
@@ -13,14 +13,28 @@ export default function HostConfigTree(props: { id: number, available: boolean }
return await control.sendTo({ hostId: props.id }, new lib.HostConfigGetRequest());
}
async function setConfig(fields: Record<string, string | Record<string, string>>) {
await control.sendTo({ hostId: props.id }, new lib.HostConfigSetRequest(fields));
async function setField(field: string, value: any) {
await control.sendTo({ hostId: props.id }, new lib.HostConfigSetFieldRequest(field, value));
}
async function setProp(field: string, prop: string, value: any) {
if (value) {
try {
value = JSON.parse(value);
} catch (err) {
return;
}
} else {
value = undefined;
}
await control.sendTo({ hostId: props.id }, new lib.HostConfigSetPropRequest(field, prop, value));
}
return <BaseConfigTree
ConfigClass={lib.HostConfig}
retrieveConfig={retrieveConfig}
setConfig={setConfig}
setField={setField}
setProp={setProp}
id={props.id}
available={props.available}
/>;
+9 -29
View File
@@ -1,4 +1,5 @@
import React, { useContext, useRef, useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { Button, Flex, Form, InputNumber, Modal, Space, Switch, Table, Tooltip, Typography } from "antd";
import { ExclamationCircleOutlined, CopyOutlined } from "@ant-design/icons";
@@ -19,10 +20,6 @@ import {
import { useHosts } from "../model/host";
import { useSystems } from "../model/system";
import notify, { notifyErrorHandler } from "../util/notify";
import useTableQueryState from "../util/useTableQueryState";
import useColumnSearch from "../util/useColumnSearch";
import useRowNavigation from "../util/useRowNavigation";
import Link from "./Link";
const strcmp = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }).compare;
@@ -202,16 +199,12 @@ function CopyButton({ text, message }: { text:string, message:string }) {
export default function HostsPage() {
let account = useAccount();
let navigate = useNavigate();
let [hosts] = useHosts();
const [systems] = useSystems();
const [showRatios, setShowRatios] = useState(true);
const [showNumbers, setShowNumbers] = useState(false);
const [showCpuModel, setShowCpuModel] = useState(false);
const tableState = useTableQueryState<lib.HostDetails>({
namespace: "host", defaultSortKey: "name",
});
const nameSearch = useColumnSearch<lib.HostDetails>(tableState, "name", host => host.name, "Search hosts");
const rowNav = useRowNavigation();
return <PageLayout nav={[{ name: "Hosts" }]}>
<PageHeader
@@ -224,20 +217,13 @@ export default function HostsPage() {
{
title: "Name",
dataIndex: "name",
defaultSortOrder: "ascend",
sorter: (a, b) => strcmp(a.name, b.name),
sortOrder: tableState.sortOrder("name"),
filteredValue: tableState.filteredValue("name"),
className: "table-link-cell",
render: (_, host) => <Link to={`/hosts/${host.id}/view`} style={{ color: "inherit" }}>
{host.name}
</Link>,
...nameSearch,
},
{
title: "CPU Model",
dataIndex: "cpuModel",
sorter: (a, b) => strcmp(systems.get(a.id)?.cpuModel ?? "", systems.get(b.id)?.cpuModel ?? ""),
sortOrder: tableState.sortOrder("cpuModel"),
render: (_, host) => systems.get(host.id)?.cpuModel,
hidden: !showCpuModel,
},
@@ -245,7 +231,6 @@ export default function HostsPage() {
title: "CPU%",
key: "cpuPct",
sorter: (a, b) => (systems.get(a.id)?.cpuRatio ?? 0) - (systems.get(b.id)?.cpuRatio ?? 0),
sortOrder: tableState.sortOrder("cpuPct"),
render: (_, host) => <MetricCpuRatio system={systems.get(host.id)} />,
hidden: !showRatios,
},
@@ -255,7 +240,6 @@ export default function HostsPage() {
sorter: (a, b) => (
(systems.get(a.id)?.cpuUsed ?? 0) - (systems.get(b.id)?.cpuUsed ?? 0)
),
sortOrder: tableState.sortOrder("cores"),
render: (_, host) => <MetricCpuUsed system={systems.get(host.id)} />,
hidden: !showNumbers,
},
@@ -265,7 +249,6 @@ export default function HostsPage() {
sorter: (a, b) => (
(systems.get(a.id)?.memoryRatio ?? 0) - (systems.get(b.id)?.memoryRatio ?? 0)
),
sortOrder: tableState.sortOrder("memPct"),
render: (_, host) => <MetricMemoryRatio system={systems.get(host.id)} />,
hidden: !showRatios,
},
@@ -275,7 +258,6 @@ export default function HostsPage() {
sorter: (a, b) => (
(systems.get(a.id)?.memoryUsed ?? 0) - (systems.get(b.id)?.memoryUsed ?? 0)
),
sortOrder: tableState.sortOrder("memory"),
render: (_, host) => <MetricMemoryUsed system={systems.get(host.id)} />,
hidden: !showNumbers,
},
@@ -283,7 +265,6 @@ export default function HostsPage() {
title: "Disk%",
key: "diskPct",
sorter: (a, b) => (systems.get(a.id)?.diskAvailable ?? 0) - (systems.get(b.id)?.diskAvailable ?? 0),
sortOrder: tableState.sortOrder("diskPct"),
render: (_, host) => <MetricDiskRatio system={systems.get(host.id)} />,
hidden: !showRatios,
},
@@ -293,7 +274,6 @@ export default function HostsPage() {
sorter: (a, b) => (
(systems.get(a.id)?.diskUsed ?? 0) - (systems.get(b.id)?.diskUsed ?? 0)
),
sortOrder: tableState.sortOrder("disk"),
render: (_, host) => <MetricDiskUsed system={systems.get(host.id)} />,
hidden: !showNumbers,
},
@@ -307,13 +287,11 @@ export default function HostsPage() {
</Tooltip>}
</Space>,
sorter: (a, b) => strcmp(a.version, b.version),
sortOrder: tableState.sortOrder("version"),
},
{
title: "Public address",
dataIndex: "publicAddress",
sorter: (a, b) => strcmp(a.publicAddress??"", b.publicAddress??""),
sortOrder: tableState.sortOrder("publicAddress"),
},
{
title: "Connected",
@@ -323,14 +301,16 @@ export default function HostsPage() {
<RestartRequired system={systems.get(host.id)}/>
</>,
sorter: (a, b) => Number(a.connected) - Number(b.connected),
sortOrder: tableState.sortOrder("connected"),
},
]}
dataSource={[...hosts.values()]}
rowKey={host => host.id}
pagination={tableState.pagination}
onChange={tableState.onChange}
onRow={record => rowNav(`/hosts/${record.id}/view`)}
pagination={false}
onRow={(record, rowIndex) => ({
onClick: event => {
navigate(`/hosts/${record.id}/view`);
},
})}
/>
<Flex wrap="wrap" style={{ margin: "16px 0 0 0" }} gap="small">
<label style={{ width: "14em", display: "flex", justifyContent: "space-between", marginRight: 16 }}>
@@ -1,56 +1,109 @@
import { useMemo, useState } from "react";
import React, { useMemo, useState } from "react";
import { Card, Input, Space, Table, Tag, Typography } from "antd";
import { useExportPrototypeMetadata } from "../model/export_prototype_metadata";
import {
useItemMetadata,
useRecipeMetadata,
useSignalMetadata,
useTechnologyMetadata,
usePlanetMetadata,
useQualityMetadata,
useEntityMetadata,
} from "../model/item_metadata";
import { useExportManifest } from "../model/export_manifest";
import PageHeader from "./PageHeader";
import PageLayout from "./PageLayout";
import FactorioIcon from "./FactorioIcon";
import { useParams } from "react-router-dom";
import { useModPack } from "../model/mod_pack";
import { PrototypeMetadataEntry } from "../store/export_prototype_metadata_store";
const { Text } = Typography;
function filterPrototypes(
prototypes: Map<string, Map<string, PrototypeMetadataEntry>> | undefined,
filter: string
) {
const query = filter.trim().toLowerCase();
return new Map(
[...prototypes ?? []].map(([baseType, entries]) => [
baseType,
new Map([...entries.entries()].filter(([name, entry]) => (
`${entry.base_type}.${name}`.includes(query)
|| `${entry.type}.${name}`.includes(query)
))),
]),
);
}
type IconEntry = {
key: string;
category: string;
name: string;
cssClass: string;
size: number;
path?: string;
};
function countPrototypes(
prototypes: Map<string, Map<string, PrototypeMetadataEntry>> | undefined,
) {
return prototypes ? [...prototypes.values()].reduce((a, e) => a + e.size, 0) : 0;
const CATEGORIES = [
"item", "recipe", "signal", "technology", "planet", "quality", "entity",
] as const;
type Category = typeof CATEGORIES[number];
const CATEGORY_LABELS: Record<Category, string> = {
item: "Item",
recipe: "Recipe",
signal: "Signal",
technology: "Technology",
planet: "Planet",
quality: "Quality",
entity: "Entity",
};
function useAllIconEntries() {
const item = useItemMetadata();
const recipe = useRecipeMetadata();
const signal = useSignalMetadata();
const technology = useTechnologyMetadata();
const planet = usePlanetMetadata();
const quality = useQualityMetadata();
const entity = useEntityMetadata();
const byCategory: Record<Category, Map<string, { size: number; path?: string }>> = {
item, recipe, signal, technology, planet, quality, entity,
};
return useMemo(() => {
const entries: IconEntry[] = [];
for (const category of CATEGORIES) {
const names = [...byCategory[category].keys()].sort();
for (const name of names) {
const meta = byCategory[category].get(name)!;
entries.push({
key: `${category}:${name}`,
category,
name,
cssClass: `${category}-${CSS.escape(name)}`,
size: meta.size,
path: meta.path,
});
}
}
return entries;
}, [item, recipe, signal, technology, planet, quality, entity]);
}
export default function IconReferencePage() {
let params = useParams();
let modPackId = Number(params.id);
const [filter, setFilter] = useState("");
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
const [modPack] = useModPack(modPackId);
const manifest = modPack?.exportManifest;
const prototypes = useExportPrototypeMetadata(modPack);
const filtered = useMemo(() => filterPrototypes(prototypes, filter), [prototypes, filter]);
const groups = useMemo(() => (
[...filtered]
.map(([baseType, entries]) => ({ baseType, entries: [...entries.values()] }))
.filter(group => group.entries.length)
), [filtered]);
const allEntries = useAllIconEntries();
const visibleCount = countPrototypes(filtered);
const totalCount = countPrototypes(prototypes);
const filtered = useMemo(() => {
const q = filter.trim().toLowerCase();
if (!q) {
return allEntries;
}
return allEntries.filter(e => e.name.toLowerCase().includes(q) || e.cssClass.toLowerCase().includes(q)
);
}, [allEntries, filter]);
const groups = useMemo(() => {
const map = new Map<string, IconEntry[]>();
for (const cat of CATEGORIES) {
map.set(cat, []);
}
for (const entry of filtered) {
map.get(entry.category)!.push(entry);
}
return CATEGORIES.map(cat => ({ category: cat, entries: map.get(cat)! }))
.filter(g => g.entries.length > 0);
}, [filtered]);
const visibleCount = filtered.length;
const totalCount = allEntries.length;
const autoExpand = visibleCount <= 20;
const isExpanded = (category: string) => autoExpand || expandedGroups.has(category);
@@ -70,10 +123,11 @@ export default function IconReferencePage() {
const columns = [
{
title: "Icon",
dataIndex: "cssClass",
key: "icon",
width: 48,
render: (prototype: PrototypeMetadataEntry) => (
<FactorioIcon modPackId={modPackId} prototype={prototype}/>
render: (_: string, record: IconEntry) => (
<div className={record.cssClass} style={{ imageRendering: "pixelated" }} />
),
},
{
@@ -84,9 +138,19 @@ export default function IconReferencePage() {
<Text style={{ fontSize: 12 }}>{name}</Text>
),
},
{
title: "Usage",
dataIndex: "cssClass",
key: "usage",
render: (cssClass: string) => (
<Text code style={{ fontSize: 11, color: "#90ee90" }}>
{`<div class="${cssClass}" />`}
</Text>
),
},
{
title: "Path",
dataIndex: ["icon", "path"],
dataIndex: "path",
key: "path",
render: (p: string | undefined) => (p
? <Text type="secondary" style={{ fontSize: 11 }}>{p}</Text>
@@ -94,17 +158,13 @@ export default function IconReferencePage() {
},
];
let nav = [
{ name: "Mods", path: "/mods" },
{ name: "Mod Packs" },
{ name: modPack?.name ?? String(modPackId), path: `/mods/mod-packs/${modPackId}/view` },
{ name: "Icon Reference" },
];
const manifest = useExportManifest();
return (
<PageLayout nav={nav}>
<PageLayout nav={[{ name: "Icon Reference" }]}>
<PageHeader title="Icon Reference" />
<div style={{ width: "80%", margin: "0 auto" }}>
{!modPack?.exportManifest && (
{!manifest && (
<Card>
<Text type="secondary">
No export data available. Run export-data on an instance to generate icons.
@@ -120,7 +180,7 @@ export default function IconReferencePage() {
</div>
)}
<div>
<Text type="secondary">Prototypes: </Text>
<Text type="secondary">Icons: </Text>
<Text>{totalCount}</Text>
</div>
</div>
@@ -128,7 +188,7 @@ export default function IconReferencePage() {
<Card>
<Space style={{ marginBottom: 16, display: "flex", alignItems: "center" }}>
<Input
placeholder="Filter by type or name…"
placeholder="Filter by name or class…"
value={filter}
onChange={e => setFilter(e.target.value)}
allowClear
@@ -140,12 +200,13 @@ export default function IconReferencePage() {
: `${visibleCount} / ${totalCount} icons`}
</Text>
</Space>
{groups.map(({ baseType, entries }) => {
const expanded = isExpanded(baseType);
{groups.map(({ category, entries }) => {
const label = CATEGORY_LABELS[category as Category] ?? category;
const expanded = isExpanded(category);
return (
<div key={baseType} style={{ marginBottom: 8 }}>
<div key={category} style={{ marginBottom: 8 }}>
<div
onClick={() => toggleGroup(baseType)}
onClick={() => toggleGroup(category)}
style={{
display: "flex",
alignItems: "center",
@@ -161,13 +222,13 @@ export default function IconReferencePage() {
<span style={{ fontSize: 10, color: "#888", width: 12 }}>
{expanded ? "▾" : "►"}
</span>
<Tag style={{ margin: 0 }}>{baseType}</Tag>
<Tag style={{ margin: 0 }}>{label}</Tag>
<Text type="secondary" style={{ fontSize: 12 }}>
{entries.length}
</Text>
</div>
{expanded && (
<Table<PrototypeMetadataEntry>
<Table<IconEntry>
columns={columns}
dataSource={entries}
rowKey="key"
@@ -1,42 +0,0 @@
import { Select } from "antd";
import { InputComponentProps } from "../BaseWebPlugin";
import { useInstances } from "../model/instance";
interface BaseInputInstanceProps extends InputComponentProps {
includeAll?: boolean;
}
function BaseInputInstance(props: BaseInputInstanceProps) {
const [instances, synced] = useInstances();
const options = [
...(props.includeAll ? [{ label: "All instances", value: null }] : []),
...[...instances.values()].map(instance => ({
label: instance.name ?? `Instance ${instance.id}`,
value: instance.id,
})),
];
return (
<Select
showSearch
optionFilterProp="label"
style={{ minWidth: 175 }}
onChange={value => props.onChange(value ?? null)}
value={props.value}
options={options}
allowClear={props.fieldDefinition.optional}
disabled={!synced || props.disabled}
loading={!synced}
/>
);
}
export function InputInstanceWithAll(props: InputComponentProps) {
return <BaseInputInstance {...props} includeAll />;
}
export function InputInstance(props: InputComponentProps) {
return <BaseInputInstance {...props} />;
}
@@ -5,7 +5,7 @@ import { useModPacks } from "../model/mod_pack";
import { InputComponentProps } from "../BaseWebPlugin";
export default function InputModPack(props: InputComponentProps) {
const [modPacks, synced] = useModPacks();
const [modPacks] = useModPacks();
return <Select
showSearch
optionFilterProp="label"
@@ -17,7 +17,6 @@ export default function InputModPack(props: InputComponentProps) {
value: modPack.id,
}))}
allowClear={props.fieldDefinition.optional}
disabled={!synced || props.disabled}
loading={!synced}
disabled={props.disabled}
/>;
}
@@ -0,0 +1,68 @@
import React, { useContext, useEffect, useState } from "react";
import { TreeSelect } from "antd";
import * as lib from "@clusterio/lib";
import { InputComponentProps } from "../BaseWebPlugin";
import { useAccount } from "../model/account";
import ControlContext from "./ControlContext";
const defaultFieldDefinition: lib.FieldDefinition = { type: "string", optional: false };
export default function InputPartialVersion (
props: Omit<InputComponentProps, "fieldDefinition"> & {
fieldDefinition?: lib.FieldDefinition;
className?: string;
}
) {
const fieldDefinition = props.fieldDefinition || defaultFieldDefinition;
const [versions, setVersions] = useState<readonly lib.PartialVersion[]>(lib.ApiVersions);
const control = useContext(ControlContext);
const account = useAccount();
useEffect(() => {
(async () => {
if (account.hasPermission("core.external.get_factorio_versions")) {
const res = await control.factorioVersions.get(5 * 60 * 1000);
setVersions(res.map(v => v.version));
}
})();
}, [control]);
// Split the versions into groups based on major minor
const groups = new Map<lib.PartialVersion, lib.PartialVersion[]>();
for (const version of versions) {
const [major, minor] = version.split(".");
const key = `${major}.${minor}` as lib.PartialVersion;
if (!groups.has(key)) {
groups.set(key, []);
}
groups.get(key)!.push(version);
}
// Construct the tree to be disabled
const tree = [...groups.entries()].map(([majorMinor, patchVersions]) => (
{
title: majorMinor,
value: majorMinor,
key: majorMinor,
children: patchVersions.map((v) => ({
title: v,
value: v,
key: v,
})),
}
));
return <TreeSelect
showSearch
style={{ minWidth: 175 }}
onChange={(value) => props.onChange(value ?? null)}
value={props.value}
treeData={tree}
allowClear={fieldDefinition.optional}
className={props.className}
disabled={props.disabled}
/>;
}
+2 -3
View File
@@ -4,7 +4,7 @@ import { InputComponentProps } from "../BaseWebPlugin";
import { useRoles } from "../model/roles";
export default function InputRole(props: InputComponentProps) {
const [roles, synced] = useRoles();
const [roles] = useRoles();
return <Select
showSearch
@@ -17,7 +17,6 @@ export default function InputRole(props: InputComponentProps) {
value: role.id,
}))}
allowClear={props.fieldDefinition.optional}
disabled={!synced || props.disabled}
loading={!synced}
disabled={props.disabled}
/>;
}
@@ -0,0 +1,71 @@
import React, { useContext, useEffect, useState } from "react";
import { TreeSelect } from "antd";
import * as lib from "@clusterio/lib";
import { InputComponentProps } from "../BaseWebPlugin";
import { useAccount } from "../model/account";
import ControlContext from "./ControlContext";
const defaultFieldDefinition: lib.FieldDefinition = { type: "string", optional: false };
export default function InputTargetVersion (
props: Omit<InputComponentProps, "fieldDefinition"> & {
fieldDefinition?: lib.FieldDefinition;
className?: string;
}
) {
const fieldDefinition = props.fieldDefinition || defaultFieldDefinition;
const [versions, setVersions] = useState<readonly lib.TargetVersion[]>(lib.ApiVersions);
const control = useContext(ControlContext);
const account = useAccount();
useEffect(() => {
(async () => {
if (account.hasPermission("core.external.get_factorio_versions")) {
const res = await control.factorioVersions.get(5 * 60 * 1000);
setVersions(res.map(v => v.version));
}
})();
}, [control]);
// Split the versions into groups based on major minor
const groups = new Map<lib.TargetVersion, lib.TargetVersion[]>();
for (const version of versions) {
const [major, minor] = version.split(".");
const key = `${major}.${minor}` as lib.PartialVersion;
if (!groups.has(key)) {
groups.set(key, []);
}
groups.get(key)!.push(version);
}
// Construct the tree to be disabled
const tree = [...groups.entries()].map(([majorMinor, patchVersions]) => (
{
title: majorMinor,
value: majorMinor,
key: majorMinor,
children: patchVersions.map((v) => ({
title: v,
value: v,
key: v,
})),
}
));
// Add "latest" option for TargetVersion
tree.unshift({ title: "latest", value: "latest", key: "latest", children: [] });
return <TreeSelect
showSearch
style={{ minWidth: 175 }}
onChange={(value) => props.onChange(value ?? null)}
value={props.value}
treeData={tree}
allowClear={fieldDefinition.optional}
className={props.className}
disabled={props.disabled}
/>;
}
@@ -1,206 +0,0 @@
import React, { useContext, useEffect, useState, useRef } from "react";
import { Button, Divider, Input, InputRef, RefSelectProps, TreeSelect } from "antd";
import * as lib from "@clusterio/lib";
import { InputComponentProps } from "../BaseWebPlugin";
import { useAccount } from "../model/account";
import ControlContext from "./ControlContext";
const defaultFieldDefinition: lib.FieldDefinition = { type: "string", optional: false };
function InputVersion<
Version extends lib.TargetVersion,
IncludeLatest extends (Version extends "latest" ? boolean : false),
> (
props: Omit<InputComponentProps, "fieldDefinition"> & {
fieldDefinition?: lib.FieldDefinition;
className?: string;
version: {
isValid: (v: string) => v is Version;
includeLatest: IncludeLatest;
};
}
) {
const fieldDefinition = props.fieldDefinition || defaultFieldDefinition;
const [versions, setVersions] = useState<readonly string[]>(lib.ApiVersions);
const [channels, setChannels] = useState<{ name: string, version: string }[]>([]);
const [customVersion, setCustomVersion] = useState("");
const [open, setOpen] = useState(false);
const inputRef = useRef<InputRef>(null);
const selectRef = useRef<RefSelectProps>(null);
const control = useContext(ControlContext);
const account = useAccount();
useEffect(() => {
(async () => {
if (account.hasPermission("core.external.get_factorio_versions")) {
const res = await control.factorioVersions.get(5 * 60 * 1000);
setVersions(res.map(v => v.version));
}
})();
}, [control]);
// Release channels (e.g. stable, experimental) only apply to target versions
useEffect(() => {
(async () => {
if (props.version.includeLatest && account.hasPermission("core.external.get_latest_releases")) {
const res = await control.latestReleases.get(5 * 60 * 1000);
// Read the headless build's version directly rather than via a lib
// helper: lib is a federation-shared module here and unused exports
// can be tree-shaken out of it.
setChannels(Object.entries(res).map(([name, builds]) => (
{ name, version: builds.headless ?? Object.values(builds)[0] ?? "" }
)));
}
})();
}, [control]);
// Split the versions into groups based on major minor
const groups = new Map<string, string[]>();
for (const version of versions) {
const [major, minor] = version.split(".");
const key = `${major}.${minor}`;
if (!groups.has(key)) {
groups.set(key, []);
}
groups.get(key)!.push(version);
}
// Construct the tree to be disabled
const tree = [...groups.entries()].map(([majorMinor, patchVersions]) => (
{
title: majorMinor,
value: majorMinor,
key: majorMinor,
// Skip a patch equal to the group key (a bare major.minor version,
// e.g. the ApiVersions fallback): the selectable group node already
// represents it, and a duplicate value warns in the TreeSelect.
children: patchVersions.filter((v) => v !== majorMinor).map((v) => ({
title: v,
value: v,
key: v,
})),
}
));
// Add "latest" and release channel options for TargetVersion
if (props.version.includeLatest) {
tree.unshift(
{ title: "latest", value: "latest", key: "latest", children: [] },
...channels.map(({ name, version }) => ({
title: version ? `${name} (${version})` : name,
value: name,
key: name,
children: [],
})),
);
}
return <TreeSelect
showSearch
ref={selectRef}
style={{ minWidth: 175 }}
onChange={(value) => props.onChange(value ?? null)}
value={props.value}
treeData={tree}
allowClear={fieldDefinition.optional}
className={props.className}
disabled={props.disabled}
open={open}
onOpenChange={newOpen => {
setOpen(newOpen);
if (newOpen && typeof props.value === "string") {
setCustomVersion(props.value);
}
}}
onKeyDown={e => {
if (open && e.key === "Tab") {
e.preventDefault();
inputRef.current?.focus();
}
}}
popupRender={(menu) => {
const isValid = props.version.isValid(customVersion);
const submit = () => {
if (customVersion && isValid) {
setTimeout(() => selectRef.current?.focus(), 0);
props.onChange(customVersion);
setOpen(false);
}
};
return (
<>
{menu}
<Divider style={{ margin: "8px 0" }} />
<div style={{ display: "flex", gap: 8, padding: "0 8px 4px" }}>
<Input
ref={inputRef}
style={{ flex: 1 }}
placeholder="Custom X.Y.Z"
value={customVersion}
status={customVersion && !isValid ? "error" : undefined}
onChange={(e) => setCustomVersion(e.target.value)}
onPressEnter={submit}
/>
<Button
type="primary"
disabled={!customVersion || !isValid}
onClick={submit}
>
Use
</Button>
</div>
</>
);
}}
/>;
}
export function InputTargetVersion (
props: Omit<InputComponentProps, "fieldDefinition"> & {
fieldDefinition?: lib.FieldDefinition;
className?: string;
}
) {
return <InputVersion
{...props}
version={{
isValid: lib.isTargetVersion,
includeLatest: true,
}}
/>;
}
export function InputPartialVersion (
props: Omit<InputComponentProps, "fieldDefinition"> & {
fieldDefinition?: lib.FieldDefinition;
className?: string;
}
) {
return <InputVersion
{...props}
version={{
isValid: lib.isPartialVersion,
includeLatest: false,
}}
/>;
}
export function InputFullVersion (
props: Omit<InputComponentProps, "fieldDefinition"> & {
fieldDefinition?: lib.FieldDefinition;
className?: string;
}
) {
return <InputVersion
{...props}
version={{
isValid: lib.isFullVersion,
includeLatest: false,
}}
/>;
}
Loaded 100 of 215 files, more files were not shown because too many files have changed in this diff. Show more