Compare commits

...
Author SHA1 Message Date
Danielv123 5ddf3fe34f fix(minimap): restore nested previous_recipe name access
Furnace previous_recipe ids are nested under prev.name.name in this context. Restore the original access path and leave an inline maintainer note to prevent repeat regressions.
2026-02-21 19:49:59 +01:00
Danielv123 17480aafff fix(minimap): clarify dual filter API pattern in data source
Dual filter API methods (setInstanceSurfaceFilters and setInstanceSurfaceFilter) both exist for backward compatibility with different plugin implementations. Added comment explaining why both are checked in fallback pattern.
2026-02-21 17:16:06 +01:00
Danielv123 387a9425ce refactor(minimap): deduplicate tile parsing and parallelize file cleanup 2026-02-21 17:11:33 +01:00
Danielv123 550ea22724 fix(minimap): improve error handling and performance in tile parsing
- Add warning logging in renderChunkToPixels catch block before fallback
- Switch maxTick filtering from skip/continue to early break (append-only optimization)
- Use existsSync for directory existence check in loadExistingTagContent
2026-02-21 17:02:50 +01:00
Danielv123 9375c462af fix(minimap): use existing player_position channel for join start event
Replace unsupported minimap:player_session_start IPC with existing minimap:player_position channel to emit immediate session start on join.
2026-02-21 16:49:03 +01:00
Danielv123 e28b4121e3 fix(minimap): use physical position and emit session start on join
- Use player.physical_surface/physical_position for accurate tracking when in vehicles/remotes
- Emit session start record immediately on on_player_joined_game
- Make storage.minimap initialization consistent (init() ensures all fields exist)
2026-02-21 16:46:32 +01:00
Danielv123 a1e8d009ff fix(minimap): restore chart tag interval semantics after task 3
Task 3 inadvertently changed chart tag data schema from start_tick/end_tick
interval model to single tick field. This restores the original interval
semantics for chart tags while preserving legitimate Task 3 fixes.

Reverted:
- create_chart_tag_data(tick) -> create_tag_data(start_tick, end_tick)
- Single-emission in on_chart_tag_modified -> dual-emission (end old, start new)
- All chart tag event handlers use interval model again

Preserved from Task 3:
- prev.name.name fix (both locations)
- Hot-loop local aliases (math_floor, bit32_rshift, table_insert)
- IIFE removal for recipe icon lookup
- Recipe data single tick field (within Task 3 scope)
2026-02-21 16:43:31 +01:00
Danielv123 7c6dd1ff66 fix(minimap): fix prev.name bug and lua code quality improvements
- Fix prev.name.name bug: previous_recipe.name is RecipeID, not nested
- Add hot-loop local references (math_floor, bit32_rshift, table_insert)
- Remove IIFE pattern for icon lookup, use inline logic
- Rename create_tag_data to create_chart_tag_data for consistency
- Use recipe nilness + single tick field instead of start_tick/end_tick split
2026-02-21 16:40:31 +01:00
Danielv123 bb74b615a7 refactor(minimap): tighten typed parse helper returns for player records 2026-02-21 16:34:40 +01:00
Danielv123 c3136532b8 refactor(minimap): use discriminated union for PlayerPositionRecord and extract readSignedInt24 2026-02-21 16:31:20 +01:00
Danielv123 245ceb14fa security(minimap): fix path traversal in controller file loading 2026-02-21 16:25:11 +01:00
Danielv123 5ff7d7ee42 minimap: fix debounced zoom cleanup 2026-02-09 19:19:48 +01:00
Danielv123 35575a93a3 minimap: remove redundant instance RCON init 2026-02-09 19:13:24 +01:00
Danielv123 57b50048bc minimap: address small review feedback 2026-02-09 18:54:55 +01:00
Danielv123 923316b44d Order player session records by receive time 2026-02-05 22:45:20 +01:00
Danielv123 81fcea8398 Revert keyToLabels label parsing 2026-02-05 22:38:17 +01:00
Danielv123 3f8ef85987 Harden minimap tile storage paths 2026-02-05 22:38:13 +01:00
Danielv123 78fc90d7a0 Align minimap plugin package metadata 2026-02-05 22:37:56 +01:00
Danielv123 ebbc14ba71 Use type-only import for InputPartialVersion props 2026-02-05 22:37:56 +01:00
Danielv123 3be1d637c8 Validate tile paths 2026-02-05 21:55:05 +01:00
Danielv123 3d9c682d7c Fix surfaces with underscore in the name 2026-02-03 21:51:53 +01:00
Danielv123 b0fd90d273 Extend minimap components to allow external use 2026-01-29 23:18:26 +01:00
Danielv123 73ab8fc4f3 Player session end tracking 2026-01-29 21:51:19 +01:00
Danielv123 2eedddf17f Don't break version selector 2026-01-28 09:06:52 +01:00
Danielv123 9061d2aa92 Stable label creation, forgot imports of messages 2026-01-28 00:15:41 +01:00
Danielv123 3932ac2cae Merge branch 'plugin-minimap' of https://github.com/clusterio/clusterio into plugin-minimap 2026-01-28 00:08:10 +01:00
Danielv123 107201e6e5 Add antd icons to minimap 2026-01-28 00:07:42 +01:00
Danielv123 a7ab63ba3c Add antd icons to minimap 2026-01-28 00:02:50 +01:00
Danielv123 ec0c068d44 Add button to delete minimap data 2026-01-27 23:49:25 +01:00
Danielv123 264a32431e Fix react errors in factorio version selector 2026-01-27 23:12:15 +01:00
Danielv123 01f6397983 Fix circular imort breaking web UI 2026-01-27 20:37:01 +01:00
Danielv123 979d961618 Merge remote-tracking branch 'origin/master' into plugin-minimap 2026-01-27 17:45:23 +01:00
Daniel 6489fa4048 Merge pull request #823 from Cooldude2606/fix/update-plugin-ts-config
Updates package jsons and tsconfigs to have catalogs and correct peer deps
2026-01-25 20:10:14 +01:00
Daniel 22850b7cd0 Merge pull request #815 from Cooldude2606/feature/database-lock
Add extra startup warnings to CLI: User count and Lock file
2026-01-25 20:08:36 +01:00
Daniel f65c8fb9a0 Merge pull request #816 from Cooldude2606/fix/download-headless
Fix headless download
2026-01-25 20:08:18 +01:00
Daniel 3232da71e2 Merge pull request #817 from Cooldude2606/fix/disable-achivement
Fix achievements not being disabled when save patching disabled
2026-01-25 20:08:05 +01:00
Daniel c9fc4f1c8f Merge pull request #821 from Cooldude2606/feature/version-selector
Add drop down version selector based on Wube version archive
2026-01-25 20:07:38 +01:00
Cooldude2606 0c56c56f74 Fixed duplicate pnpm version spec 2026-01-25 00:26:53 +00:00
Cooldude2606 2928b9b7c5 Bump node and pnpm version 2026-01-25 00:23:09 +00:00
Cooldude2606 1fc6971278 Switch to catalogs 2026-01-25 00:02:20 +00:00
Cooldude2606 2679851d66 Update plugin template 2026-01-24 22:54:59 +00:00
Cooldude2606 ab86cd2b1c Update package json and tsconfig 2026-01-24 22:47:40 +00:00
Cooldude2606 02fce63e11 Fix git ignore 2026-01-24 21:03:57 +00:00
Cooldude2606 8981a018b0 Fix lint issue 2026-01-24 18:33:28 +00:00
Cooldude2606 bbf5df6d01 Add remaining tests 2026-01-24 17:58:36 +00:00
Cooldude2606 6f68cf74a7 Add network events and switch to tree select 2026-01-24 16:27:28 +00:00
Cooldude2606 07f501a67a Add ValueCache and fetchFactorioVersions 2026-01-23 01:50:20 +00:00
Daniel 4caf06aafc Merge pull request #819 from Cooldude2606/feature/login-screen-name
Display cluster name on login screen
2026-01-22 15:45:00 +01:00
Cooldude2606 4901d76ebe Refactor version inputs 2026-01-22 00:37:34 +00:00
Cooldude2606 6b0dd4e9cf Add tests for new api route 2026-01-21 23:47:47 +00:00
Cooldude2606 a2c38ad4e8 Add cluster name to login form 2026-01-21 23:42:53 +00:00
Cooldude2606 438ccbdc7f Fix achievements not being disabled 2026-01-14 21:24:03 +00:00
Cooldude2606 d6c5430e0c Fix headless download 2026-01-11 17:05:21 +00:00
Cooldude2606 d48b731ea4 Add lockfile bypass 2026-01-08 22:48:00 +00:00
Cooldude2606 bccb6527a7 Add lock file for configs 2026-01-07 01:20:40 +00:00
Cooldude2606 00cf0f59ec Refuse to start controller with no users 2026-01-07 00:21:56 +00:00
Double 3a823bee01 Update link to message passing protocol specification (#813) 2026-01-01 19:23:54 +00:00
Cooldude2606 98650b9bfb 2.0.0-alpha.22b (plugins)
Due to the bump of lib version, all plugins must also be bumped to 22b
2025-11-30 22:07:37 +00:00
Daniel a781af5b6a Merge pull request #811 from clusterio/copilot/fix-add-mods-button-visibility
Fix button overflow on narrow screens across web interface
2025-11-22 23:21:55 +01:00
copilot-swe-agent[bot]andDanielv123 f202a0975a Add wrap to more Space components and fix table scroll on narrow screens
Co-authored-by: Danielv123 <6792749+Danielv123@users.noreply.github.com>
2025-11-20 10:55:42 +00:00
copilot-swe-agent[bot]andDanielv123 ea4280d184 Add wrap prop to Space components in SectionHeader for mobile responsiveness
Co-authored-by: Danielv123 <6792749+Danielv123@users.noreply.github.com>
2025-11-20 07:52:22 +00:00
copilot-swe-agent[bot] c519ee1389 Initial plan 2025-11-20 07:45:31 +00:00
CopilotandDanielv123 c61b9dbe60 Remove debug log statement from serialize.lua (#808)
* Initial plan

* Remove debug log statement from serialize.lua

Co-authored-by: Danielv123 <6792749+Danielv123@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Danielv123 <6792749+Danielv123@users.noreply.github.com>
2025-11-11 12:21:07 +01:00
Cooldude2606 77f4aa6296 2.0.0-alpha.22b 2025-11-07 21:55:35 +00:00
Cooldude2606 86a9084d56 Fix package updater 2025-11-07 21:48:09 +00:00
Cooldude2606 43def44136 Fix package path for restart required 2025-11-07 21:47:48 +00:00
Cooldude2606 8c1be7210e Skip git checks on npm publish 2025-11-07 20:08:22 +00:00
Cooldude2606 24e290746d 2.0.0-alpha.22 2025-11-07 19:48:21 +00:00
Cooldude2606 d52931a326 Better handling of mod dependencies (#801)
* Add ModDependency and version types

* Add warnings for mod dependencies

* Add warning for wrong factorio version

* Add fix issues button

* Add automatic dependency resolution and download

* Add abilty to check for updates

* Add extra test coverage

* One extra test

* Fix copy paste error
2025-11-07 19:22:17 +00:00
Cooldude2606 73cc019d2f Sets restart required when a plugin is updated (#794)
* Template type for load plugin config

* Add plugin update checking

* Add restart icon to instance list

* Add tests

* Log package name when failing to read package json
2025-11-01 16:11:11 +00:00
Cooldude2606 41151d47f0 Add publish workflow (#798)
* Change build-mod to belong to packages

* Add publish script and workflow

* Fix typo in variable name
2025-11-01 16:10:41 +00:00
Cooldude2606 3fbd9a995d Add better plugin index missing error (#799)
* Add better plugin load errors

* Remove check for permission

* Account for packages using different main file
2025-11-01 16:10:24 +00:00
Daniel af299bc08d Merge pull request #800 from Cooldude2606/fix/template-ignore
Fix plugin template npm and git ignore
2025-10-30 13:54:29 +01:00
Hornwitser 93fa12200f Fix installer asking for remoteNpm even when given 2025-10-18 18:43:55 +02:00
Hornwitser 35f769ad25 Release hotfix of installer 2025-10-18 18:27:41 +02:00
Cooldude2606 732cee4837 Fix plugin template npm and git ignore 2025-09-19 23:45:31 +01:00
Cooldude2606 681e78967f Webpack Build Improvements (#791)
* Bump @swc/core to 1.13.3

* Switch to using --mode production rather than --env

Closes #792

* Add cache
2025-08-17 15:54:37 +01:00
Daniel d2bd1382ba Filtered subscriptions (#788)
* Add filtered subscriptions

* Add tests for subscription filters

* Stricter assertions

* Un-nest if statements
2025-08-13 11:55:49 +01:00
Daniel c0ec9a3890 Send inventory on interval instead of event to ensure time gets updated (#790)
This means inventory updates in low-volume clusters might be delayed up to 1 second more, but ensures we always have the unix time signal available, even in clusters that don't have anything in the inventory yet. Resolves #610
2025-08-10 22:25:32 +01:00
Daniel 4221b96431 Fix rendered more hooks error when refreshing hosts page (#789)
* Fix rendered more hooks error when refreshing hosts page

* Fix conditional render on controllerPage

Doesn't cause any issues that I know off right now, but changes to antd internal code could change that so it feels better to be sure.
2025-08-10 22:17:14 +01:00
Daniel 3ddbe2ddb3 Merge pull request #787 from Cooldude2606/fix/plugin-template
Plugin templates no longer depend on a dev install of Clusterio
2025-08-10 21:20:24 +02:00
Danielv123 38d5fdfdba Use filtered subscriptions to save data 2025-08-10 00:28:48 +02:00
Danielv123 ea144863f9 Fix incorrect type in player path request response 2025-08-10 00:28:48 +02:00
Danielv123 48437a5f6c Use SubscriptionController 2025-08-10 00:28:48 +02:00
Danielv123 70ef8a60ad Fix lint, use of sync method in web UI 2025-08-10 00:28:48 +02:00
Danielv123 c4d4cb3afd Remove unused filter 2025-08-10 00:28:48 +02:00
Danielv123 093b71045b Move player position parsing to shared util, fix player ID persistence 2025-08-10 00:28:48 +02:00
Danielv123 a36e7bddca Fix player ID assignments 2025-08-10 00:28:48 +02:00
Danielv123 3737659cc3 Remove extraneous logging 2025-08-10 00:28:48 +02:00
Danielv123 5ba6056848 Don't load Lua in 1.1 2025-08-10 00:28:48 +02:00
Danielv123 89b6d5aa13 Attempt to fix tests 2025-08-10 00:28:48 +02:00
Danielv123 630a0d3898 Clean up UI, fix focus issues 2025-08-10 00:28:48 +02:00
Danielv123 047d3c22f5 Add player position tracking 2025-08-10 00:28:48 +02:00
Danielv123 57f89de746 Fix pixel parsing offset error 2025-08-10 00:28:48 +02:00
Danielv123 14f69cda11 Fix pixel change record bounds check
From cursor review: Insufficient bounds checking
for pixel change records (type 2) in renderTileToPixels,
extractAvailableTicks, and parseTileData. The code
checks for 6 bytes of header data but reads 10 bytes
(tick, pixelCount, newColor, oldColor), potentially
leading to reading out of bounds. The check should
be offset + 10 > tileData.length.
2025-08-10 00:28:48 +02:00
Danielv123 2bce506d96 Lint and refactor 2025-08-10 00:28:48 +02:00
Danielv123 ae7a1744ad Further compress tile pixel delta data
7 bytes header + 6 bytes per pixel -> 11 byte header + 2 bytes per pixel
2025-08-10 00:28:48 +02:00
Danielv123 ab8321ff4b Document tile data storage 2025-08-10 00:28:48 +02:00
Danielv123 c5c26bfad1 Use binary storage for recipe overlay for 8x space reduction 2025-08-10 00:28:48 +02:00
Danielv123 1c06611cca Timeline support for recipe overlay 2025-08-10 00:28:48 +02:00
Danielv123 7db716eceb Fix recipe data deduplication 2025-08-10 00:28:48 +02:00
Danielv123 3b7fd164af Implement recipe overlay 2025-08-10 00:28:48 +02:00
Danielv123 cf2efb1380 Fix deleting and modifying tags, tags on timeline 2025-08-10 00:28:48 +02:00
Danielv123 fc466ec799 Add chart tag support
Also fixes react state management issues, moving a few things over to useRef and showing all instances instead of using a custom solution
2025-08-10 00:28:48 +02:00
Danielv123 f5aeaff256 Optimize canvas to render independent of react 2025-08-10 00:28:48 +02:00
Danielv123 6ce4d0d20b F and G as timeline navigation shortcuts 2025-08-10 00:28:48 +02:00
Danielv123 0785798b7d Incremental rendering of timeline scrubbing 2025-08-10 00:28:48 +02:00
Danielv123 9dc5f8ff37 Working timelapse rendering functionality 2025-08-10 00:28:48 +02:00
Danielv123 be21f22707 Add timeline selection components and track tick on changes 2025-08-10 00:28:48 +02:00
Danielv123 0d294479b3 Use same parsing utilities on backend and frontend 2025-08-10 00:28:48 +02:00
Danielv123 626188dd16 Calculate and store deltas for changes 2025-08-10 00:28:48 +02:00
Danielv123 a516b6b129 Implement new tile storage format with tentative timelapse support 2025-08-10 00:28:48 +02:00
Danielv123 2dcc92c96b Document new data flow and tile storage format 2025-08-10 00:28:48 +02:00
Danielv123 015fd2c30b Add readme 2025-08-10 00:28:48 +02:00
Danielv123 3f06bd0993 Smooth keyboard movement 2025-08-10 00:28:48 +02:00
Danielv123 cfc1b7687e Allow click and drag to pan 2025-08-10 00:28:48 +02:00
Danielv123 285254e3bc Fix issues with black seams from incorrect scaling math 2025-08-10 00:28:48 +02:00
Danielv123 b70c7bd84f Fix negative coordinates when drawing updated chunks 2025-08-10 00:28:48 +02:00
Danielv123 3bf7a84de4 Switch to custom canvas based map renderer 2025-08-10 00:28:48 +02:00
Danielv123 063e622721 Render map tiles as pixelated 2025-08-10 00:28:48 +02:00
Danielv123 5b9f650946 Use encode_string and support multiple instances 2025-08-10 00:28:48 +02:00
Danielv123 c62eae0009 Improve webUI loading speed with 512x512 tiles, still corrupted looking 2025-08-10 00:28:48 +02:00
Danielv123 e9a4627118 Cleanup, web interface now shows images (but corrupted) 2025-08-10 00:28:48 +02:00
Danielv123 6cbd3229f6 Use new chunk charting API 2025-08-10 00:28:48 +02:00
Danielv123 f7658e1118 Create minimap core plugin based on gridworld plugin 2025-08-10 00:28:48 +02:00
Danielv123 f31fb535df Add tests for subscription filters 2025-08-09 23:57:13 +02:00
Danielv123 75b2dac371 Add filtered subscriptions 2025-08-09 23:44:01 +02:00
Cooldude2606 7ceb786143 Add onExit to instance template 2025-08-08 21:41:49 +01:00
Cooldude2606 174d9d5b07 Bug Fixes 2025-08-07 19:14:23 +01:00
Cooldude2606 a1118d08dd Add ^ to clusterio version 2025-08-07 17:44:55 +01:00
Cooldude2606 379d2575a0 Improve comments 2025-08-07 17:42:52 +01:00
Cooldude2606 e30dcc6fff Fix tsconfigs 2025-08-07 17:32:54 +01:00
Cooldude2606 281a5ed97e Update common files 2025-08-07 13:17:41 +01:00
Daniel 6004f861fe Merge pull request #783 from clusterio/cursor/configure-master-listen-address-53c7
Exclude instance from "start all" function
2025-08-04 20:50:47 +02:00
Danielv123 174c798d4b Run integration test instead of unit test 2025-08-04 18:46:13 +00:00
Danielv123 14b8b5ac61 ctl instance stop-all test 2025-08-04 18:28:25 +00:00
Danielv123 89dec572bb Add test for ctl instance start-all 2025-08-04 18:10:48 +00:00
Danielv123 f662880c8d Fix zlib update breaking our types 2025-08-04 17:36:18 +00:00
Danielv123 90cd2770b3 Add start-all command to cli 2025-08-04 17:36:18 +00:00
Cursor Agentanddanielv 69240e7917 Add option to exclude instances from bulk start operation
Co-authored-by: danielv <danielv@danielv.no>
2025-08-04 17:36:18 +00:00
Daniel e1d9c1e414 Merge pull request #785 from clusterio/dependency-updates
Update dependencies
2025-08-04 19:03:49 +02:00
Danielv123 376eaeba52 Revert save writing delay during tests 2025-08-04 16:28:35 +00:00
Danielv123 80983662aa Wait for promise to resolve before returning 2025-08-04 16:14:07 +00:00
Danielv123 9840f22688 Wait for savefile to be moved 2025-08-04 15:36:27 +00:00
Danielv123 b9e2acaaa5 eslint needs to be ecmascript module 2025-08-01 23:29:41 +00:00
Danielv123 7ac8cd3781 Fix mocha arguments leaking into yargs parser 2025-08-01 23:26:47 +00:00
Danielv123 7e2f10e842 Fix linting issues with new rules 2025-08-01 22:26:25 +00:00
Danielv123 256407b9fd Update eslint config file for eslint 9 2025-08-01 22:26:07 +00:00
Danielv123 7979dfaa16 Fix missing import 2025-08-01 21:25:13 +00:00
Danielv123 10a87d47da Convert ArrayBufferLike to Buffer 2025-08-01 21:19:58 +00:00
Danielv123 59709280da Update dependencies in package.json 2025-08-01 21:12:46 +00:00
Daniel 81b656f0ab Merge pull request #782 from clusterio/cursor/implement-factorio-1-1-support-a0fd
Online player list on instance page
2025-08-01 21:59:34 +02:00
Daniel f966c5b965 Merge pull request #780 from Cooldude2606/feature/instance-restart
Add instance restart
2025-07-31 00:18:41 +02:00
Danielv123 56a26848e6 Paginate users on instance page, better search ux 2025-07-31 00:13:02 +02:00
Cooldude2606 28f21bfb70 Fix mutating actions prop 2025-07-30 16:16:29 +01:00
Danielv123 7b4724845f Fix linter error 2025-07-24 22:36:24 +02:00
Danielv123 2053423864 Add proper filters 2025-07-23 15:00:51 +02:00
Danielv123 e7519975ec Use same component for all user tables 2025-07-23 14:21:33 +02:00
Cursor Agentanddanielv 7b7389526b Add online players list component to instance view page
Co-authored-by: danielv <danielv@danielv.no>
2025-07-21 17:41:13 +00:00
Cooldude2606 a464b61d39 Remove redundant status check 2025-07-16 01:41:23 +01:00
Cooldude2606 88ca4563a8 Add tests 2025-07-16 01:25:27 +01:00
Cooldude2606 449bd68fab Add instance restart to ctl 2025-07-16 00:06:44 +01:00
Cooldude2606 59256e77d1 Make the control buttons consistent 2025-07-15 00:49:33 +01:00
Cooldude2606 f11d91bd6b Add missing space to instances and users list 2025-07-14 22:38:06 +01:00
Cooldude2606 e0278d0331 Add instance restart button 2025-07-14 22:34:41 +01:00
Cooldude2606 2baa2e50ac Add InstanceRestartRequest 2025-07-14 21:48:12 +01:00
Daniel 02d14bd98d Merge pull request #778 from Cooldude2606/refactor/test-exec-ctl
Refactor execCtl and Ctl to speed up unit testing
2025-07-10 13:09:27 +02:00
Daniel 952ae21958 Merge pull request #777 from Cooldude2606/refactor/user-datastore
Refactor roles to use datastore and subscriptions
2025-07-10 13:02:38 +02:00
Cooldude2606 50d7d28054 Fix expected error messages for assert rejects 2025-07-06 23:17:52 +01:00
Cooldude2606 2896ec1c15 Add initializeCtl and control reuse in tests 2025-07-06 22:58:39 +01:00
Cooldude2606 6b81e6383d Fix usage of assert.rejects 2025-07-06 22:53:39 +01:00
Cooldude2606 5ed7163ed8 Add missing await statement
Fixes #774
2025-06-14 23:05:14 +01:00
Cooldude2606 df1edec623 Add role subscriptions and data model 2025-06-14 00:28:03 +01:00
Cooldude2606 a5bf8d92d9 Separate roles to their own file 2025-06-13 22:24:56 +01:00
Cooldude2606 af95ca587b Fix datastore types 2025-06-13 22:24:44 +01:00
Cooldude2606 331fc7d947 Add types to event emitters and clean up connections (#772)
* Event types for controller

* Clean up ctl event emitters

* Event types for server

* Event types for lib

* Remove TypedEventEmitter

* Event types for webui

* Fix compile issues

* Small cleanup of connectors
2025-06-13 22:20:50 +01:00
199 changed files with 16680 additions and 3714 deletions

No files matched your search

-1
View File
@@ -1 +0,0 @@
dist/
+2 -8
View File
@@ -20,8 +20,6 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 8
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
@@ -48,11 +46,9 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 8
- uses: actions/setup-node@v4
with:
node-version: 18.x
node-version: 20.x
- run: pnpm i --no-frozen-lockfile
- run: pnpm run lint
@@ -61,11 +57,9 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 8
- uses: actions/setup-node@v4
with:
node-version: 18.x
node-version: 20.x
- run: pnpm i --no-frozen-lockfile
- run: pnpm run build-mod
# Upload build to artifacts
+48
View File
@@ -0,0 +1,48 @@
name: Publish
on:
release:
types:
- released
jobs:
npm:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 8
- uses: actions/setup-node@v4
with:
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
factorio:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 8
- uses: actions/setup-node@v4
with:
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
env:
FACTORIO_TOKEN: ${{ secrets.FACTORIO_TOKEN }}
- uses: softprops/action-gh-release@v2
with:
files: ./dist/*
+4
View File
@@ -30,8 +30,12 @@ dist/
node_modules
Factorio*
!*Factorio*.ts
!*Factorio*.tsx
!*Factorio*.js
/factorio
!/test/file/factorio
!/test/file/html/*.html
!/test/lib/factorio
!/packages/lib/src/factorio
/instances
+45
View File
@@ -31,6 +31,51 @@ Many thanks to the following for contributing to this release:
[@username](https://github.com/username)
-->
## Version 2.0.0-alpha.22
### Major Features
- Added a download dependencies button which downloads missing dependencies. [#726](https://github.com/clusterio/clusterio/issues/726)
- Added an update mod button which downloads the latest compatible version of a mod. [#725](https://github.com/clusterio/clusterio/issues/725)
### Features
- Added online player list to instance details page. [#666](https://github.com/clusterio/clusterio/issues/666)
- Added ability to restart instances as a single action. [#779](https://github.com/clusterio/clusterio/issues/779)
- Added ability to exclude instances from the 'Start all' web UI button. [#451](https://github.com/clusterio/clusterio/issues/451)
- Added plugin update detection for restart required [#759](https://github.com/clusterio/clusterio/issues/759)
- Added detection for mismatched mod and mod pack factorio version. [#727](https://github.com/clusterio/clusterio/issues/727)
- Added detection for dependency issues such as incompatible or wrong version. [#727](https://github.com/clusterio/clusterio/issues/727)
- Added a quick fix button for resolving detected dependency issues. [#801](https://github.com/clusterio/clusterio/issues/801)
### Changes
- Restart host / controller is now the default action with stop moved to a dropdown. [#780](https://github.com/clusterio/clusterio/issues/780)
- Restart host / controller no longer shows unless it is enabled for that machine. [#780](https://github.com/clusterio/clusterio/issues/780)
- Plugin templates no longer depend on a dev install of Clusterio. [#786](https://github.com/clusterio/clusterio/issues/786)
- Better error message for when a plugin is missing an index file. [#799](https://github.com/clusterio/clusterio/pull/799)
- `ModStore.downloadMods` no longer checks if the mod is already downloaded. [#801](https://github.com/clusterio/clusterio/issues/801
- Mod version for buildins will always be fully qualified, matching the requirement enforced on other mods. [#801](https://github.com/clusterio/clusterio/issues/801))
- Subscriptions can now be filtered. [#788](https://github.com/clusterio/clusterio/pull/788)
### Fixes
- Fixed the missing space between action buttons on the users and hosts list. [#780](https://github.com/clusterio/clusterio/issues/780)
- Fixed "rendered more hooks" error when refreshing hosts page. [#769](https://github.com/clusterio/clusterio/issues/769)
- Fixed inventory combinator time signal not always updating [#610](https://github.com/clusterio/clusterio/issues/610)
- Renamed templates for `.gitignore` and `.npmignore` that caused `create --plugin-template` to fail. [#800](https://github.com/clusterio/clusterio/pull/800)
- Fixed npm update button failing to apply update and restart.
### Breaking changes
- `ModPortalDownloadRequest` now requires an array of mod version pairs rather than a single mod name and version. [#801](https://github.com/clusterio/clusterio/issues/801)
- Version strings (e.g. `1.0`, `1.0.0`) now have stricter typing and validation, passing unchecked strings to functions will error. [#801](https://github.com/clusterio/clusterio/issues/801)
Many thanks to the following for contributing to this release:
[@Cooldude2606](https://github.com/Cooldude2606)
[@Danielv123](https://github.com/Danielv123)
[@Hornwitser](https://github.com/Hornwitser)
## Version 2.0.0-alpha.21
### Major Features
+1 -1
View File
@@ -232,7 +232,7 @@ Do not install the [Clusterio mod](https://mods.factorio.com/mod/clusterio), thi
## Running Clusterio
After completing the installation start up the controller and at least one host separately.
After completing the installation and creating your admin user, start up the controller and at least one host separately.
The installer provides the `run-controller` and `run-host` scripts to make this simple.
Once the controller process is running you can log into the web interface which is hosted by default on http://localhost:8080/ (adjust the port number if you changed it), use the admin authentication token provided from the installation to log in.
+7 -4
View File
@@ -47,6 +47,9 @@ async function removeBuildArtifacts() {
tasks.push(
tryRemove(path.join(basePath, packageName, "dist"))
);
tasks.push(
tryRemove(path.join(basePath, packageName, "node_modules", ".cache"))
);
}
}
@@ -81,10 +84,10 @@ async function removeExecutionArtifacts() {
* Main function for this script
*/
async function main() {
await yargs
await yargs(process.argv.slice(2))
.scriptName("clean")
.usage("$0 <command> [options]")
.option("dry", {
.option("dry-run", {
alias: "dry",
nargs: 0,
describe: "Will not delete any files",
default: false,
@@ -105,7 +108,7 @@ async function main() {
DRY = argv.dry;
removeBuildArtifacts();
})
.demandCommand(1, "You need to specify a command to run")
.demandCommand(1, "A command must be specified")
.strict()
.parse();
}
+8
View File
@@ -239,6 +239,14 @@ Does not affect start or stop of the instance while the host is running.
Defaults to false.
### instance.exclude_from_start_all
If enabled this instance will be excluded from the "Start all" button operation in the web interface.
This allows you to prevent certain instances from being started when using the bulk start operation.
Defaults to false.
### factorio.version
Version of Factorio to use for this instance.
+1 -1
View File
@@ -120,7 +120,7 @@ There's also the `add_libraries` function exported by event_handler, which accep
## Communicating with Clusterio
Clusterio uses a homebrew protocol based on sending JSON payloads over a WebSocket connection.
See [socket.md](socket.md) for the implementation details of it.
See [protocol.md](devs/protocol.md) for the implementation details of it.
It's also possible to write a plugin for Clusterio that exposes a custom interface over HTTP, WebSocket or any other technology supported by Node.js.
+275 -186
View File
@@ -1,118 +1,47 @@
"use strict";
module.exports = {
"plugins": [
"@typescript-eslint",
"node",
],
"root": true,
"env": {
"node": true,
"commonjs": true,
"es2022": true,
import {
defineConfig,
globalIgnores,
} from "eslint/config";
import typescriptEslint from "@typescript-eslint/eslint-plugin";
import node from "eslint-plugin-node";
import globals from "globals";
import tsParser from "@typescript-eslint/parser";
import js from "@eslint/js";
import stylistic from "@stylistic/eslint-plugin";
import {
FlatCompat,
} from "@eslint/eslintrc";
const compat = new FlatCompat({
baseDirectory: import.meta.dirname,
recommendedConfig: js.configs.recommended,
allConfig: js.configs.all,
});
export default defineConfig([{
plugins: {
"@typescript-eslint": typescriptEslint,
"@stylistic": stylistic,
node,
},
"overrides": [
{
"files": ["{packages,plugins}/**/*.ts?(x)"],
"parser": "@typescript-eslint/parser",
"extends": [
"plugin:@typescript-eslint/recommended",
],
"rules": {
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-useless-constructor": "error",
"@typescript-eslint/no-unused-vars": "off",
"@typescript-eslint/triple-slash-reference": "off",
"@typescript-eslint/no-shadow": ["error", { "allow": ["Event", "Request", "yargs"] }],
"prefer-const": "off",
"no-shadow": "off",
},
languageOptions: {
globals: {
...globals.node,
...globals.commonjs,
},
{
"files": ["test/**/*.js", "plugins/*/test/**/*.js"],
"env": {
"mocha": "true",
},
"parserOptions": { sourceType: "commonjs" },
"rules": {
"prefer-arrow-callback": "off",
"no-invalid-this": "off",
"valid-jsdoc": "off",
"node/no-unpublished-import": "off",
"node/no-unpublished-require": "off",
},
},
{
"files": [
"packages/web_ui/src/**/*.ts?(x)",
"packages/lib/browser.ts",
"packages/create/templates/*/web/**/*.{t,j}s?(x)",
"{packages,plugins}/*/web/**/*.ts?(x)",
],
"env": {
"browser": true,
},
"parserOptions": {
"sourceType": "module",
"ecmaFeatures": {
"jsx": true,
},
},
"rules": {
"node/callback-return": "off",
"node/exports-style": "off",
"node/file-extension-in-import": "off",
"node/global-require": "off",
"node/handle-callback-err": "off",
"node/no-callback-literal": "off",
"node/no-deprecated-api": "off",
"node/no-exports-assign": "off",
"node/no-extraneous-import": "off",
"node/no-extraneous-require": "off",
"node/no-missing-import": "off",
"node/no-missing-require": "off",
"node/no-mixed-requires": "off",
"node/no-new-require": "off",
"node/no-path-concat": "off",
"node/no-process-env": "off",
"node/no-process-exit": "off",
"node/no-restricted-require": "off",
"node/no-sync": "off",
"node/no-unpublished-bin": "off",
"node/no-unpublished-import": "off",
"node/no-unpublished-require": "off",
"node/no-unsupported-features/es-builtins": "off",
"node/no-unsupported-features/es-syntax": "off",
"node/no-unsupported-features/node-builtins": "off",
"node/prefer-global/buffer": "off",
"node/prefer-global/console": "off",
"node/prefer-global/process": "off",
"node/prefer-global/text-decoder": "off",
"node/prefer-global/text-encoder": "off",
"node/prefer-global/url-search-params": "off",
"node/prefer-global/url": "off",
"node/prefer-promises/dns": "off",
"node/prefer-promises/fs": "off",
"node/process-exit-as-throw": "off",
"node/shebang": "off",
},
},
{
"files": [
"packages/create/templates/**/*.{t,j}s?(x)",
],
"rules": {
"node/no-missing-require": "off",
"node/no-missing-import": "off",
"spaced-comment": "off",
"comma-spacing": "off",
"max-len": "off",
"indent": "off",
},
},
],
},
"rules": {
"@stylistic/indent": ["error", "tab", {
"ignoredNodes": [
"JSXFragment", // TODO: Linter crashes if this is removed
],
"ignoreComments": true,
"SwitchCase": 1, // Indent case statements one level from switch
}],
"accessor-pairs": "error",
"array-bracket-newline": "off",
"array-bracket-spacing": ["error", "never"],
@@ -120,26 +49,34 @@ module.exports = {
"array-element-newline": "off",
"arrow-body-style": "error",
"arrow-parens": "off",
"arrow-spacing": ["error", { "after": true, "before": true }],
"arrow-spacing": ["error", {
"after": true,
"before": true,
}],
"block-scoped-var": "error",
"block-spacing": "error",
"brace-style": ["error", "1tbs", { "allowSingleLine": true }],
"brace-style": ["error", "1tbs", {
"allowSingleLine": true,
}],
"camelcase": "off",
"capitalized-comments": "off",
"class-methods-use-this": "off",
"comma-dangle": [
"error",
{
"arrays": "always-multiline",
"objects": "always-multiline",
"imports": "always-multiline",
"exports": "always-multiline",
"functions": "only-multiline",
},
],
"comma-dangle": ["error", {
"arrays": "always-multiline",
"objects": "always-multiline",
"imports": "always-multiline",
"exports": "always-multiline",
"functions": "only-multiline",
}],
"comma-spacing": "error",
"comma-style": ["error", "last"],
"complexity": "error",
"complexity": ["error", { "max": 25 }],
"computed-property-spacing": ["error", "never"],
"consistent-return": "error",
"consistent-this": "error",
@@ -148,27 +85,36 @@ module.exports = {
"default-case-last": "error",
"default-param-last": "off",
"dot-location": ["error", "property"],
"dot-notation": ["error", { "allowPattern": "^[a-z]+(_[a-z]+)*$" }],
"dot-notation": ["error", {
"allowPattern": "^[a-z]+(_[a-z]+)*$",
}],
"eol-last": "error",
"eqeqeq": "error",
"func-call-spacing": "error",
"func-name-matching": "error",
"func-names": "off",
"func-style": ["error", "declaration", {
// Allow arrow functions as it better expresses referencing this
// of the containing scope, which is unweilding in plain functions.
"allowArrowFunctions": true,
}],
"function-call-argument-newline": "off",
"function-paren-newline": "off",
"generator-star-spacing": ["error", { "before": false, "after": true }],
"generator-star-spacing": ["error", {
"before": false,
"after": true,
}],
"grouped-accessor-pairs": "error",
"guard-for-in": "error",
"id-blacklist": "error",
"id-length": "off",
"id-match": "error",
"implicit-arrow-linebreak": "error",
"indent": ["error", "tab", { "SwitchCase": 1 }],
"indent-legacy": "off",
"init-declarations": "off",
"jsx-quotes": "error",
@@ -178,10 +124,18 @@ module.exports = {
"linebreak-style": ["error", "unix"],
"lines-around-comment": "off",
"lines-around-directive": "off",
"lines-between-class-members": ["error", "always", { "exceptAfterSingleLine": true }],
"lines-between-class-members": ["error", "always", {
"exceptAfterSingleLine": true,
}],
"max-classes-per-file": "off",
"max-depth": "error",
"max-len": ["error", { "code": 120 }],
"max-len": ["error", {
"code": 120,
}],
"max-lines": "off",
"max-lines-per-function": "off",
"max-nested-callbacks": "error",
@@ -190,10 +144,12 @@ module.exports = {
"max-statements-per-line": "off",
"multiline-comment-style": ["error", "separate-lines"],
"multiline-ternary": "off",
"new-cap": ["error", {
"capIsNewExceptions": ["StringEnum", "StringKey"],
"capIsNewExceptionPattern": "^Type\\.",
}],
"new-parens": "error",
"newline-after-var": "off",
"newline-before-return": "off",
@@ -207,7 +163,11 @@ module.exports = {
"no-catch-shadow": "off",
"no-confusing-arrow": "error",
"no-console": "error",
"no-constant-condition": ["error", { "checkLoops": false }],
"no-constant-condition": ["error", {
"checkLoops": false,
}],
"no-constructor-return": "error",
"no-continue": "off",
"no-div-regex": "error",
@@ -232,16 +192,15 @@ module.exports = {
"no-lonely-if": "error",
"no-loop-func": "error",
"no-magic-numbers": "off",
"no-mixed-operators": [
"error",
{
"groups": [
["&", "|", "^", "~", "<<", ">>", ">>>"],
["==", "!=", "===", "!==", ">", ">=", "<", "<="],
["in", "instanceof"],
],
},
],
"no-mixed-operators": ["error", {
"groups": [
["&", "|", "^", "~", "<<", ">>", ">>>"],
["==", "!=", "===", "!==", ">", ">=", "<", "<="],
["in", "instanceof"],
],
}],
"no-multi-assign": "error",
"no-multi-spaces": "error",
"no-multi-str": "error",
@@ -256,7 +215,11 @@ module.exports = {
"no-new-wrappers": "error",
"no-octal-escape": "error",
"no-param-reassign": "off",
"no-plusplus": ["error", { "allowForLoopAfterthoughts": true }],
"no-plusplus": ["error", {
"allowForLoopAfterthoughts": true,
}],
"no-proto": "error",
"no-restricted-exports": "error",
"no-restricted-globals": "error",
@@ -268,9 +231,15 @@ module.exports = {
"no-script-url": "error",
"no-self-compare": "error",
"no-sequences": "error",
"no-shadow": ["error", { "allow": ["Event", "Request", "yargs"] }],
"no-shadow": ["error", {
"allow": ["Event", "Request", "yargs"],
}],
"no-spaced-func": "error",
"no-tabs": ["error", { "allowIndentationTabs": true }],
"no-tabs": "off",
"no-template-curly-in-string": "error",
"no-ternary": "off",
"no-throw-literal": "error",
@@ -289,7 +258,11 @@ module.exports = {
"no-useless-rename": "error",
"no-useless-return": "error",
"no-var": "error",
"no-void": ["error", { "allowAsStatement": true }],
"no-void": ["error", {
"allowAsStatement": true,
}],
"no-warning-comments": "off",
"no-whitespace-before-property": "error",
"nonblock-statement-body-position": "error",
@@ -299,6 +272,7 @@ module.exports = {
"one-var": "off",
"one-var-declaration-per-line": "error",
"operator-assignment": "off",
"operator-linebreak": ["error", "after", {
"overrides": {
"?": "before",
@@ -308,6 +282,7 @@ module.exports = {
"??": "before",
},
}],
"padded-blocks": "off",
"padding-line-between-statements": "error",
"prefer-arrow-callback": "error",
@@ -324,9 +299,13 @@ module.exports = {
"prefer-spread": "error",
"prefer-template": "error",
"quote-props": "off",
"quotes": ["error", "double", { "avoidEscape": true }],
"quotes": ["error", "double", {
"avoidEscape": true,
}],
"radix": ["error", "always"],
"require-atomic-updates": "off", // XXX Not sure why this triggers on process.title = ...
"require-atomic-updates": "off",
"require-await": "off",
"require-jsdoc": "off",
"require-unicode-regexp": "off",
@@ -342,73 +321,183 @@ module.exports = {
"space-in-parens": ["error", "never"],
"space-infix-ops": "off",
"space-unary-ops": "error",
"spaced-comment": ["error", "always", {
"line": {
"markers": ["/"],
},
}],
"strict": "error",
"switch-colon-spacing": "error",
"symbol-description": "error",
"template-curly-spacing": ["error", "never"],
"template-tag-spacing": "error",
"unicode-bom": ["error", "never"],
"valid-jsdoc": ["error", {
"requireReturn": false,
"requireReturnType": false,
"requireParamType": false,
}],
"vars-on-top": "error",
"wrap-iife": "error",
"wrap-regex": "off",
"yield-star-spacing": "error",
"yoda": ["error", "never", { "exceptRange": true }],
"yoda": ["error", "never", {
"exceptRange": true,
}],
"node/callback-return": "off",
"node/exports-style": "off",
"node/file-extension-in-import": "off",
"node/global-require": "off",
// Node.js callback passing style of (err, value) is not used in this project
"node/handle-callback-err": "off",
"node/no-callback-literal": "off",
"node/no-deprecated-api": "error",
"node/no-exports-assign": "error",
"node/no-deprecated-api": "off",
"node/no-exports-assign": "off",
"node/no-extraneous-import": "off",
"node/no-extraneous-require": "off",
"node/no-missing-import": ["error", {
"tryExtensions": [".js", ".ts"],
}],
"node/no-missing-require": "error",
"node/no-mixed-requires": "error",
"node/no-new-require": "error",
"node/no-path-concat": "error",
"node/no-process-env": "error",
"node/no-process-exit": "error",
"node/no-missing-import": "off",
"node/no-missing-require": "off",
"node/no-mixed-requires": "off",
"node/no-new-require": "off",
"node/no-path-concat": "off",
"node/no-process-env": "off",
"node/no-process-exit": "off",
"node/no-restricted-require": "off",
"node/no-sync": "error",
"node/no-unpublished-bin": "error",
"node/no-unpublished-import": "error",
"node/no-unpublished-require": [
"error",
{ "allowModules": ["webpack", "webpack-merge", "webpack-dev-middleware"] },
],
"node/no-unpublished-bin": "off",
"node/no-unpublished-import": "off",
"node/no-unpublished-require": "off",
"node/no-unsupported-features/es-builtins": "off",
"node/no-unsupported-features/es-syntax": "off",
"node/no-unsupported-features/node-builtins": "off",
"node/prefer-global/buffer": "error",
"node/prefer-global/console": "error",
"node/prefer-global/process": "error",
"node/prefer-global/text-decoder": "error",
"node/prefer-global/text-encoder": "error",
"node/prefer-global/url-search-params": "error",
"node/prefer-global/url": "error",
"node/prefer-global/buffer": "off",
"node/prefer-global/console": "off",
"node/prefer-global/process": "off",
"node/prefer-global/text-decoder": "off",
"node/prefer-global/text-encoder": "off",
"node/prefer-global/url-search-params": "off",
"node/prefer-global/url": "off",
"node/prefer-promises/dns": "off",
"node/prefer-promises/fs": "off",
"node/process-exit-as-throw": "error",
"node/shebang": ["error", {
"convertPath": {
"*.ts": ["^(.+)\\.ts$", "dist/node/$1.js"],
},
}],
"node/process-exit-as-throw": "off",
"node/shebang": "off",
},
};
}, {
files: ["{packages,plugins}/**/*.ts?(x)"],
languageOptions: {
parser: tsParser,
},
extends: compat.extends("plugin:@typescript-eslint/recommended"),
"rules": {
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-useless-constructor": "error",
"@typescript-eslint/no-unused-vars": "off",
"@typescript-eslint/triple-slash-reference": "off",
"@typescript-eslint/no-shadow": ["error", {
"allow": ["Event", "Request", "yargs"],
}],
// Allow require imports where necessary (dynamic loading, webpack configs, etc.)
"@typescript-eslint/no-require-imports": "off",
"prefer-const": "off",
"no-shadow": "off",
},
}, {
files: ["test/**/*.js", "plugins/*/test/**/*.js"],
languageOptions: {
globals: {
...globals.mocha,
},
sourceType: "commonjs",
parserOptions: {},
},
"rules": {
"prefer-arrow-callback": "off",
"no-invalid-this": "off",
"node/no-unpublished-import": "off",
"node/no-unpublished-require": "off",
},
}, {
files: [
"packages/web_ui/src/**/*.ts?(x)",
"packages/lib/browser.ts",
"packages/create/templates/*/web/**/*.{t,j}s?(x)",
"{packages,plugins}/*/web/**/*.ts?(x)",
],
languageOptions: {
globals: {
...globals.browser,
},
"sourceType": "module",
parserOptions: {
"ecmaFeatures": {
"jsx": true,
},
},
},
"rules": {
"node/callback-return": "off",
"node/exports-style": "off",
"node/file-extension-in-import": "off",
"node/global-require": "off",
"node/handle-callback-err": "off",
"node/no-callback-literal": "off",
"node/no-deprecated-api": "off",
"node/no-exports-assign": "off",
"node/no-extraneous-import": "off",
"node/no-extraneous-require": "off",
"node/no-missing-import": "off",
"node/no-missing-require": "off",
"node/no-mixed-requires": "off",
"node/no-new-require": "off",
"node/no-path-concat": "off",
"node/no-process-env": "off",
"node/no-process-exit": "off",
"node/no-restricted-require": "off",
"node/no-sync": "error",
"node/no-unpublished-bin": "off",
"node/no-unpublished-import": "off",
"node/no-unpublished-require": "off",
"node/no-unsupported-features/es-builtins": "off",
"node/no-unsupported-features/es-syntax": "off",
"node/no-unsupported-features/node-builtins": "off",
"node/prefer-global/buffer": "off",
"node/prefer-global/console": "off",
"node/prefer-global/process": "off",
"node/prefer-global/text-decoder": "off",
"node/prefer-global/text-encoder": "off",
"node/prefer-global/url-search-params": "off",
"node/prefer-global/url": "off",
"node/prefer-promises/dns": "off",
"node/prefer-promises/fs": "off",
"node/process-exit-as-throw": "off",
"node/shebang": "off",
},
}, {
files: ["packages/create/templates/**/*.{t,j}s?(x)"],
"rules": {
"node/no-missing-require": "off",
"node/no-missing-import": "off",
"@stylistic/indent": "off",
"spaced-comment": "off",
"comma-spacing": "off",
"max-len": "off",
"indent": "off",
},
}, globalIgnores(["**/dist/", "dist/"])]);
+21 -16
View File
@@ -2,10 +2,11 @@
"name": "root",
"private": true,
"files": [],
"packageManager": "pnpm@10.28.1+sha512.7d7dbbca9e99447b7c3bf7a73286afaaf6be99251eb9498baefa7d406892f67b879adb3a1d7e687fc4ccc1a388c7175fbaae567a26ab44d1067b54fcb0d6a316",
"scripts": {
"build": "tsc --build",
"watch": "tsc --build --watch",
"build-mod": "node ./packages/lib/build_mod.js --source-dir ./packages/host/lua/clusterio_lib",
"build-mod": "pnpm run -r build-mod --output-dir ../../dist",
"test": "pnpm run build && mocha --enable-source-maps --check-leaks -R spec --recursive test \"plugins/*/test/**\" --exclude \"test/manual/**\"",
"silent-test": "SILENT_TEST=y pnpm test",
"fast-test": "FAST_TEST=y pnpm test",
@@ -27,7 +28,7 @@
]
},
"engines": {
"node": ">=18"
"node": ">=20"
},
"workspaces": [
"packages/*",
@@ -41,21 +42,25 @@
"@clusterio/host": "workspace:*",
"@clusterio/lib": "workspace:*",
"@clusterio/web_ui": "workspace:*",
"@swc/core": "^1.11.1",
"@typescript-eslint/eslint-plugin": "^6.4.1",
"@typescript-eslint/parser": "^6.4.1",
"eslint": "^8.57.1",
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "^9.32.0",
"@stylistic/eslint-plugin": "^5.2.2",
"@swc/core": "^1.13.3",
"@typescript-eslint/eslint-plugin": "^8.38.0",
"@typescript-eslint/parser": "^8.38.0",
"eslint": "^9.32.0",
"eslint-plugin-node": "^11.1.0",
"express": "^4.21.2",
"form-data": "^4.0.2",
"express": "^5.1.0",
"form-data": "^4.0.4",
"fs-extra": "^11.3.0",
"jsonwebtoken": "^9.0.1",
"jszip": "^3.10.0",
"mocha": "^10.8.2",
"nyc": "^15.1.0",
"phin": "^3.7.0",
"typedoc": "^0.26.11",
"typescript": "^5.7.3",
"yargs": "^17.7.2"
"globals": "^16.3.0",
"jsonwebtoken": "^9.0.2",
"jszip": "^3.10.1",
"mocha": "^11.7.1",
"nyc": "^17.1.0",
"phin": "^3.7.1",
"typedoc": "^0.28.9",
"typescript": "^5.9.2",
"yargs": "^18.0.0"
}
}
+39 -10
View File
@@ -127,12 +127,23 @@ void new lib.Gauge(
async function handleBootstrapCommand(
args: any,
controllerConfig: lib.ControllerConfig
controllerConfig: lib.ControllerConfig,
controllerConfigLock: lib.LockFile,
): Promise<void> {
let subCommand = args._[1];
let userManager = new UserManager(controllerConfig);
await userManager.load(path.join(controllerConfig.get("controller.database_directory"), "users.json"));
const databaseDirectory = controllerConfig.get("controller.database_directory");
const roles = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
path.join(databaseDirectory, "roles.json"),
lib.Role.fromJSON.bind(lib.Role),
).bootstrap());
const userManager = new UserManager(controllerConfig, roles);
await userManager.load(path.join(databaseDirectory, "users.json"));
if (subCommand === "create-admin") {
await controllerConfigLock.acquire(); // Also needed to write to the database files
if (!args.name) {
logger.error("name cannot be blank");
process.exitCode = 1;
@@ -144,10 +155,11 @@ async function handleBootstrapCommand(
admin = userManager.createUser(args.name);
}
let adminRole = lib.ensureDefaultAdminRole(userManager.roles);
let adminRole = lib.ensureDefaultAdminRole(roles);
admin.roleIds.add(adminRole.id);
admin.isAdmin = true;
await userManager.save(path.join(controllerConfig.get("controller.database_directory"), "users.json"));
await controllerConfigLock.release();
} else if (subCommand === "generate-user-token") {
let user = userManager.getByName(args.name);
@@ -243,6 +255,7 @@ async function initialize(): Promise<InitializeParameters> {
default: "plugin-list.json",
type: "string",
})
.option("bypass-lock-file", { hidden: true, type: "boolean", nargs: 0, default: false })
.command("plugin", "Manage available plugins", lib.pluginCommand)
.command("config", "Manage Controller config", lib.configCommand)
.command("bootstrap", "Bootstrap access to cluster", yargs => {
@@ -273,6 +286,7 @@ async function initialize(): Promise<InitializeParameters> {
type: "boolean", nargs: 0, default: false,
describe: "Start the controller in recovery mode with all plugins disabled and hosts disconnected",
});
yargs.option("check-user-count", { hidden: true, type: "boolean", nargs: 0, default: true });
yargs.option("dev", { hidden: true, type: "boolean", nargs: 0 });
yargs.option("dev-plugin", { hidden: true, type: "array" });
})
@@ -342,8 +356,14 @@ async function initialize(): Promise<InitializeParameters> {
lib.registerPluginMessages(pluginInfos);
lib.addPluginConfigFields(pluginInfos);
let controllerConfig;
const controllerConfigPath = args.config;
const controllerConfigLockPath = `${controllerConfigPath}.lock`;
if (args.bypassLockFile) {
await fs.unlink(controllerConfigLockPath).catch(() => {}); // ignore error, file might not exist
}
let controllerConfig;
const controllerConfigLock = new lib.LockFile(controllerConfigLockPath);
logger.info(`Loading config from ${controllerConfigPath}`);
try {
controllerConfig = await lib.ControllerConfig.fromFile("controller", controllerConfigPath);
@@ -369,10 +389,11 @@ async function initialize(): Promise<InitializeParameters> {
}
if (command === "config") {
await lib.handleConfigCommand(args, controllerConfig);
await lib.handleConfigCommand(args, controllerConfig, controllerConfigLock);
} else if (command === "bootstrap") {
await handleBootstrapCommand(args, controllerConfig);
await handleBootstrapCommand(args, controllerConfig, controllerConfigLock);
} else if (shouldRun) {
await controllerConfigLock.acquire(); // Hold the lock until process exit
}
return {
@@ -409,12 +430,20 @@ async function startup() {
...await Controller.bootstrap(controllerConfig)
);
// Refuse to start if there are no users loaded
if (args.checkUserCount && controller.userManager.users.size === 0) {
logger.fatal(
"Cannot start controller, no users loaded.\n" +
"Try `npx clusteriocontroller bootstrap create-admin <username>`");
process.exit(1);
}
let secondSigint = false;
process.on("SIGINT", () => {
if (secondSigint) {
setBlocking(true);
logger.fatal("Caught second interrupt, terminating immediately");
// eslint-disable-next-line node/no-process-exit
process.exit(1);
}
@@ -427,7 +456,7 @@ async function startup() {
if (secondSigterm) {
setBlocking(true);
logger.fatal("Caught second termination, terminating immediately");
// eslint-disable-next-line node/no-process-exit
process.exit(1);
}
+30 -30
View File
@@ -3,10 +3,10 @@
"description": "Implementation of Clusterio controller",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.21",
"version": "2.0.0-alpha.22b",
"main": "dist/node/index.js",
"scripts": {
"prepare": "tsc --build && webpack-cli --env production",
"prepare": "tsc --build && webpack-cli --mode production",
"test": "echo \"Error: no test specified\" && exit 1"
},
"bin": {
@@ -17,45 +17,45 @@
"factorio"
],
"engines": {
"node": ">=18"
"node": ">=20"
},
"dependencies": {
"@clusterio/lib": "workspace:*",
"@sinclair/typebox": "^0.30.4",
"@sinclair/typebox": "catalog:",
"set-blocking": "catalog:",
"jsonwebtoken": "catalog:",
"express": "catalog:",
"fs-extra": "catalog:",
"ws": "catalog:",
"jszip": "catalog:",
"winston": "catalog:",
"winston-daily-rotate-file": "catalog:",
"busboy": "^1.6.0",
"compression": "^1.8.0",
"express": "^4.21.2",
"finalhandler": "^1.3.1",
"fs-extra": "^11.3.0",
"jsonwebtoken": "^9.0.1",
"jszip": "^3.10.1",
"set-blocking": "^2.0.0",
"winston": "^3.17.0",
"winston-daily-rotate-file": "^4.7.1",
"ws": "^8.18.1",
"yargs": "^17.7.2"
},
"devDependencies": {
"@clusterio/web_ui": "workspace:*",
"@swc/core": "^1.11.1",
"@types/busboy": "^1.5.0",
"@types/compression": "^1.7.2",
"@types/express": "^4.17.17",
"@types/finalhandler": "^1.2.0",
"@types/fs-extra": "^11.0.1",
"@types/jsonwebtoken": "^9.0.9",
"@types/node": "^20.17.19",
"@types/set-blocking": "^2.0.0",
"@types/ws": "^8.5.14",
"@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",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.29.0",
"typescript": "^5.7.3",
"webpack": "^5.98.0",
"webpack-cli": "^5.1.4",
"webpack-dev-middleware": "^6.1.1",
"webpack-merge": "^5.9.0"
"@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-merge": "catalog:",
"webpack-dev-middleware": "^6.1.1"
},
"publishConfig": {
"access": "public"
+2 -2
View File
@@ -16,7 +16,7 @@ import * as routes from "./routes";
* @alias module:controller/src/BaseConnection
*/
export default class BaseConnection extends lib.Link {
declare ["connector"]: WsServerConnector;
declare "connector": WsServerConnector;
constructor(
connector: lib.WebSocketBaseConnector,
@@ -78,7 +78,7 @@ export default class BaseConnection extends lib.Link {
return modPack;
}
getMod(mod: { name: string, version: string, 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) {
+191 -57
View File
@@ -100,6 +100,8 @@ export default class ControlConnection extends BaseConnection {
this.handle(lib.ModListRequest, this.handleModListRequest.bind(this));
this.handle(lib.ModSearchRequest, this.handleModSearchRequest.bind(this));
this.handle(lib.ModPortalGetAllRequest, this.handleModPortalGetAllRequest.bind(this));
this.handle(lib.ModPortalDownloadRequest, this.handleModPortalDownloadRequest.bind(this));
this.handle(lib.ModDependencyResolveRequest, this.handleModDependencyResolveRequest.bind(this));
this.handle(lib.ModDeleteRequest, this.handleModDeleteRequest.bind(this));
this.handle(lib.LogSetSubscriptionsRequest, this.handleLogSetSubscriptionsRequest.bind(this));
this.handle(lib.LogQueryRequest, this.handleLogQueryRequest.bind(this));
@@ -124,7 +126,7 @@ export default class ControlConnection extends BaseConnection {
this.handle(lib.PluginUpdateRequest, this.handlePluginUpdateRequest.bind(this));
this.handle(lib.PluginInstallRequest, this.handlePluginInstallRequest.bind(this));
this.handle(lib.DebugDumpWsRequest, this.handleDebugDumpWsRequest.bind(this));
this.handle(lib.ModPortalDownloadRequest, this.handleModPortalDownloadRequest.bind(this));
this.handle(lib.FactorioVersionsRequest, this.handleFactorioVersionsRequest.bind(this));
}
validateIngress(message: lib.MessageRequest | lib.MessageEvent) {
@@ -313,7 +315,7 @@ export default class ControlConnection extends BaseConnection {
let stream = await routes.createProxyStream(this._controller.app);
stream.filename = name;
let ready = new Promise((resolve, reject) => {
let ready = new Promise<void>((resolve, reject) => {
stream.events.on("source", resolve);
stream.events.on("timeout", () => reject(
new lib.RequestError("Timed out establishing stream from host")
@@ -564,52 +566,181 @@ export default class ControlConnection extends BaseConnection {
* Handle request to download a mod from the Factorio Mod Portal to the controller.
* @param request - Request object with mod name, version, and factorioVersion.
*/
async handleModPortalDownloadRequest(request: lib.ModPortalDownloadRequest): Promise<lib.ModInfo> {
const { name, version, factorioVersion } = request;
const filename = lib.ModInfo.filename(name, version);
async handleModPortalDownloadRequest(request: lib.ModPortalDownloadRequest) {
const factorioVersion = request.factorioVersion;
const toDownload = this._controller.modStore.filterInstalled(request.mods, false);
// Check if mod already exists
if (this._controller.modStore.files.has(filename)) {
logger.verbose(`Mod ${filename} already exists, skipping download.`);
return this._controller.modStore.files.get(filename)!;
}
if (toDownload.length > 0) {
// Get Factorio credentials from config
const username = this._controller.config.get("controller.factorio_username");
const token = this._controller.config.get("controller.factorio_token");
// Get Factorio credentials from config
const username = this._controller.config.get("controller.factorio_username");
const token = this._controller.config.get("controller.factorio_token");
if (!username || !token) {
throw new lib.RequestError("Factorio credentials (username, token) not configured on the controller.");
}
try {
logger.info(`Downloading mod ${filename} for Factorio ${factorioVersion} from portal.`);
const modMap = new Map([[name, version]]);
await this._controller.modStore.downloadMods(modMap, username || "", token || "", factorioVersion);
// Verify download and return ModInfo
const downloadedMod = this._controller.modStore.files.get(filename);
if (!downloadedMod) {
throw new lib.RequestError(`Failed to find mod ${filename} in store after download attempt.`);
if (!username || username === "" || !token || token === "") {
throw new lib.RequestError("Factorio credentials (username, token) not configured on the controller.");
}
logger.info(`Successfully downloaded mod ${filename}.`);
return downloadedMod;
} catch (error: any) {
logger.error(`Error downloading mod ${filename} from portal: ${error.message}`);
// Improve error message clarity
let errorMessage = `Mod portal download failed for ${name} v${version}`;
if (error instanceof lib.RequestError) {
errorMessage += `: ${error.message}`;
} else if (error.message.includes("401") || error.message.includes("Unauthorized")) {
errorMessage += ": Authentication failed. Check Factorio username/token in controller config.";
} else if (error.message.includes("404") || error.message.includes("Not Found")) {
errorMessage += ": Mod version not found on the portal.";
try {
logger.info(`Downloading ${toDownload.length} mods for Factorio ${factorioVersion} from portal.`);
await this._controller.modStore.downloadMods(toDownload, username, token, factorioVersion);
} catch (error: any) {
logger.error(`Error downloading mods from portal: ${error.message}`);
// Improve error message clarity
let errorMessage = "Mod portal download failed";
if (error instanceof lib.RequestError) {
errorMessage += `: ${error.message}`;
} else if (error.message.includes("401") || error.message.includes("Unauthorized")) {
errorMessage += ": Authentication failed. Check Factorio username/token in controller config.";
} else if (error.message.includes("404") || error.message.includes("Not Found")) {
errorMessage += ": Mod version not found on the portal.";
} else {
errorMessage += `: ${error.message}`;
}
throw new lib.RequestError(errorMessage);
}
}
// Verify all mods are present
const modInfos = [];
const installedMods = [...this._controller.modStore.mods()];
for (const mod of request.mods) {
const modInfo = installedMods.find(m => m.name === mod.name && mod.version.testVersion(m.version));
if (!modInfo) {
throw new lib.RequestError(`Failed to find mod ${mod.name} in store after download attempt.`);
}
modInfos.push(modInfo);
}
return modInfos;
}
async handleModDependencyResolveRequest(request: lib.ModDependencyResolveRequest) {
const {factorioVersion, checkForUpdates} = request;
const searchQueue = [...request.mods];
const localInfos = [...this._controller.modStore.mods()];
const _builtInModNames = lib.ModPack.getBuiltinModNames(factorioVersion);
const builtInModNames = new Set(_builtInModNames);
const skipSearch = new Set(_builtInModNames);
type ModPortalModType = InstanceType<typeof lib.ModPortalGetAllRequest.Response>["mods"][number];
type ModPortalReleaseType = NonNullable<ModPortalModType["releases"]>[number];
const dependencyRequirements = new Map<string, lib.ModVersionRange>();
const optionalRequirements = new Map<string, lib.ModVersionRange>();
const candidateReleases = new Map<string, lib.ModInfo>();
const incompatible = new Set<string>();
const invalid = new Set<string>();
const _modPortalReleases = new Map<string, ModPortalModType | null>();
async function fetchModReleases(modName: string) {
let releases = _modPortalReleases.get(modName);
if (releases === undefined) {
try {
releases = await lib.ModStore.fetchModReleases(modName);
} catch (error: any) {
if (!error.message.includes("404") && !error.message.includes("Not Found")) {
logger.error(`Error fetching mod from portal: ${error.message}`);
throw new lib.RequestError(`Dependency resolution failed: ${error.message}`);
} else {
releases = null;
}
}
}
_modPortalReleases.set(modName, releases);
return releases;
}
while (searchQueue.length > 0) {
const mod = searchQueue.shift()!;
if (mod.incompatible) {
incompatible.add(mod.name);
continue;
}
// 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);
}
}
// Update the version range if needed
if (mod.version) {
versionRange.combineVersion(mod.version);
if (!versionRange.valid) {
candidateReleases.delete(mod.name);
invalid.add(mod.name);
}
}
// Do not add dependencies for invalids, optionals, skips, or valid candidates
const existingCandidate = candidateReleases.get(mod.name);
if (invalid.has(mod.name) || optionalRequirements.has(mod.name) || skipSearch.has(mod.name) || (
existingCandidate && versionRange.testVersion(existingCandidate.version)
)) {
continue;
}
// Check if a local version fits the requirements
let candidate = localInfos
.filter(info => (info.name === mod.name && versionRange.testVersion(info.version)))
.reduce<lib.ModInfo | undefined>((max, cur) => (
max && max.integerVersion > cur.integerVersion ? max : cur
), undefined);
// Check if a mod poral version fits the requirements
if (!candidate || checkForUpdates) {
const modReleases = await fetchModReleases(mod.name);
if (modReleases && modReleases.releases) {
const release = modReleases.releases
.filter(info => (
info.info_json.factorio_version === factorioVersion
&& versionRange.testVersion(info.version)
))
.reduce<ModPortalReleaseType | undefined>((max, cur) => (
max && lib.integerFullVersion(max.version) > lib.integerFullVersion(cur.version) ? max : cur
), undefined);
// We only select the mod portal release if it is more recent than the local candidate
if (release && (!candidate || lib.integerFullVersion(release.version) > candidate.integerVersion)) {
candidate = lib.ModInfo.fromJSON({
...release.info_json, // Also includes dependences
sha1: release.sha1,
version: release.version,
name: modReleases.name,
author: modReleases.owner,
title: modReleases.title,
});
}
}
}
// Queue the dependencies of the new candidate
if (candidate) {
candidateReleases.set(mod.name, candidate);
for (const dep of candidate.dependencies) {
searchQueue.push(dep);
}
} else {
errorMessage += `: ${error.message}`;
candidateReleases.delete(mod.name);
skipSearch.add(mod.name);
}
throw new lib.RequestError(errorMessage);
}
return new lib.ModDependencyResolveRequest.Response(
[...candidateReleases.values()],
[...invalid.values(), ...[...incompatible.values()].filter(mod => (
candidateReleases.has(mod) || builtInModNames.has(mod)
))],
[...dependencyRequirements.keys()].filter(mod => (
!candidateReleases.has(mod) && !incompatible.has(mod) && !invalid.has(mod)
)),
);
}
async handleModDeleteRequest(request: lib.ModDeleteRequest) {
@@ -671,22 +802,21 @@ export default class ControlConnection extends BaseConnection {
}
async handleRoleListRequest() {
return [...this._controller.userManager.roles.values()];
return [...this._controller.roles.values()];
}
async handleRoleCreateRequest(request: lib.RoleCreateRequest) {
let lastId = Math.max.apply(null, [...this._controller.userManager.roles.keys()]);
let lastId = Math.max.apply(null, [...this._controller.roles.keys()]);
// Start at 5 to leave space for future default roles
let id = Math.max(5, lastId+1);
this._controller.userManager.roles.set(id, lib.Role.fromJSON({ id, ...request }));
this._controller.userManager.dirty = true;
this._controller.roles.set(lib.Role.fromJSON({ id, ...request }));
return id;
}
async handleRoleUpdateRequest(request: lib.RoleUpdateRequest) {
let { id, name, description, permissions } = request;
let role = this._controller.userManager.roles.get(id);
let role = this._controller.roles.getMutable(id);
if (!role) {
throw new lib.RequestError(`Role with ID ${id} does not exist`);
}
@@ -694,27 +824,29 @@ export default class ControlConnection extends BaseConnection {
role.name = name;
role.description = description;
role.permissions = new Set(permissions);
this._controller.rolePermissionsUpdated(role);
this._controller.roles.set(role);
}
async handleRoleGrantDefaultPermissionsRequest(request: lib.RoleGrantDefaultPermissionsRequest) {
let role = this._controller.userManager.roles.get(request.id);
let role = this._controller.roles.get(request.id);
if (!role) {
throw new lib.RequestError(`Role with ID ${request.id} does not exist`);
}
role.grantDefaultPermissions();
this._controller.rolePermissionsUpdated(role);
this._controller.roles.set(role);
}
async handleRoleDeleteRequest(request: lib.RoleDeleteRequest) {
let id = request.id;
if (!this._controller.userManager.roles.delete(id)) {
const role = this._controller.roles.get(id);
if (!role) {
throw new lib.RequestError(`Role with ID ${id} does not exist`);
}
this._controller.userManager.dirty = true;
this._controller.roles.delete(role);
this._controller.userManager.dirty = true;
for (let user of this._controller.userManager.users.values()) {
if (user.roleIds.delete(id)) {
this._controller.userPermissionsUpdated(user);
@@ -766,7 +898,7 @@ export default class ControlConnection extends BaseConnection {
}
for (let roleId of request.roles) {
if (!this._controller.userManager.roles.has(roleId)) {
if (!this._controller.roles.has(roleId)) {
throw new lib.RequestError(`Role with ID ${roleId} does not exist`);
}
}
@@ -900,7 +1032,7 @@ export default class ControlConnection extends BaseConnection {
}
// A large number of small if statements are unavoidable when reducing the number of updates sent
// eslint-disable-next-line complexity
async handleUserBulkImportRequest(request: lib.UserBulkImportRequest) {
let backup: undefined | Awaited<ReturnType<ControlConnection["handleUserBulkExportRequest"]>>;
const updated = new Map<ControllerUser["id"], ControllerUser>();
@@ -1057,7 +1189,7 @@ export default class ControlConnection extends BaseConnection {
if (!this._controller.config.get("controller.allow_remote_updates")) {
throw new lib.RequestError("Remote updates are disabled on this machine");
}
return lib.updatePackage("@clusterio/controller");
return await lib.updatePackage("@clusterio/controller");
}
async handlePluginUpdateRequest(request: lib.PluginUpdateRequest) {
@@ -1078,9 +1210,7 @@ export default class ControlConnection extends BaseConnection {
return this._controller.pluginInfos.map(pluginInfo => lib.PluginDetails.fromNodeEnvInfo(
pluginInfo,
this._controller.plugins.has(pluginInfo.name),
this._controller.config.get(
`${pluginInfo.name}.load_plugin` as keyof lib.ControllerConfigFields
) as boolean,
this._controller.config.get(`${pluginInfo.name}.load_plugin`),
));
}
@@ -1093,6 +1223,10 @@ export default class ControlConnection extends BaseConnection {
this.connector._socket!.clusterio_ignore_dump = true;
this._controller.debugEvents.on("message", this.ws_dumper);
}
async handleFactorioVersionsRequest(request: lib.FactorioVersionsRequest) {
return await this._controller.factorioVersions.get(request.maxAgeMs);
}
}
module.exports = ControlConnection;
+131 -43
View File
@@ -6,7 +6,7 @@ import type { Static } from "@sinclair/typebox";
import finalhandler from "finalhandler";
import compression from "compression";
import events, { EventEmitter } from "events";
import events from "events";
import fs from "fs-extra";
import http from "http";
import https from "https";
@@ -42,6 +42,14 @@ const logSizeGauge = new Gauge(
type InstanceId = { instanceId: number };
type ControllerDebugEvents = {
"message": [ { direction: "in" | "out", content: string } ],
};
type ControllerEvents = {
"stop": [],
}
/**
* Manages all controller related operations
* @alias module:controller/src/Controller
@@ -66,8 +74,8 @@ export default class Controller {
wsServer: WsServer;
router = new ControllerRouter(this);
trustedProxies: BlockList;
debugEvents: events.EventEmitter = new events.EventEmitter();
private _events: events.EventEmitter = new events.EventEmitter();
debugEvents = new events.EventEmitter<ControllerDebugEvents>();
private _events = new events.EventEmitter<ControllerEvents>();
/** Event subscription controller */
subscriptions = new lib.SubscriptionController();
@@ -97,6 +105,8 @@ export default class Controller {
// Cache for mod portal requests
modPortalCache = new Map<string, { timestamp: number, data: any[] }>();
// Cache for factorio versions
factorioVersions = new lib.ValueCache(lib.fetchFactorioVersions);
static async bootstrap(config: lib.ControllerConfig) {
let databaseDirectory = config.get("controller.database_directory");
@@ -127,20 +137,32 @@ export default class Controller {
const modPacks = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
path.join(databaseDirectory, "mod-packs.json"),
lib.ModPack.fromJSON.bind(lib.ModPack)
lib.ModPack.fromJSON.bind(lib.ModPack),
this.migrateModPacks,
).bootstrap());
if (modPacks.size === 0) {
modPacks.setMany(lib.ModPack.defaultModPacks);
}
const roles = new lib.SubscribableDatastore(...await new lib.JsonIdDatastoreProvider(
path.join(databaseDirectory, "roles.json"),
lib.Role.fromJSON.bind(lib.Role),
this.migrateRoles,
).bootstrap());
const userManager = new UserManager(config);
const userManager = new UserManager(config, roles);
await userManager.load(path.join(databaseDirectory, "users.json"));
let modsDirectory = config.get("controller.mods_directory");
await fs.ensureDir(modsDirectory);
const modStore = await lib.ModStore.fromDirectory(modsDirectory);
// Add default mod packs
if (modPacks.size === 0) {
modPacks.setMany(lib.ModPack.defaultModPacks);
}
// Add default roles
lib.ensureDefaultAdminRole(roles);
lib.ensureDefaultPlayerRole(roles);
return [
systems,
hosts,
@@ -148,6 +170,7 @@ export default class Controller {
saves,
modPacks,
modStore,
roles,
userManager,
] as const;
}
@@ -178,8 +201,10 @@ export default class Controller {
public modPacks = new lib.SubscribableDatastore<lib.ModPack>(),
/** Mods stored on the controller */
public modStore = new lib.ModStore(config.get("controller.mods_directory"), new Map()),
/** Mapping of mod pack id to mod pack */
public roles = new lib.SubscribableDatastore<lib.Role>(),
/** User and roles manager for the cluster */
public userManager = new UserManager(config),
public userManager = new UserManager(config, roles),
) {
this.clusterLogger = clusterLogger;
this.pluginInfos = pluginInfos;
@@ -206,6 +231,7 @@ export default class Controller {
this.subscriptions.handle(lib.ModPackUpdatesEvent, this.handleModPackSubscription.bind(this));
this.subscriptions.handle(lib.ModUpdatesEvent, this.handleModSubscription.bind(this));
this.subscriptions.handle(lib.UserUpdatesEvent, this.handleUserSubscription.bind(this));
this.subscriptions.handle(lib.RoleUpdatesEvent, this.handleRoleSubscription.bind(this));
// Handle updates for datastores
this.systems.on("update", this.systemsUpdated.bind(this));
@@ -213,6 +239,7 @@ export default class Controller {
this.instances.on("update", this.instanceDetailsUpdated.bind(this));
this.saves.on("update", this.savesUpdated.bind(this));
this.modPacks.on("update", this.modPacksUpdated.bind(this));
this.roles.on("update", this.rolesUpdated.bind(this));
}
async start(args: ControllerArgs) {
@@ -375,13 +402,12 @@ export default class Controller {
async _startDevServer(args: ControllerArgs) {
logger.warn("Webpack development mode enabled");
/* eslint-disable @typescript-eslint/no-var-requires, node/no-unpublished-require */
const webpack = require("webpack");
const webpackDevMiddleware = require("webpack-dev-middleware");
const webpackConfigs = [];
if (args.dev) {
// eslint-disable-next-line node/no-missing-require
webpackConfigs.push(require("../../../webpack.config")({})); // Path outside of build
}
if (args.devPlugin) {
@@ -397,7 +423,7 @@ export default class Controller {
}
this.app.locals.devPlugins = devPlugins;
}
/* eslint-enable @typescript-eslint/no-var-requires, node/no-unpublished-require */
const compiler = webpack(webpackConfigs);
this.devMiddleware = webpackDevMiddleware(compiler, { serverSideRender: true });
@@ -526,19 +552,8 @@ export default class Controller {
}
requests.push(hostConnection.send(new lib.SystemInfoRequest()));
}
if (!this.config.restartRequired) {
// If a restart isn't already required, then test if a new version is installed
try {
const runningVersion = this.config.get("controller.version");
const packageJson = await fs.readJSON(path.join(__dirname, "..", "package.json"));
if (runningVersion !== packageJson.version) {
this.config.restartRequired = true;
}
} catch (err: any) {
logger.warn(`Failed to read package json:\n${err.stack ?? err.message}`);
}
}
requests.push(lib.gatherSystemInfo("controller", this.canRestart, this.config.restartRequired));
const restartRequired = await this.checkRestartRequired();
requests.push(lib.gatherSystemInfo("controller", this.canRestart, restartRequired));
const newMetrics = await Promise.all(requests);
for (const metric of newMetrics) {
this.systems.set(metric);
@@ -548,6 +563,46 @@ export default class Controller {
}
}
async checkRestartRequired() {
if (this.config.restartRequired) {
return true;
}
let packageName = "@clusterio/controller";
try {
// First check the clusterio version
const runningVersion = this.config.get("controller.version");
const packageJsonPath = require.resolve("@clusterio/controller/package.json");
const packageJson = await fs.readJSON(packageJsonPath);
if (runningVersion !== packageJson.version) {
this.config.restartRequired = true;
return true;
}
// Second check plugin versions
for (const pluginInfo of this.pluginInfos) {
if (
!pluginInfo.controllerEntrypoint && !pluginInfo.webEntrypoint
|| !this.config.get(`${pluginInfo.name}.load_plugin`)
) {
continue;
}
packageName = pluginInfo.npmPackage ?? pluginInfo.name;
const pluginPackageJsonPath = require.resolve(path.posix.join(pluginInfo.requirePath, "package.json"));
const pluginPackageJson = await fs.readJSON(pluginPackageJsonPath);
if (pluginInfo.version !== pluginPackageJson.version) {
this.config.restartRequired = true;
return true;
}
}
} catch (err: any) {
logger.warn(`Failed to read package json for ${packageName}:\n${err.stack ?? err.message}`);
}
return false;
}
onAutosaveIntervalChanged() {
if (this.autosaveInterval) {
clearInterval(this.autosaveInterval);
@@ -589,6 +644,7 @@ export default class Controller {
this.instances.save(),
this.saves.save(),
this.modPacks.save(),
this.roles.save(),
]);
if (this.userManager.dirty) {
@@ -649,6 +705,31 @@ export default class Controller {
return instance;
}
static migrateModPacks(rawJson: unknown[]): Static<typeof lib.ModPack.jsonSchema>[] {
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 - json schema now enforces X.Y.Z for mod version, builtins would use X.Y only
mod.version = lib.normaliseFullVersion(mod.version);
}
return json;
});
}
static migrateRoles(rawJson: unknown[]): Static<typeof lib.Role.jsonSchema>[] {
const serialized = rawJson as Static<typeof lib.Role.jsonSchema>[];
return serialized.map(json => {
json.permissions = json.permissions.map(permission => {
// migrate: core.instance.save.list.subscribe was renamed in alpha 17
if (permission === "core.instance.save.list.subscribe") {
return "core.instance.save.subscribe";
}
return permission;
});
return json;
});
}
static async loadJsonObject(filePath: string, throwOnMissing: boolean = false): Promise<any> {
let manifest = {};
try {
@@ -1052,27 +1133,34 @@ export default class Controller {
}
}
/**
* Notify connected control clients with the given role that the
* permissions may have changed.
* @param role - Role permisions updated for.
*/
rolePermissionsUpdated(role: lib.Role) {
for (let controlConnection of this.wsServer.controlConnections.values()) {
if (controlConnection.user.roles.has(role)) {
controlConnection.send(
new lib.AccountUpdateEvent(
[...controlConnection.user.roles].map(r => ({
name: r.name,
id: r.id,
permissions: [...r.permissions],
}))
)
);
rolesUpdated(roles: lib.Role[]) {
this.subscriptions.broadcast(new lib.RoleUpdatesEvent(roles));
// lib.invokeHook(this.plugins, "onRolesUpdated", roles); // This doesn't exist at the moment
// Notify connected control clients with the given role that the permissions may have changed.
for (const role of roles) {
for (let controlConnection of this.wsServer.controlConnections.values()) {
if (controlConnection.user.roles.has(role)) {
controlConnection.send(
new lib.AccountUpdateEvent(
[...controlConnection.user.roles].map(r => ({
name: r.name,
id: r.id,
permissions: [...r.permissions],
}))
)
);
}
}
}
}
async handleRoleSubscription(request: lib.SubscriptionRequest) {
const roles = [...this.roles.values()].filter(
role => role.updatedAtMs > request.lastRequestTimeMs,
);
return roles.length ? new lib.RoleUpdatesEvent(roles) : null;
}
async loadPlugins() {
for (let pluginInfo of this.pluginInfos) {
try {
@@ -1082,7 +1170,7 @@ export default class Controller {
logger.warn(`Unable to load dist/web/manifest.json for plugin ${pluginInfo.name}`);
}
if (!this.config.get(`${pluginInfo.name}.load_plugin` as keyof lib.ControllerConfigFields)) {
if (!this.config.get(`${pluginInfo.name}.load_plugin`)) {
continue;
}
+10 -7
View File
@@ -1,10 +1,13 @@
import { Static, Type } from "@sinclair/typebox";
import { IControllerUser, PermissionError, PlayerStats, Role, User, permissions } from "@clusterio/lib";
import type UserManager from "./UserManager";
import {
IControllerUser, PermissionError, PlayerStats,
Role, SubscribableDatastore, User, permissions,
} from "@clusterio/lib";
export default class ControllerUser extends User implements IControllerUser {
constructor(
public userManager: UserManager,
private _controllerRoles: SubscribableDatastore<Role>,
/** Unix time in seconds the user token must be issued after to be valid. */
public tokenValidAfter = 0,
...args: ConstructorParameters<typeof User>
@@ -17,7 +20,7 @@ export default class ControllerUser extends User implements IControllerUser {
token_valid_after: Type.Optional(Type.Number()),
});
static fromJSON(json: Static<typeof this.jsonSchema>, userManager: UserManager) {
static fromJSON(json: Static<typeof this.jsonSchema>, controllerRoles: SubscribableDatastore<Role>) {
const roleIds = new Set<number>(json.roles ?? []);
const instanceStats = new Map(
(json.instance_stats ?? []).map(
@@ -26,7 +29,7 @@ export default class ControllerUser extends User implements IControllerUser {
);
const playerStats = User._calculatePlayerStats(instanceStats);
return new this(
userManager,
controllerRoles,
json.token_valid_after,
json.name,
roleIds,
@@ -57,7 +60,7 @@ export default class ControllerUser extends User implements IControllerUser {
get roles(): ReadonlySet<Readonly<Role>> {
return new Set([...this.roleIds].map(
id => this.userManager.roles.get(id)
id => this._controllerRoles.get(id)
).filter(
(r): r is Role => Boolean(r)
));
@@ -79,7 +82,7 @@ export default class ControllerUser extends User implements IControllerUser {
}
for (let roleId of this.roleIds) {
let role = this.userManager.roles.get(roleId);
let role = this._controllerRoles.get(roleId);
if (!role) {
continue;
}
+1 -1
View File
@@ -47,7 +47,7 @@ export default class HostConnection extends BaseConnection {
this._checkPluginVersions();
for (let event of ["connect", "drop", "resume", "close"] as const) {
// eslint-disable-next-line no-loop-func
this.connector.on(event, () => {
for (let plugin of this._controller.plugins.values()) {
plugin.onHostConnectionEvent(this, event);
+3 -2
View File
@@ -11,7 +11,7 @@ export default class InstanceInfo {
public config: lib.InstanceConfig,
public status: lib.InstanceStatus,
public gamePort?: number,
public factorioVersion?: string,
public factorioVersion?: lib.TargetVersion,
public updatedAtMs = 0,
) {
this.config = config;
@@ -23,7 +23,7 @@ export default class InstanceInfo {
"status": lib.InstanceStatus,
"gamePort": Type.Optional(Type.Number()),
"updatedAtMs": Type.Optional(Type.Number()),
"factorioVersion": Type.Optional(Type.String()),
"factorioVersion": Type.Optional(lib.TargetVersionSchema),
});
static fromJSON(json: Static<typeof this.jsonSchema>, location: lib.ConfigLocation) {
@@ -45,6 +45,7 @@ export default class InstanceInfo {
this.status,
this.factorioVersion,
this.updatedAtMs,
this.config.get("instance.exclude_from_start_all"),
);
}
+13 -28
View File
@@ -10,7 +10,6 @@ import ControllerUser from "./ControllerUser";
* @alias module:controller/src/UserManager
*/
export default class UserManager {
roles: Map<lib.Role["id"], lib.Role> = new Map();
users: Map<ControllerUser["id"], ControllerUser> = new Map();
dirty = false;
@@ -20,7 +19,8 @@ export default class UserManager {
onlineUsers = new Set<ControllerUser>();
constructor(
private _config: lib.ControllerConfig
private _config: lib.ControllerConfig,
private _roles: lib.SubscribableDatastore<lib.Role>,
) {
}
@@ -31,14 +31,15 @@ export default class UserManager {
async load(filePath: string): Promise<void> {
try {
let content = JSON.parse(await fs.readFile(filePath, { encoding: "utf8" }));
for (let serializedRole of content.roles) {
let role = lib.Role.fromJSON(serializedRole);
this.roles.set(role.id, role);
if ("roles" in content) { // Split file format after 2.0.0-alpha.22
this._roles.setMany(content.roles.map((serializedRole: any) => lib.Role.fromJSON(serializedRole)));
await this._roles.save(); // Force a save to prevent data loss
content = content.users;
}
let duplicates = 0;
for (let serializedUser of content.users) {
let user = ControllerUser.fromJSON(serializedUser, this);
for (const serializedUser of content) {
const user = ControllerUser.fromJSON(serializedUser, this._roles);
const existingUser = this.users.get(user.id);
if (existingUser) {
// Required migration to all lowercase ids in alpha 19
@@ -65,30 +66,14 @@ export default class UserManager {
if (err.code !== "ENOENT") {
throw err;
}
// Create default roles if loading failed
lib.ensureDefaultAdminRole(this.roles);
lib.ensureDefaultPlayerRole(this.roles);
}
}
async save(filePath: string): Promise<void> {
let serializedRoles = [];
for (let role of this.roles.values()) {
serializedRoles.push(role.toJSON());
}
let serializedUsers = [];
for (let user of this.users.values()) {
serializedUsers.push(user.toJSON(true));
}
let serialized = {
users: serializedUsers,
roles: serializedRoles,
};
this.dirty = false;
await lib.safeOutputFile(filePath, JSON.stringify(serialized, null, "\t"));
await lib.safeOutputFile(filePath,
JSON.stringify([...this.users.values()].map(user => user.toJSON(true)), null, "\t")
);
}
/**
@@ -103,11 +88,11 @@ export default class UserManager {
let roles = new Set<number>();
let defaultRoleId = this._config.get("controller.default_role_id");
if (defaultRoleId !== null && this.roles.has(defaultRoleId)) {
if (defaultRoleId !== null && this._roles.has(defaultRoleId)) {
roles.add(defaultRoleId);
}
let user = new ControllerUser(this, 0, name, roles);
let user = new ControllerUser(this._roles, 0, name, roles);
this.users.set(user.id, user);
this.dirty = true;
return user;
+5 -8
View File
@@ -1,6 +1,7 @@
import type WebSocket from "ws";
import events from "events";
import assert from "assert/strict";
import * as lib from "@clusterio/lib";
const { logger } = lib;
@@ -113,8 +114,7 @@ export default class WsServerConnector extends lib.WebSocketBaseConnector {
_timedOut() {
logger.verbose("Connector | Connection timed out");
this._reset();
this.emit("close");
this._close();
}
_attachSocketHandlers() {
@@ -130,8 +130,7 @@ export default class WsServerConnector extends lib.WebSocketBaseConnector {
this._socket = null;
if (this._closing) {
this._reset();
this.emit("close");
this._close();
} else {
this._state = "resuming";
@@ -187,8 +186,7 @@ export default class WsServerConnector extends lib.WebSocketBaseConnector {
}
if (this._state === "resuming") {
this._reset();
this.emit("close");
this._close();
return;
}
@@ -223,8 +221,7 @@ export default class WsServerConnector extends lib.WebSocketBaseConnector {
*/
setClosing() {
if (this._state === "resuming") {
this._reset();
this.emit("close");
this._close();
return;
}
+15 -4
View File
@@ -109,9 +109,7 @@ function getPlugins(req: Request, res: Response) {
for (let pluginInfo of req.app.locals.controller.pluginInfos) {
let name = pluginInfo.name;
let loaded = req.app.locals.controller.plugins.has(name);
let enabled = loaded && req.app.locals.controller.config.get(
`${pluginInfo.name}.load_plugin` as keyof lib.ControllerConfigFields,
) as unknown as boolean;
let enabled = loaded && req.app.locals.controller.config.get(`${pluginInfo.name}.load_plugin`);
// Note: Cast through unknown is needed because load_plugin is
// defined at runtime and no other fields in the controller config
// currently have the boolean type.
@@ -136,6 +134,12 @@ function getPlugins(req: Request, res: Response) {
res.send(plugins);
}
function getClusterName(req: Request, res: Response) {
const controller: Controller = req.app.locals.controller;
const clusterName = controller.config.get("controller.name");
res.json({ name: clusterName });
}
function validateHostToken(req: Request, res: Response, next: any) {
let token = req.header("x-access-token");
if (!token) {
@@ -266,6 +270,12 @@ async function uploadExport(req: Request, res: Response) {
res.sendStatus(200);
}
type ProxyStreamEvents = {
"error": [ err: any ],
"close": [],
"timeout": [],
"source": [],
}
export interface ProxyStream {
id: string;
@@ -274,7 +284,7 @@ export interface ProxyStream {
size?: string;
mime?: string;
filename: string | null;
events: events.EventEmitter;
events: events.EventEmitter<ProxyStreamEvents>;
timeout: ReturnType<typeof setTimeout>;
}
@@ -658,6 +668,7 @@ async function uploadMod(req: Request, res: Response) {
export function addRouteHandlers(app: Application) {
app.get("/metrics", (req:Request, res:Response, next:any) => getMetrics(req, res, next).catch(next));
app.get("/api/plugins", getPlugins);
app.get("/api/cluster-name", getClusterName);
app.put("/api/upload-export",
validateHostToken,
(req:Request, res:Response, next:any) => uploadExport(req, res).catch(next)
+1 -1
View File
@@ -5,7 +5,7 @@ const { merge } = require("webpack-merge");
const common = require("@clusterio/web_ui/webpack.common");
module.exports = (env = {}) => merge(common(env), {
module.exports = (env = {}, argv = {}) => merge(common(env, argv), {
context: __dirname,
entry: "./web/index.tsx",
devServer: {
+13 -9
View File
@@ -114,7 +114,7 @@ async function execFile(cmd, args) {
escaped,
{
shell: true,
// eslint-disable-next-line node/no-process-env
env: process.platform === "win32" ? { ...process.env, pct: "%" } : undefined,
},
(err, stdout, stderr) => {
@@ -396,7 +396,7 @@ async function installClusterio(mode, plugins) {
}
for (let plugin of plugins) {
if (!pluginList.has(plugin)) {
// eslint-disable-next-line node/global-require
let pluginInfo = require(require.resolve(plugin, { paths: [process.cwd()] })).plugin;
pluginList.set(pluginInfo.name, plugin);
}
@@ -689,7 +689,8 @@ async function inquirerMissingArgs(args) {
}
if (["standalone", "controller", "host"].includes(answers.mode)) {
answers = inquirer.prompt([
if (args.hasOwnProperty("remoteNpm")) { answers.remoteNpm = args.remoteNpm; }
answers = await inquirer.prompt([
{
type: "confirm",
name: "remoteNpm",
@@ -753,8 +754,8 @@ async function inquirerMissingArgs(args) {
async function downloadLinuxServer() {
let res = await fetch("https://factorio.com/get-download/stable/headless/linux64");
const url = new URL(res.headers.location);
// 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) {
@@ -771,10 +772,7 @@ async function downloadLinuxServer() {
if (await fs.pathExists(factorioDir)) {
logger.warn(`setting downloadDir to ${factorioDir}, but not downloading because already existing`);
} else {
await fs.ensureDir(tmpDir);
// follow the redirect
res = await fetch(url.href);
await fs.emptyDir(tmpDir);
logger.info("Downloading latest Factorio server release. This may take a while.");
const writeStream = fs.createWriteStream(tmpArchivePath);
@@ -793,8 +791,10 @@ async function downloadLinuxServer() {
throw e;
}
await fs.unlink(archivePath);
await fs.ensureDir("factorio");
await fs.rename(tmpFactorioDir, factorioDir);
await fs.unlink(archivePath);
await fs.rm(tmpDir);
}
}
@@ -820,6 +820,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/controller] [linux only]",
type: "boolean", nargs: 0,
})
.option("host-name", {
nargs: 1, describe: "Host name [host]", type: "string",
})
+1 -1
View File
@@ -50,7 +50,7 @@ function escapeArg(arg) {
// For all other platforms the command is /bin/sh -c <command arg arg ...>
// The command and its arguments are passed as a single argument to the shell.
// See https://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html#tag_18_02
// eslint-disable-next-line no-lonely-if
if (!/[|&;<>()$`\\"' \t*?\[#~=%]/.test(arg)) {
return arg;
}
+4 -4
View File
@@ -3,7 +3,7 @@
"description": "Installer for clusterio",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.21",
"version": "2.0.0-alpha.22b",
"main": "create.js",
"bin": {
"clusteriocreate": "./create.js"
@@ -13,11 +13,11 @@
"factorio"
],
"engines": {
"node": ">=18"
"node": ">=20"
},
"dependencies": {
"chalk": "^4.1.2",
"fs-extra": "^11.3.0",
"chalk": "catalog:",
"fs-extra": "catalog:",
"inquirer": "^8.2.4",
"yargs": "^17.7.2"
},
+17 -9
View File
@@ -3,6 +3,8 @@ const { logger } = require("./logging");
const fs = require("fs-extra");
const path = require("path");
const _package = require("./package.json");
async function copyTemplateFile(src, dst, properties) {
logger.verbose(`Writing ${dst} from template ${src}`);
@@ -92,8 +94,8 @@ async function copyPluginTemplates(pluginName, templates) {
const commonPath = path.resolve(__dirname, "./templates/common");
// Files included in all templates
files.set(".gitignore", path.join(commonPath, ".gitignore"));
files.set(".npmignore", path.join(commonPath, ".npmignore"));
files.set(".gitignore", path.join(commonPath, "template.gitignore"));
files.set(".npmignore", path.join(commonPath, "template.npmignore"));
files.set("package.json", path.join(commonPath, "package.json"));
files.set(`index.${ext}`, path.join(templatePath, `index.${ext}`));
@@ -102,6 +104,7 @@ async function copyPluginTemplates(pluginName, templates) {
prepare.push("tsc --build");
files.set("tsconfig.json", path.join(templatePath, "tsconfig.json"));
files.set("tsconfig.node.json", path.join(templatePath, "tsconfig.node.json"));
files.set("tsconfig.base.json", path.join(templatePath, "tsconfig.base.json"));
if (webpack) {
files.set("tsconfig.browser.json", path.join(templatePath, "tsconfig.browser.json"));
}
@@ -109,7 +112,7 @@ async function copyPluginTemplates(pluginName, templates) {
// Files and dependences to support webpack
if (webpack) {
prepare.push("webpack-cli --env production");
prepare.push("webpack-cli --mode production");
files.set("webpack.config.js", path.join(commonPath, "webpack.config.js"));
if (templates.includes("web")) {
files.set(`web/index.${ext}x`, path.join(templatePath, `web/plugin.${ext}x`));
@@ -139,7 +142,6 @@ async function copyPluginTemplates(pluginName, templates) {
// Files for lua modules
if (templates.includes("module")) {
files.set(".luacheckrc", path.join(commonPath, ".luacheckrc"));
files.set("module/module.json", path.join(commonPath, "module/module.json"));
files.set("module/control.lua", path.join(commonPath, "module/control.lua"));
files.set("module/module_exports.lua", path.join(commonPath, "module/module_exports.lua"));
@@ -147,7 +149,6 @@ async function copyPluginTemplates(pluginName, templates) {
files.set("module/globals.lua", path.join(commonPath, "module/globals.lua"));
} else {
files.set(`instance.${ext}`, path.join(templatePath, `instance_empty.${ext}`));
pluginContexts += 1;
}
}
@@ -164,18 +165,25 @@ async function copyPluginTemplates(pluginName, templates) {
// Properties that will control the replacements in the templates
const properties = {
config: config,
// Weather this is ts or js
typescript: !javascriptOnly,
ext: ext,
// Which templates where requested
multi_context: pluginContexts > 1,
controller: templates.includes("controller"),
host: templates.includes("host"),
instance: templates.includes("instance"),
module: templates.includes("module"),
ctl: templates.includes("ctl"),
web: templates.includes("web"),
multi_context: pluginContexts > 1,
plugin_name: pluginName,
// Macro flags for context requirements
webpack: webpack,
config: config,
// String values for package json
clusterio_version: _package.version,
node_version: _package.engines.node,
prepare: prepare.join(" && "),
ext: ext,
plugin_name: pluginName,
};
// Write all the template files
File diff suppressed because it is too large. Load diff
@@ -1,3 +1,14 @@
--[[
The 'control' file within factorio is used as the entry point for mods, in Clusterio this is only a convention
You can change which files are loaded (exports event handlers) and required (no events) by Clusterio within module.json
It is highly advised to use FMTK (justarandomgeek.factoriomod-debug) when writing Lua code for factorio
Most of your code will be within this one file, but you can create more and require them
clusterio_api exposes functions to enable communication with your InstancePlugin
compat exposes shims to simplify compatibly between factorio versions, use the following to require 2.0
`assert(compat.version_ge("2.0.0"), "__plugin_name__ requires factorio 2.0")`
]]
local clusterio_api = require("modules/clusterio/api")
local compat = require("modules/clusterio/compat")
@@ -6,10 +17,12 @@ local compat = require("modules/clusterio/compat")
local MyModule = {
}
--- script_data is 'synced' between players, you should use your plugin name to avoid name conflicts
-- The following helper function is desync safe and avoids name conflicts after being called during on_server_startup
-- There are edge cases in the following events: pre player left, player left, console command, and config changed
-- If you use any of these events and access globalData then make sure to call this function first
--[[
`compat.script_data` persists between restarts and is shared between modules, therefore you should use your plugin name to avoid conflicts
The following helper function is desync safe and avoids name conflicts after being called during `clusterio_api.events.on_server_startup`
There are edge cases in the following events: pre player left, player left, console command, and config changed
If you use any of these events and access `script_data` then make sure to call this function first
]]
local script_data = {}
local function setup_script_data()
if compat.script_data["__plugin_name__"] == nil then
@@ -28,12 +41,13 @@ end
--- Public methods should be available though your top level module table
function MyModule.foo()
game.print("foo")
game.print("foo: " .. (script_data.counter or 0))
end
--- Private methods should be local to the file, this will prevent others from calling it
local function bar()
game.print("bar")
script_data.counter = (script_data.counter or 0) + 1
game.print("bar: " .. script_data.counter)
end
--- Clusterio provides a few custom events, on_server_startup is the most useful and should be used in place of on_init
@@ -61,7 +75,7 @@ local function on_nth_tick_300()
bar()
end
--- Factorio events are accessible through defines.events, you can have one handler per event per module
--- Factorio events are accessible through defines.events, you can have one handler per event per file
local e = defines.events
local events = {
[clusterio_api.events.on_server_startup] = on_server_startup,
@@ -73,7 +87,7 @@ local on_nth_tick = {
[300] = on_nth_tick_300,
}
--- Always return the top level module table from control, this is how clusterio will access your event handlers
--- Always return the top level module table from control, this is how Clusterio will access your event handlers
MyModule.events = events --- @package
MyModule.on_nth_tick = on_nth_tick --- @package
return MyModule
@@ -1,3 +1,9 @@
--- Expose any globals here, typically just your control file if you want to access it through rcon or commands
-- luacheck: globals __plugin_name__
--[[
It is best practice to not expose any globals because all modules share a global environment
However, sometimes you need globals, for example to access functions within rcon commands
Therefore, we advise that this should be the only file in your module to expose globals
Typically this would be your control file as shown in the example below
]]
-- Access using `/sc __plugin_name__.foo()`
__plugin_name__ = require("modules/__plugin_name__/control")
@@ -1,4 +1,8 @@
--- Can contain anything you want to allow other plugins to have access to, this example exposes the control file
--- If you only export a single file then it may be better to rename that file to module_exports and save the redirection
-- Access the exports from other modules using require("modules/__plugin_name__")
--[[
It is typical for a module to have a control file as the entry point, although this is not a requirement
Therefore, Clusterio allows you to specify a single default export that can be accessed by other modules
You are not required to specify any default export, but note that a module can still require yours directly
]]
-- Access using `local __plugin_name__ = require("modules/__plugin_name__")`
return require("modules/__plugin_name__/control")
+28 -5
View File
@@ -12,10 +12,22 @@
"prepare": "__prepare__"
},
"engines": {
"node": ">=18"
"node": "__node_version__"
},
"peerDependencies": {
"@clusterio/lib": "workspace:*"
//%if controller
"@clusterio/controller": "^__clusterio_version__",
//%endif
//%if webpack
"@clusterio/web_ui": "^__clusterio_version__",
//%endif
//%if host
"@clusterio/host": "^__clusterio_version__",
//%endif
//%if ctl
"@clusterio/ctl": "^__clusterio_version__",
//%endif
"@clusterio/lib": "^__clusterio_version__"
},
"devDependencies": {
//%// Typescript and type declarations
@@ -32,13 +44,24 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
//%endif
//%if web | controller // The controller uses webpack even when there is no plugin code
//%if webpack
"webpack": "^5.88.2",
"webpack-cli": "^5.1.4",
"webpack-merge": "^5.9.0",
"@clusterio/web_ui": "workspace:*",
//%endif
"@clusterio/lib": "workspace:*"
//%if controller
"@clusterio/controller": "^__clusterio_version__",
//%endif
//%if webpack
"@clusterio/web_ui": "^__clusterio_version__",
//%endif
//%if host
"@clusterio/host": "^__clusterio_version__",
//%endif
//%if ctl
"@clusterio/ctl": "^__clusterio_version__",
//%endif
"@clusterio/lib": "^__clusterio_version__"
},
"dependencies": {
"@sinclair/typebox": "^0.30.4"
@@ -1,3 +1,2 @@
dist/
node_modules/
package-lock.json
dist/
@@ -1,3 +1,3 @@
webpack.config.js
dist/web/build
dist/browser
dist/web
@@ -5,7 +5,7 @@ const { merge } = require("webpack-merge");
const common = require("@clusterio/web_ui/webpack.common");
module.exports = (env = {}) => merge(common(env), {
module.exports = (env = {}, argv = {}) => merge(common(env, argv), {
context: __dirname,
entry: "./web/index.__ext__x",
output: {
@@ -18,9 +18,7 @@ module.exports = (env = {}) => merge(common(env), {
exposes: {
"./": "./index.__ext__",
"./package.json": "./package.json",
//%if web
"./web": "./web/index.__ext__x",
//%endif
},
shared: {
"@clusterio/lib": { import: false },
@@ -1,7 +1,7 @@
"use strict";
const lib = require("@clusterio/lib");
const { BaseControllerPlugin } = require("@clusterio/controller");
//%if multi_context // Subscribing requires multi context
//%if multi_context // Messages requires multi context
const {
PluginExampleEvent, PluginExampleRequest,
@@ -9,7 +9,7 @@ const {
//%if controller & web // Subscribing requires web content and the controller
ExampleSubscribableUpdate, ExampleSubscribableValue,
//%endif
//%if multi_context // Subscribing requires multi context
//%if multi_context // Messages requires multi context
} = require("./messages");
//%endif
@@ -26,7 +26,8 @@ class ControllerPlugin extends BaseControllerPlugin {
//%endif
//%if controller & web // Subscribing requires web content and the controller
this.controller.subscriptions.handle(ExampleSubscribableUpdate, this.handleExampleSubscription.bind(this));
this.exampleDatabase = new Map(); // If needed, replace with loading from database file
// If needed, replace with loading from database file such as lib.Datastore
this.exampleDatabase = new Map([["foo", new ExampleSubscribableValue("foo", 0, false)]]);
//%endif
//%if multi_context // Subscribing requires multi context
}
@@ -70,6 +71,7 @@ class ControllerPlugin extends BaseControllerPlugin {
//%if controller & web // Subscribing requires web content and the controller
async handleExampleSubscription(request) {
this.logger.info(JSON.stringify(request));
const values = [...this.exampleDatabase.values()].filter(
value => value.updatedAtMs > request.lastRequestTimeMs,
);
+5 -1
View File
@@ -7,12 +7,14 @@ lib.definePermission({
name: "__plugin_name__.example.permission.event",
title: "Example permission event",
description: "Example Description. Event. Change me in index.js",
grantByDefault: true,
});
lib.definePermission({
name: "__plugin_name__.example.permission.request",
title: "Example permission request",
description: "Example Description. Request. Change me in index.js",
grantByDefault: true,
});
//%endif
//%if controller & web // Subscribing requires web content and the controller
@@ -21,6 +23,7 @@ lib.definePermission({
name: "__plugin_name__.example.permission.subscribe",
title: "Example permission subscribe",
description: "Example Description. Subscribe. Change me in index.js",
grantByDefault: true,
});
//%endif
//%if web
@@ -29,6 +32,7 @@ lib.definePermission({
name: "__plugin_name__.page.view",
title: "Example page view permission",
description: "Example Description. View. Change me in index.js",
grantByDefault: true,
});
//%endif
@@ -40,7 +44,7 @@ const plugin = {
//%endif
//%if controller
controllerEntrypoint: "./dist/node/controller",
controllerEntrypoint: "./dist/node/controller",
//%endif
//%if controller & config
controllerConfigFields: {
@@ -24,18 +24,26 @@ class InstancePlugin extends BaseInstancePlugin {
}
async onStart() {
// Called once rcon becomes available
this.logger.info("instance::onStart");
}
async onStop() {
// Called during normal exits before rcon becomes unavailable
this.logger.info("instance::onStop");
}
onExit() {
// Called during all exits, including crashes and init failures, rcon is not available
this.logger.info("instance::onExit");
}
async onPlayerEvent(event) {
this.logger.info(`onPlayerEvent::onPlayerEvent ${JSON.stringify(event)}`);
//%if module
if (this.instance.status === "running") {
this.sendRcon("/sc __plugin_name__.foo()");
await this.sendRcon("/sc __plugin_name__.foo()");
}
//%endif
}
@@ -12,7 +12,7 @@ import React, {
import {
BaseWebPlugin, PageLayout, PageHeader, Control, ControlContext, notifyErrorHandler,
} from "@clusterio/web_ui";
//%if multi_context // Subscribing requires multi context
//%if multi_context // Messages requires multi context
import {
PluginExampleEvent, PluginExampleRequest,
@@ -20,7 +20,7 @@ import {
//%if controller // Subscribing requires web content and the controller
ExampleSubscribableUpdate, ExampleSubscribableValue,
//%endif
//%if multi_context // Subscribing requires multi context
//%if multi_context // Messages requires multi context
} from "../messages";
//%endif
@@ -57,7 +57,7 @@ export class WebPlugin extends BaseWebPlugin {
permission: "__plugin_name__.example.permission.subscribe",
//%endif
//%if !controller
permission: undefined, // "__plugin_name__.example.permission.request",
permission: "__plugin_name__.example.permission.view",
//%endif
content: <MyTemplatePage/>,
},
@@ -1,6 +1,6 @@
import * as lib from "@clusterio/lib";
import { BaseControllerPlugin, InstanceInfo } from "@clusterio/controller";
//%if multi_context // Subscribing requires multi context
//%if multi_context // Messages requires multi context
import {
PluginExampleEvent, PluginExampleRequest,
@@ -8,7 +8,7 @@ import {
//%if controller & web // Subscribing requires web content and the controller
ExampleSubscribableUpdate, ExampleSubscribableValue,
//%endif
//%if multi_context // Subscribing requires multi context
//%if multi_context // Messages requires multi context
} from "./messages";
//%endif
@@ -25,7 +25,8 @@ export class ControllerPlugin extends BaseControllerPlugin {
//%endif
//%if controller & web // Subscribing requires web content and the controller
this.controller.subscriptions.handle(ExampleSubscribableUpdate, this.handleExampleSubscription.bind(this));
this.exampleDatabase = new Map(); // If needed, replace with loading from database file
// If needed, replace with loading from database file such as lib.Datastore
this.exampleDatabase = new Map([["foo", new ExampleSubscribableValue("foo", 0, false)]]);
//%endif
//%if multi_context // Subscribing requires multi context
}
@@ -69,6 +70,7 @@ export class ControllerPlugin extends BaseControllerPlugin {
//%if controller & web // Subscribing requires web content and the controller
async handleExampleSubscription(request: lib.SubscriptionRequest) {
this.logger.info(JSON.stringify(request));
const values = [...this.exampleDatabase.values()].filter(
value => value.updatedAtMs > request.lastRequestTimeMs,
);
@@ -6,12 +6,14 @@ lib.definePermission({
name: "__plugin_name__.example.permission.event",
title: "Example permission event",
description: "Example Description. Event. Change me in index.ts",
grantByDefault: true,
});
lib.definePermission({
name: "__plugin_name__.example.permission.request",
title: "Example permission request",
description: "Example Description. Request. Change me in index.ts",
grantByDefault: true,
});
//%endif
//%if controller & web // Subscribing requires web content and the controller
@@ -20,6 +22,7 @@ lib.definePermission({
name: "__plugin_name__.example.permission.subscribe",
title: "Example permission subscribe",
description: "Example Description. Subscribe. Change me in index.ts",
grantByDefault: true,
});
//%endif
//%if web
@@ -28,6 +31,7 @@ lib.definePermission({
name: "__plugin_name__.page.view",
title: "Example page view permission",
description: "Example Description. View. Change me in index.ts",
grantByDefault: true,
});
//%endif
//%if config
@@ -31,18 +31,25 @@ export class InstancePlugin extends BaseInstancePlugin {
}
async onStart() {
// Called once rcon becomes available
this.logger.info("instance::onStart");
}
async onStop() {
// Called during normal exits before rcon becomes unavailable
this.logger.info("instance::onStop");
}
onExit() {
// Called during all exits, including crashes and init failures, rcon is not available
this.logger.info("instance::onExit");
}
async onPlayerEvent(event: lib.PlayerEvent) {
this.logger.info(`onPlayerEvent::onPlayerEvent ${JSON.stringify(event)}`);
//%if module
if (this.instance.status === "running") {
this.sendRcon("/sc __plugin_name__.foo()");
await this.sendRcon("/sc __plugin_name__.foo()");
}
//%endif
}
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"composite": true,
"declaration": true,
"declarationMap": true,
"incremental": true,
"sourceMap": true,
"strict": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"skipLibCheck": true,
},
}
@@ -1,8 +1,13 @@
{
"extends": "../../tsconfig.browser.json",
"references": [
{ "path": "../../packages/lib/tsconfig.browser.json" },
{ "path": "../../packages/web_ui/tsconfig.browser.json" },
],
"extends": "./tsconfig.base.json",
"compilerOptions": {
"lib": ["dom", "es2023"],
"module": "es2022",
"moduleResolution": "bundler",
"target": "es2022",
"jsx": "react-jsx",
"noEmitOnError": true,
"outDir": "${configDir}/dist/browser",
},
"include": [ "web/**/*.tsx", "web/**/*.ts", "messages.ts", "package.json" ],
}
@@ -1,8 +1,13 @@
{
"extends": "../../tsconfig.node.json",
"references": [
{ "path": "../../packages/lib/tsconfig.node.json" },
],
"extends": "./tsconfig.base.json",
"compilerOptions": {
"lib": ["es2023"],
"module": "node16",
"moduleResolution": "node16",
"target": "es2022",
"forceConsistentCasingInFileNames": true,
"outDir": "${configDir}/dist/node"
},
"include": ["./**/*.ts"],
"exclude": ["test/*", "./dist/*"],
}
@@ -12,7 +12,7 @@ import React, {
import {
BaseWebPlugin, PageLayout, PageHeader, Control, ControlContext, notifyErrorHandler,
} from "@clusterio/web_ui";
//%if multi_context // Subscribing requires multi context
//%if multi_context // Messages requires multi context
import {
PluginExampleEvent, PluginExampleRequest,
@@ -20,7 +20,7 @@ import {
//%if controller & web // Subscribing requires web content and the controller
ExampleSubscribableUpdate, ExampleSubscribableValue,
//%endif
//%if multi_context // Subscribing requires multi context
//%if multi_context // Messages requires multi context
} from "../messages";
//%endif
@@ -57,7 +57,7 @@ export class WebPlugin extends BaseWebPlugin {
permission: "__plugin_name__.example.permission.subscribe",
//%endif
//%if !controller
permission: undefined, // "__plugin_name__.example.permission.request",
permission: "__plugin_name__.example.permission.view",
//%endif
content: <MyTemplatePage/>,
},
+87 -37
View File
@@ -5,7 +5,7 @@
* @module ctl/ctl
*/
import fs from "fs-extra";
import yargs from "yargs";
import yargs, { type Argv } from "yargs";
import path from "path";
import { version } from "./package.json";
import setBlocking from "set-blocking";
@@ -143,8 +143,30 @@ interface CtlArguments {
pluginList: string,
}
async function startControl() {
yargs
interface InitializeParameters {
args: CtlArguments;
shouldRun: boolean;
ctlPlugins?: Map<string, BaseCtlPlugin>;
rootCommands?: lib.CommandTree;
controlConfig?: lib.ControlConfig;
}
export async function initialize(
argv: string | string[],
ctlPlugins?: Map<string, BaseCtlPlugin>,
noLoggerTransport?: boolean,
): Promise<InitializeParameters> {
// Build a fresh, isolated yargs parser each time this function is called.
// If the currently loaded yargs object supports .reset() we use that (older versions),
// otherwise we fall back to creating a brand-new parser instance by invoking the
// yargs factory function directly with an empty argv array. This avoids Mocha’s
// own CLI flags (or anything else that touched yargs earlier in the same process)
// from leaking into subsequent parses inside the test runner.
const parser: Argv = typeof (yargs as any).reset === "function"
? (yargs as any).reset()
: (yargs as any)([]);
parser
.scriptName("clusterioctl")
.usage("$0 <command> [options]")
.option("log-level", {
@@ -167,50 +189,60 @@ async function startControl() {
default: "plugin-list.json",
type: "string",
})
.option("bypass-lock-file", { hidden: true, type: "boolean", nargs: 0, default: false })
.command("plugin", "Manage available plugins", lib.pluginCommand)
.command("control-config", "Manage Control config", lib.configCommand)
.wrap(yargs.terminalWidth())
.wrap(parser.terminalWidth())
.help(false) // Disable help to avoid triggering it on the first parse.
;
// Parse the args first to get the configured plugin list.
// eslint-disable-next-line node/no-sync
let args = yargs.parseSync() as CtlArguments;
let args = parser.parseSync(argv) as CtlArguments;
// Log stream for the ctl session.
logger.add(
new ConsoleTransport({
errorLevels: Object.keys(levels),
level: args.logLevel,
format: new lib.TerminalFormat(),
})
);
lib.handleUnhandledErrors();
logger.verbose(`Loading available plugins from ${args.pluginList}`);
let pluginList = await lib.loadPluginList(args.pluginList);
// If the command is plugin management we don't try to load plugins
if (args._[0] === "plugin") {
await lib.handlePluginCommand(args, pluginList, args.pluginList);
return;
// Log stream for the ctl session. Skipped in testing.
if (!noLoggerTransport) {
logger.add(
new ConsoleTransport({
errorLevels: Object.keys(levels),
level: args.logLevel,
format: new lib.TerminalFormat(),
})
);
lib.handleUnhandledErrors();
}
logger.verbose("Loading Plugins");
let ctlPlugins = await loadPlugins(pluginList);
// Discover and load plugins. This check exists to allow tests to inject plugins.
if (!ctlPlugins || args._[0] === "plugin") {
logger.verbose(`Loading available plugins from ${args.pluginList}`);
const pluginList = await lib.loadPluginList(args.pluginList);
// Add all cluster management commands including ones from plugins
let rootCommands = await commands.registerCommands(ctlPlugins, yargs);
// If the command is plugin management we don't try to load plugins
if (args._[0] === "plugin") {
await lib.handlePluginCommand(args, pluginList, args.pluginList);
return { args, shouldRun: false };
}
// Reparse after commands have been added with help and strict checking.
args = yargs
logger.verbose("Loading Plugins");
ctlPlugins = await loadPlugins(pluginList);
}
// Add all commands including from plugins and reparse with help and strict checking.
const rootCommands = await commands.registerCommands(ctlPlugins, parser);
args = parser
.help()
.strict()
.parse() as CtlArguments
.parse(argv) as CtlArguments
;
let controlConfig;
const controlConfigPath = args.config;
const controlConfigLockPath = `${controlConfigPath}.lock`;
if (args.bypassLockFile) {
await fs.unlink(controlConfigLockPath).catch(() => {}); // ignore error, file might not exist
}
let controlConfig;
const controlConfigLock = new lib.LockFile(controlConfigLockPath);
logger.verbose(`Loading config from ${controlConfigPath}`);
try {
controlConfig = await lib.ControlConfig.fromFile("control", controlConfigPath);
@@ -226,25 +258,41 @@ async function startControl() {
}
if (args._.length === 0) {
yargs.showHelp();
yargs.exit(1, undefined as unknown as Error); // Type definition file is wrong.
parser.showHelp();
parser.exit(1, undefined as unknown as Error); // Type definition file is wrong.
}
// Handle the control-config command before trying to connect.
if (args._[0] === "control-config") {
await lib.handleConfigCommand(args, controlConfig);
return;
await lib.handleConfigCommand(args, controlConfig, controlConfigLock);
return { args, controlConfig, ctlPlugins, rootCommands, shouldRun: false };
}
// Determine which command is being executed.
return { args, controlConfig, ctlPlugins, rootCommands, shouldRun: true };
}
export function selectTargetCommand(args: CtlArguments, rootCommands: lib.CommandTree): lib.Command {
let commandPath = [...args._] as string[];
let targetCommand: lib.CommandTree | lib.Command = rootCommands;
while (commandPath.length && targetCommand instanceof lib.CommandTree) {
targetCommand = targetCommand.get(commandPath.shift()!)!;
}
assert(targetCommand instanceof lib.Command);
return targetCommand;
}
async function startControl() {
const {
args,
shouldRun,
ctlPlugins,
rootCommands,
controlConfig,
} = await initialize(process.argv.slice(2));
if (!shouldRun || !ctlPlugins || !rootCommands || !controlConfig) {
return;
}
// The remaining commands require connecting to the controller.
if (!controlConfig.get("control.controller_url") || !controlConfig.get("control.controller_token")) {
logger.error("Missing URL and/or token to connect with. See README.md for setting up access.");
process.exitCode = 1;
@@ -263,6 +311,7 @@ async function startControl() {
tlsCa,
controlConfig.get("control.controller_token")!,
);
let control = new Control(controlConnector, controlConfig, tlsCa, ctlPlugins);
try {
await controlConnector.connect();
@@ -278,12 +327,13 @@ async function startControl() {
control.shutdown().catch(err => {
setBlocking(true);
logger.error(err.stack);
// eslint-disable-next-line node/no-process-exit
process.exit(1);
});
});
try {
const targetCommand = selectTargetCommand(args, rootCommands);
await targetCommand.run(args, control);
} catch (err) {
+10 -10
View File
@@ -3,7 +3,7 @@
"description": "Clusterio command line management tool",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.21",
"version": "2.0.0-alpha.22",
"main": "dist/node/index.js",
"scripts": {
"prepare": "tsc --build",
@@ -17,25 +17,25 @@
"factorio"
],
"engines": {
"node": ">=18"
"node": ">=20"
},
"dependencies": {
"@clusterio/lib": "workspace:*",
"set-blocking": "catalog:",
"fs-extra": "catalog:",
"winston": "catalog:",
"phin": "catalog:",
"as-table": "^1.0.55",
"fs-extra": "^11.3.0",
"phin": "^3.7.0",
"set-blocking": "^2.0.0",
"winston": "^3.17.0",
"yargs": "^17.7.2"
},
"publishConfig": {
"access": "public"
},
"devDependencies": {
"@types/fs-extra": "^11.0.1",
"@types/node": "^20.17.19",
"@types/set-blocking": "^2.0.0",
"@types/fs-extra": "catalog:",
"@types/node": "catalog:",
"@types/set-blocking": "catalog:",
"@types/yargs": "^17.0.33",
"typescript": "^5.7.3"
"typescript": "catalog:"
}
}
+182 -80
View File
@@ -197,28 +197,24 @@ controllerConfigCommands.add(new lib.Command({
editorSpawn.on("data", (data) => {
process.stdout.pipe(data);
});
let doneEmitter = new events.EventEmitter();
editorSpawn.on("exit", async (exit) => {
const data = await fs.readFile(tmpFile, "utf8");
const final = await configToKeyVal(data);
for (let index in final) {
if (index in final) {
try {
await control.send(new lib.ControllerConfigSetFieldRequest(
index,
final[index],
));
} catch (err) {
// eslint-disable-next-line
print(`Attempt to set ${index} to ${final[index] || String(null)} failed; set back to previous value.`);
print(err);
// If the string is empty, it's better to just print "" instead of nothing
}
await events.once(editorSpawn, "exit");
const data = await fs.readFile(tmpFile, "utf8");
const final = await configToKeyVal(data);
for (let index in final) {
if (index in final) {
try {
await control.send(new lib.ControllerConfigSetFieldRequest(
index,
final[index],
));
} catch (err) {
// eslint-disable-next-line
print(`Attempt to set ${index} to ${final[index] || String(null)} failed; set back to previous value.`);
print(err);
// If the string is empty, it's better to just print "" instead of nothing
}
}
doneEmitter.emit("dot_on_done");
});
await events.once(doneEmitter, "dot_on_done");
}
try {
await fs.unlink(tmpFile);
} catch (err) {
@@ -487,33 +483,29 @@ hostConfigCommands.add(new lib.Command({
editorSpawn.on("data", (data) => {
process.stdout.pipe(data);
});
let doneEmitter = new events.EventEmitter();
editorSpawn.on("exit", async (exit) => {
const data = await fs.readFile(tmpFile, "utf8");
const final = await configToKeyVal(data);
for (let index in final) {
if (index in final) {
try {
await control.sendTo({ hostId }, new lib.HostConfigSetFieldRequest(
index,
final[index],
));
} catch (err) {
// eslint-disable-next-line
print(`\n\n\nAttempt to set ${index} to ${final[index] || String(null)} failed; set back to previous value.`);
// If the string is empty, it's better to just print "" instead of nothing
print("This message shouldn't normally appear; if the below message does not indicate it");
print("was a user mistake, please report it to the clustorio devs.");
// added this because it could be a missed entry in disallowedList
// i've added all the vanilla clustorio configs, but there may be
// some modded ones that need to be added.
print(err);
}
await events.once(editorSpawn, "exit");
const data = await fs.readFile(tmpFile, "utf8");
const final = await configToKeyVal(data);
for (let index in final) {
if (index in final) {
try {
await control.sendTo({ hostId }, new lib.HostConfigSetFieldRequest(
index,
final[index],
));
} catch (err) {
// eslint-disable-next-line
print(`\n\n\nAttempt to set ${index} to ${final[index] || String(null)} failed; set back to previous value.`);
// If the string is empty, it's better to just print "" instead of nothing
print("This message shouldn't normally appear; if the below message does not indicate it");
print("was a user mistake, please report it to the clustorio devs.");
// added this because it could be a missed entry in disallowedList
// i've added all the vanilla clustorio configs, but there may be
// some modded ones that need to be added.
print(err);
}
}
doneEmitter.emit("dot_on_done");
});
await events.once(doneEmitter, "dot_on_done");
}
try {
await fs.unlink(tmpFile);
} catch (err) {
@@ -732,34 +724,30 @@ instanceConfigCommands.add(new lib.Command({
editorSpawn.on("data", (data) => {
process.stdout.pipe(data);
});
let doneEmitter = new events.EventEmitter();
editorSpawn.on("exit", async (exit) => {
const data = await fs.readFile(tmpFile, "utf8");
const final = await configToKeyVal(data);
for (let index in final) {
if (index in final) {
try {
await control.send(new lib.InstanceConfigSetFieldRequest(
instanceId,
index,
final[index],
));
} catch (err) {
// eslint-disable-next-line
print(`\n\n\nAttempt to set ${index} to ${final[index] || String(null)} failed; set back to previous value.`);
// If the string is empty, it's better to just print "" instead of nothing
print("This message shouldn't normally appear; if the below message does not indicate it");
print("was a user mistake, please report it to the clustorio devs.");
// added this because it could be a missed entry in disallowedList
// i've added all the vanilla clustorio configs, but there may be
// some modded ones that need to be added.
print(err);
}
await events.once(editorSpawn, "exit");
const data = await fs.readFile(tmpFile, "utf8");
const final = await configToKeyVal(data);
for (let index in final) {
if (index in final) {
try {
await control.send(new lib.InstanceConfigSetFieldRequest(
instanceId,
index,
final[index],
));
} catch (err) {
// eslint-disable-next-line
print(`\n\n\nAttempt to set ${index} to ${final[index] || String(null)} failed; set back to previous value.`);
// If the string is empty, it's better to just print "" instead of nothing
print("This message shouldn't normally appear; if the below message does not indicate it");
print("was a user mistake, please report it to the clustorio devs.");
// added this because it could be a missed entry in disallowedList
// i've added all the vanilla clustorio configs, but there may be
// some modded ones that need to be added.
print(err);
}
}
doneEmitter.emit("dot_on_done");
});
await events.once(doneEmitter, "dot_on_done");
}
try {
await fs.unlink(tmpFile);
} catch (err) {
@@ -1050,17 +1038,87 @@ instanceCommands.add(new lib.Command({
definition: ["start <instance>", "Start instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to start", type: "string" });
yargs.options({
"save": { describe: "Save load, defaults to latest", nargs: 1, type: "string" },
"save": { describe: "Save to load, defaults to latest", nargs: 1, type: "string" },
"keep-open": { describe: "Keep console open", nargs: 0, type: "boolean", default: false },
});
}],
handler: async function(args: { instance: string, save?: string, keepOpen: boolean }, control: Control) {
let instanceId = await lib.resolveInstance(control, args.instance);
const instanceId = await lib.resolveInstance(control, args.instance);
await control.setLogSubscriptions({ instanceIds: [instanceId] });
await control.sendTo({ instanceId }, new lib.InstanceStartRequest(args.save));
control.keepOpen = args.keepOpen;
},
}));
instanceCommands.add(new lib.Command({
definition: ["restart <instance>", "Restart instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to restart", type: "string" });
yargs.options({
"save": { describe: "Save to load, defaults to latest", nargs: 1, type: "string" },
"keep-open": { describe: "Keep console open", nargs: 0, type: "boolean", default: false },
});
}],
handler: async function(args: { instance: string, save?: string, keepOpen: boolean }, control: Control) {
const instanceId = await lib.resolveInstance(control, args.instance);
await control.setLogSubscriptions({ instanceIds: [instanceId] });
await control.sendTo({ instanceId }, new lib.InstanceRestartRequest(args.save));
control.keepOpen = args.keepOpen;
},
}));
instanceCommands.add(new lib.Command({
definition: ["start-all", "Start all stopped instances (respects exclude_from_start_all setting)", (yargs) => {
yargs.options({
"force": {
describe: "Start instances even if they have exclude_from_start_all set",
nargs: 0,
type: "boolean",
default: false,
},
});
}],
handler: async function(args: { force: boolean }, control: Control) {
// Get all instances
const instances = await control.send(new lib.InstanceDetailsListRequest());
// Filter instances that should be started
const instancesToStart = instances.filter((instance: lib.InstanceDetails) => {
// Only start instances that are currently stopped
if (instance.status !== "stopped") {
return false;
}
// Respect exclude_from_start_all setting unless --force is used
if (!args.force && instance.excludeFromStartAll) {
return false;
}
return true;
});
if (instancesToStart.length === 0) {
print("No instances to start.");
return;
}
print(`Starting ${instancesToStart.length} instance(s):`);
for (const instance of instancesToStart) {
print(` - ${instance.name} (ID: ${instance.id})`);
}
// Start all filtered instances
const startPromises = instancesToStart.map(async (instance: lib.InstanceDetails) => {
try {
await control.sendTo({ instanceId: instance.id }, new lib.InstanceStartRequest());
print(`✓ Started ${instance.name}`);
} catch (error) {
print(`✗ Failed to start ${instance.name}: ${error}`);
}
});
await Promise.all(startPromises);
print("Start all operation completed.");
},
}));
instanceCommands.add(new lib.Command({
definition: ["load-scenario <instance> <scenario>", "Start instance by loading a scenario", (yargs) => {
@@ -1107,6 +1165,41 @@ instanceCommands.add(new lib.Command({
},
}));
instanceCommands.add(new lib.Command({
definition: ["stop-all", "Stop all running instances", () => {}],
handler: async function(_args, control: Control) {
// Get all instances
const instances = await control.send(new lib.InstanceDetailsListRequest());
// Filter instances that should be stopped
const instancesToStop = instances
.filter((instance: lib.InstanceDetails) => ["starting", "running"].includes(instance.status));
if (instancesToStop.length === 0) {
print("No instances to stop.");
return;
}
print(`Stopping ${instancesToStop.length} instance(s):`);
for (const instance of instancesToStop) {
print(` - ${instance.name} (ID: ${instance.id})`);
}
// Stop all filtered instances
const stopPromises = instancesToStop.map(async (instance: lib.InstanceDetails) => {
try {
await control.sendTo({ instanceId: instance.id }, new lib.InstanceStopRequest());
print(`✓ Stopped ${instance.name}`);
} catch (error) {
print(`✗ Failed to stop ${instance.name}: ${error}`);
}
});
await Promise.all(stopPromises);
print("Stop all operation completed.");
},
}));
instanceCommands.add(new lib.Command({
definition: ["kill <instance>", "Kill instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to kill", type: "string" });
@@ -1269,7 +1362,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 (!/^\d+\.\d+\.\d+$/.test(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)) {
@@ -1322,8 +1415,8 @@ modPackCommands.add(new lib.Command({
modPack.name = args.name;
if (args.description) { modPack.description = args.description; }
if (args.factorioVersion) {
if (!/^\d+\.\d+\.\d+?$/.test(args.factorioVersion)) {
throw new lib.CommandError("factorio-version must match the format digit.digit.digit");
if (!lib.isPartialVersion(args.factorioVersion)) {
throw new lib.CommandError("factorio-version must match the format digit.digit[.digit]");
}
modPack.factorioVersion = args.factorioVersion;
}
@@ -1403,8 +1496,8 @@ modPackCommands.add(new lib.Command({
if (args.name) { modPack.name = args.name; }
if (args.description) { modPack.description = args.description; }
if (args.factorioVersion) {
if (!/^\d+\.\d+\.\d+?$/.test(args.factorioVersion)) {
throw new lib.CommandError("factorio-version must match the format digit.digit.digit");
if (!lib.isPartialVersion(args.factorioVersion)) {
throw new lib.CommandError("factorio-version must match the format digit.digit[.digit]");
}
modPack.factorioVersion = args.factorioVersion;
}
@@ -1458,6 +1551,9 @@ 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.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));
for (let [field, value] of Object.entries(modInfo)) {
if (value instanceof Array) {
@@ -1545,7 +1641,7 @@ modCommands.add(new lib.Command({
) {
let response = await control.send(new lib.ModSearchRequest(
args.query,
args.factorioVersion,
lib.normaliseApiVersion(args.factorioVersion as any),
args.page,
args.pageSize,
args.sort,
@@ -1624,6 +1720,9 @@ 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.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")!);
@@ -1664,6 +1763,9 @@ 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.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));
},
}));
@@ -2059,7 +2161,7 @@ userCommands.add(new lib.Command({
exportType = "whitelist";
}
fs.writeJSON(args.filepath, await control.send(
await fs.writeJSON(args.filepath, await control.send(
new lib.UserBulkExportRequest(exportType)
), { spaces: 2 });
print(`Exported ${exportType} to ${args.filepath}`);
+14 -4
View File
@@ -113,6 +113,7 @@ async function startHost() {
default: "plugin-list.json",
type: "string",
})
.option("bypass-lock-file", { hidden: true, type: "boolean", nargs: 0, default: false })
.command("plugin", "Manage available plugins", lib.pluginCommand)
.command("config", "Manage Host config", lib.configCommand)
.command("run", "Run host", yargs => {
@@ -167,8 +168,14 @@ async function startHost() {
lib.registerPluginMessages(pluginInfos);
lib.addPluginConfigFields(pluginInfos);
let hostConfig;
const hostConfigPath = args.config;
const hostConfigLockPath = `${hostConfigPath}.lock`;
if (args.bypassLockFile) {
await fs.unlink(hostConfigLockPath).catch(() => {}); // ignore error, file might not exist
}
let hostConfig;
const hostConfigLock = new lib.LockFile(hostConfigLockPath);
logger.info(`Loading config from ${hostConfigPath}`);
try {
hostConfig = await lib.HostConfig.fromFile("host", hostConfigPath);
@@ -186,10 +193,13 @@ async function startHost() {
hostConfig.set("host.version", version); // Allows tracking last loaded version
if (command === "config") {
await lib.handleConfigCommand(args, hostConfig);
await lib.handleConfigCommand(args, hostConfig, hostConfigLock);
return;
}
// From this point on the host should run
await hostConfigLock.acquire();
// If we get here the command was run
await fs.ensureDir(hostConfig.get("host.instances_directory"));
await fs.ensureDir(hostConfig.get("host.mods_directory"));
@@ -243,7 +253,7 @@ async function startHost() {
if (secondSigint) {
setBlocking(true);
logger.fatal("Caught second interrupt, terminating immediately");
// eslint-disable-next-line node/no-process-exit
process.exit(1);
}
@@ -256,7 +266,7 @@ async function startHost() {
if (secondSigterm) {
setBlocking(true);
logger.fatal("Caught second termination, terminating immediately");
// eslint-disable-next-line node/no-process-exit
process.exit(1);
}
@@ -70,7 +70,6 @@ function serialize.serialize_equipment_grid(grid)
if equipment.to_be_removed then
entry.t = equipment.to_be_removed
end
log(entry)
table.insert(serialized, entry)
end
end
+14 -13
View File
@@ -3,10 +3,11 @@
"description": "Implementation of Clusterio host server",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.21",
"version": "2.0.0-alpha.22b",
"main": "dist/node/index.js",
"scripts": {
"prepare": "tsc --build",
"build-mod": "node ../lib/build_mod.js --source-dir ./lua/clusterio_lib",
"test": "echo \"Error: no test specified\" && exit 1"
},
"bin": {
@@ -17,33 +18,33 @@
"factorio"
],
"engines": {
"node": ">=18"
"node": ">=20"
},
"dependencies": {
"@clusterio/lib": "workspace:*",
"@sinclair/typebox": "^0.30.4",
"fs-extra": "^11.3.0",
"@sinclair/typebox": "catalog:",
"set-blocking": "catalog:",
"fs-extra": "catalog:",
"jszip": "catalog:",
"phin": "catalog:",
"winston": "catalog:",
"winston-daily-rotate-file": "catalog:",
"jimp": "^0.22.8",
"jszip": "^3.10.1",
"phin": "^3.7.0",
"pidusage": "^3.0.2",
"rcon-client": "^4.2.5",
"semver": "^7.7.1",
"set-blocking": "^2.0.0",
"winston": "^3.17.0",
"winston-daily-rotate-file": "^4.7.1",
"yargs": "^17.7.2"
},
"publishConfig": {
"access": "public"
},
"devDependencies": {
"@types/fs-extra": "^11.0.1",
"@types/node": "^20.17.19",
"@types/node": "catalog:",
"@types/fs-extra": "catalog:",
"@types/set-blocking": "catalog:",
"@types/pidusage": "^2.0.2",
"@types/semver": "^7.5.0",
"@types/set-blocking": "^2.0.0",
"@types/yargs": "^17.0.33",
"typescript": "^5.7.3"
"typescript": "catalog:"
}
}
+48 -18
View File
@@ -250,7 +250,7 @@ export class HostRouter {
* Connects to the controller over the WebSocket and manages intsances.
*/
export default class Host extends lib.Link {
declare ["connector"]: HostConnector;
declare "connector": HostConnector;
router = new HostRouter(this);
/**
@@ -410,7 +410,7 @@ export default class Host extends lib.Link {
for (let pluginInfo of this.pluginInfos) {
if (
!pluginInfo.hostEntrypoint && !pluginInfo.instanceEntrypoint
|| !this.config.get(`${pluginInfo.name}.load_plugin` as keyof lib.HostConfigFields)
|| !this.config.get(`${pluginInfo.name}.load_plugin`)
) {
continue;
}
@@ -693,7 +693,8 @@ export default class Host extends lib.Link {
instanceConnection ? instanceConnection.instance.server.gamePort : this.gamePort(instanceId),
instanceConnection
? instanceConnection.instance.server.version
: instanceInfo.config.get("factorio.version"),
// TODO: factorio.version is not validated
: instanceInfo.config.get("factorio.version") as lib.TargetVersion,
)
);
@@ -805,19 +806,48 @@ export default class Host extends lib.Link {
}
async handleSystemInfoRequest() {
if (!this.config.restartRequired) {
// If a restart isn't already required, then test if a new version is installed
try {
const runningVersion = this.config.get("host.version");
const packageJson = await fs.readJSON(path.join(__dirname, "..", "package.json"));
if (runningVersion !== packageJson.version) {
this.config.restartRequired = true;
}
} catch (err: any) {
logger.warn(`Failed to read package json:\n${err.stack ?? err.message}`);
}
const restartRequired = await this.checkRestartRequired();
return lib.gatherSystemInfo(this.config.get("host.id"), this.canRestart, restartRequired);
}
async checkRestartRequired() {
if (this.config.restartRequired) {
return true;
}
return lib.gatherSystemInfo(this.config.get("host.id"), this.canRestart, this.config.restartRequired);
let packageName = "@clusterio/host";
try {
// First check the clusterio version
const runningVersion = this.config.get("host.version");
const packageJsonPath = require.resolve("@clusterio/host/package.json");
const packageJson = await fs.readJSON(packageJsonPath);
if (runningVersion !== packageJson.version) {
this.config.restartRequired = true;
return true;
}
// Second check plugin versions
for (const pluginInfo of this.pluginInfos) {
if (
!pluginInfo.hostEntrypoint && !pluginInfo.instanceEntrypoint
|| !this.config.get(`${pluginInfo.name}.load_plugin`)
) {
continue;
}
packageName = pluginInfo.npmPackage ?? pluginInfo.name;
const pluginPackageJsonPath = require.resolve(path.posix.join(pluginInfo.requirePath, "package.json"));
const pluginPackageJson = await fs.readJSON(pluginPackageJsonPath);
if (pluginInfo.version !== pluginPackageJson.version) {
this.config.restartRequired = true;
return true;
}
}
} catch (err: any) {
logger.warn(`Failed to read package json for ${packageName}:\n${err.stack ?? err.message}`);
}
return false;
}
async handleHostMetricsRequest() {
@@ -1066,7 +1096,7 @@ export default class Host extends lib.Link {
if (!this.config.get("host.allow_remote_updates")) {
throw new lib.RequestError("Remote updates are disabled on this machine");
}
return lib.updatePackage("@clusterio/host");
return await lib.updatePackage("@clusterio/host");
}
async handlePluginUpdateRequest(request: lib.PluginUpdateRequest) {
@@ -1087,7 +1117,7 @@ export default class Host extends lib.Link {
return this.pluginInfos.map(pluginInfo => lib.PluginDetails.fromNodeEnvInfo(
pluginInfo,
this.plugins.has(pluginInfo.name),
this.config.get(`${pluginInfo.name}.load_plugin` as keyof lib.HostConfigFields) as boolean,
this.config.get(`${pluginInfo.name}.load_plugin`),
));
}
@@ -1201,7 +1231,7 @@ export default class Host extends lib.Link {
+------------------------------------------------------------+
${err.stack}`
);
// eslint-disable-next-line node/no-process-exit
process.exit(1);
}
}
+21 -5
View File
@@ -210,7 +210,8 @@ export default class Instance extends lib.Link {
let serverOptions = {
logger: this.logger,
version: this.config.get("factorio.version"),
// TODO: factorio.version is not validated
version: this.config.get("factorio.version") as lib.TargetVersion,
executablePath: this.config.get("factorio.executable_path") ?? undefined,
gamePort: this.config.get("factorio.game_port") ?? host.assignGamePort(this.id),
rconPort: this.config.get("factorio.rcon_port") ?? undefined,
@@ -289,6 +290,7 @@ export default class Instance extends lib.Link {
this.handle(lib.InstanceSaveDetailsListRequest, this.handleInstanceSaveDetailsListRequest.bind(this));
this.handle(lib.InstanceCreateSaveRequest, this.handleInstanceCreateSaveRequest.bind(this));
this.handle(lib.InstanceExportDataRequest, this.handleInstanceExportDataRequest.bind(this));
this.handle(lib.InstanceRestartRequest, this.handleInstanceRestartRequest.bind(this));
this.handle(lib.InstanceStopRequest, this.handleInstanceStopRequest.bind(this));
this.handle(lib.InstanceKillRequest, this.handleInstanceKillRequest.bind(this));
this.handle(lib.InstanceSendRconRequest, this.handleInstanceSendRconRequest.bind(this));
@@ -552,7 +554,8 @@ end`.replace(/\r?\n/g, " ");
this.id,
status,
this.server.gamePort,
status === "running"? this.server.version : this.config.get("factorio.version"),
// TODO: factorio.version is not validated
status === "running" ? this.server.version : this.config.get("factorio.version") as lib.TargetVersion,
),
);
}
@@ -650,7 +653,7 @@ end`.replace(/\r?\n/g, " ");
if (
!pluginInfo.instanceEntrypoint
|| !this._host.serverPlugins.has(pluginInfo.name)
|| !this.config.get(`${pluginInfo.name}.load_plugin` as keyof lib.InstanceConfigFields)
|| !this.config.get(`${pluginInfo.name}.load_plugin`)
) {
continue;
}
@@ -959,10 +962,15 @@ end`.replace(/\r?\n/g, " ");
this._loadedSave = saveName;
await this.server.start(saveName);
if (this.config.get("factorio.enable_save_patching") && this.config.get("factorio.enable_script_commands")) {
if (this.config.get("factorio.enable_script_commands")) {
await this.server.disableAchievements();
await this.updateInstanceData();
}
if (this.config.get("factorio.enable_save_patching")) {
this._watchPlayerPromote();
if (this.config.get("factorio.enable_script_commands")) {
await this.updateInstanceData();
}
} else {
this._watchServerLogActions();
}
@@ -1300,6 +1308,14 @@ end`.replace(/\r?\n/g, " ");
}
}
async handleInstanceRestartRequest(request: lib.InstanceRestartRequest) {
await this.stop();
// Stopping invalidates this instance object, a new one must be created by the host
// To do this we simulate a waking an instance and sending a start request
const instanceConnection = await this._host._connectInstance(this.id);
await instanceConnection.instance.handleInstanceStartRequest(new lib.InstanceStartRequest(request.save));
}
async handleInstanceStopRequest() {
await this.stop();
}
+1 -1
View File
@@ -221,7 +221,7 @@ async function loadLayeredIcon(
iconLayer.scale(realScale);
}
// eslint-disable-next-line no-loop-func
iconLayer.scan(0, 0, iconLayer.bitmap.width, iconLayer.bitmap.height, (x, y, sidx) => {
x += xs;
y += ys;
+50 -14
View File
@@ -79,7 +79,7 @@ function versionOrder(a: string, b: string) {
* @returns Array with path to data dir and version found.
* @internal
*/
async function findVersion(factorioDir: string, targetVersion: string) {
async function findVersion(factorioDir: string, targetVersion: lib.TargetVersion): Promise<[string, lib.FullVersion]> {
// There are two supported setups: having the factorio dir be the actual
// install directory, and having the factorio dir be a folder containing
@@ -87,8 +87,10 @@ async function findVersion(factorioDir: string, targetVersion: string) {
let simpleVersion = await getVersion(path.join(factorioDir, "data", "changelog.txt"));
if (simpleVersion !== null) {
if (simpleVersion === targetVersion || targetVersion === "latest") {
return [path.join(factorioDir, "data"), simpleVersion];
if (simpleVersion === targetVersion || simpleVersion.startsWith(targetVersion) || targetVersion === "latest") {
if (lib.isFullVersion(simpleVersion)) {
return [path.join(factorioDir, "data"), simpleVersion];
}
}
throw new Error(
@@ -96,14 +98,14 @@ async function findVersion(factorioDir: string, targetVersion: string) {
);
}
let versions = new Map();
let versions = new Map<lib.FullVersion, string>();
for (let entry of await fs.readdir(factorioDir, { withFileTypes: true })) {
if (!entry.isDirectory()) {
continue;
}
let version = await getVersion(path.join(factorioDir, entry.name, "data", "changelog.txt"));
if (version === null) {
if (version === null || !lib.isFullVersion(version)) {
continue;
}
@@ -118,9 +120,17 @@ async function findVersion(factorioDir: string, targetVersion: string) {
throw new Error(`Unable to find any Factorio install in ${factorioDir}`);
}
if (lib.isPartialVersion(targetVersion)) {
const sorted = [...versions.keys()].sort(versionOrder);
const latest = sorted.find(version => version.startsWith(targetVersion));
if (latest) {
return [path.join(factorioDir, versions.get(latest)!, "data"), latest];
}
}
if (targetVersion === "latest") {
let latest = [...versions.keys()].sort(versionOrder)[0];
return [path.join(factorioDir, versions.get(latest), "data"), latest];
const latest = [...versions.keys()].sort(versionOrder)[0];
return [path.join(factorioDir, versions.get(latest)!, "data"), latest];
}
throw new Error(`Unable to find Factorio version ${targetVersion}`);
@@ -423,7 +433,7 @@ export interface FactorioServerOptions {
* Version of Factorio to use. Can also be the string "latest" to use
* the latest version found in `factorioDir`.
*/
version?: string,
version?: lib.TargetVersion,
/** Path to executable to invoke when starting the server */
executablePath?: string;
/** UDP port to host game on. */
@@ -454,6 +464,32 @@ export interface FactorioServerOptions {
shutdownTimeoutMs?: number,
}
type FactorioServerEvents = {
// Public Events
"stdout": [ rawLine: Buffer ],
"stderr": [ rawLine: Buffer ],
"output": [ parsed: lib.ParsedFactorioOutput, line: string ]
"error": [ err: any ],
"rcon-ready": [],
"game-ready": [],
"exit": [],
"autosave-start": [ name: string ],
"autosave-finished": [ name: string ],
"save-finished": [],
"whitelist-change": [ added: string[], removed: string[] ],
// Private Events
"_autosave": [ name: string ]
"_saving": [],
"_saved": [],
// IPS events
[ipcEvent: `ipc-${string}`]: [ event: any ],
};
/**
* Factorio Server interface
*
@@ -473,7 +509,7 @@ export interface FactorioServerOptions {
* - exit - invoked when the sterver has exited
* @extends events.EventEmitter
*/
export class FactorioServer extends events.EventEmitter {
export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
/** Path to executable to invoke when starting the server */
executablePath?: string;
/** UDP port used for hosting the Factorio game server on */
@@ -497,7 +533,7 @@ export class FactorioServer extends events.EventEmitter {
_writeDir: string;
// Resolved in init
_version: string | null = null;
_version: lib.FullVersion | null = null;
_dataDir: string | null = null;
// Due to inconsistencies in the factorio api, we must manually watch the whitelist
@@ -506,7 +542,7 @@ export class FactorioServer extends events.EventEmitter {
_whitelist = new Set<string>();
_logger: lib.Logger;
_targetVersion: string;
_targetVersion: lib.TargetVersion;
_state: "new" | "init" | "create" | "running" | "stopping" = "new";
_server: child_process.ChildProcessWithoutNullStreams | null = null;
_rconClient: Rcon | null= null;
@@ -721,7 +757,7 @@ export class FactorioServer extends events.EventEmitter {
this._rconClient.on("authenticated", () => {
this._rconReady = true;
this.emit("rcon-ready");
});
});
this._rconClient.on("end", () => {
this._rconClient = null;
if (!this._rconReady) {
@@ -840,7 +876,7 @@ export class FactorioServer extends events.EventEmitter {
* "latest" (the default) was specified as `factorioVersion` to the constructor.
* This will be undefined before the server is initialized, or if init failed.
*/
get version(): string | undefined {
get version(): lib.FullVersion | undefined {
return this._version ?? undefined;
}
@@ -1212,7 +1248,7 @@ export class FactorioServer extends events.EventEmitter {
// complete before the RCON connection can be used
if (!this._rconReady) {
// Not using events.once here to avoid throwing on error events.
await new Promise(resolve => this.once("rcon-ready", resolve));
await new Promise<void>(resolve => this.once("rcon-ready", resolve));
}
// The Factorio server may have decided to get ahead of us and
+1 -1
View File
@@ -15,4 +15,4 @@ export * from "./src/subscriptions";
export { default as ExponentialBackoff } from "./src/ExponentialBackoff";
export { default as RateLimiter } from "./src/RateLimiter";
export { default as TypedEventEmitter } from "./src/TypedEventEmitter";
export { default as ValueCache } from "./src/ValueCache";
+7 -7
View File
@@ -1,10 +1,10 @@
export interface BuildArgs {
factorioVersion?: string;
dependencies?: Record<string, string>;
clean?: boolean;
pack?: boolean;
build?: boolean;
outputDir: string;
sourceDir: string;
factorioVersion?: string;
dependencies?: Record<string, string>;
clean?: boolean;
pack?: boolean;
build?: boolean;
outputDir: string;
sourceDir: string;
};
export function build(args: BuildArgs): Promise<void>;
+14 -2
View File
@@ -115,9 +115,21 @@ async function main() {
"clean": { describe: "Remove previous builds", type: "boolean", default: false },
"build": { describe: "Build mod(s)", type: "boolean", default: true },
"pack": { describe: "Pack into zip file", type: "boolean", default: true },
"source-dir": { describe: "Path to mod source files", nargs: 1, type: "string", demandOption: true },
"output-dir": { describe: "Path to output built mod(s)", nargs: 1, type: "string", default: "dist" },
"factorio-version": { describe: "Override factorio_version", type: "string" },
"source-dir": {
describe: "Path to mod source files",
type: "string",
nargs: 1,
normalize: true,
demandOption: true,
},
"output-dir": {
describe: "Path to output built mod(s)",
type: "string",
nargs: 1,
normalize: true,
default: "dist/factorio",
},
})
.strict()
.argv
+3 -1
View File
@@ -9,6 +9,7 @@ export * from "./src/config";
export * from "./src/data";
export * from "./src/database";
export * from "./src/errors";
export * from "./src/external";
export * from "./src/factorio";
export * from "./src/file_ops";
export * from "./src/hash";
@@ -31,8 +32,9 @@ export * from "./src/subscriptions";
export * from "./src/datastore";
export * from "./src/load_plugin_list";
export * from "./src/rce_ops";
export * from "./src/LockFile";
export { default as ExponentialBackoff } from "./src/ExponentialBackoff";
export { default as ModStore } from "./src/ModStore";
export { default as RateLimiter } from "./src/RateLimiter";
export { default as TypedEventEmitter } from "./src/TypedEventEmitter";
export { default as ValueCache } from "./src/ValueCache";
+16 -16
View File
@@ -1,7 +1,7 @@
{
"name": "@clusterio/lib",
"description": "Shared library for Clusterio",
"version": "2.0.0-alpha.21",
"version": "2.0.0-alpha.22b",
"repository": "https://github.com/clusterio/clusterio",
"license": "MIT",
"scripts": {
@@ -18,30 +18,30 @@
"node": ">=12"
},
"dependencies": {
"@sinclair/typebox": "^0.30.4",
"ajv": "^8.17.1",
"chalk": "^4.1.2",
"@sinclair/typebox": "catalog:",
"set-blocking": "catalog:",
"fs-extra": "catalog:",
"jszip": "catalog:",
"chalk": "catalog:",
"ws": "catalog:",
"winston": "catalog:",
"winston-transport": "catalog:",
"fast-deep-equal": "^3.1.3",
"fs-extra": "^11.3.0",
"jszip": "^3.10.1",
"klaw": "^4.1.0",
"set-blocking": "^2.0.0",
"triple-beam": "^1.4.1",
"winston": "^3.17.0",
"winston-transport": "^4.9.0",
"ws": "^8.18.1",
"ajv": "^8.17.1",
"klaw": "^4.1.0",
"yargs": "^17.7.2"
},
"publishConfig": {
"access": "public"
},
"devDependencies": {
"@types/fs-extra": "^11.0.1",
"@types/node": "^20.17.19",
"@types/set-blocking": "^2.0.0",
"@types/fs-extra": "catalog:",
"@types/node": "catalog:",
"@types/ws": "catalog:",
"@types/set-blocking": "catalog:",
"@types/triple-beam": "^1.3.2",
"@types/ws": "^8.5.14",
"@types/yargs": "^17.0.33",
"typescript": "^5.7.3"
"typescript": "catalog:"
}
}
+163
View File
@@ -0,0 +1,163 @@
import { FileHandle, open, unlink, readFile } from "fs/promises";
import { unlinkSync } from "fs";
import assert from "assert";
/**
* Error thrown when the lock file already exists.
*/
export class LockFileExistsError extends Error {
constructor(
readonly filePath: string
) {
super(`Cannot acquire lock, a valid lock file exists at ${filePath}`);
this.name = "LockFileExistsError";
}
}
/**
* Error thrown when the acquire or release called during an invalid state.
*/
export class LockFileBusyError extends Error {
constructor(message: string) {
super(message);
this.name = "LockFileBusyError";
}
}
type LockState = "idle" | "acquiring" | "acquired" | "releasing";
/**
* Cross-platform lock file utility for Node.js.
*/
export class LockFile {
private handle: FileHandle | null = null;
private state: LockState = "idle";
constructor(
private readonly filePath: string
) {}
/**
* Acquire the lock file.
* Throws LockFileExistsError if already locked.
* Throws LockFileBusyError if not in idle state.
*/
async acquire(): Promise<void> {
if (this.state !== "idle") {
throw new LockFileBusyError(`Cannot acquire lock, current state is "${this.state}"`);
}
this.state = "acquiring";
if (await this.isStale()) {
await unlink(this.filePath).catch(() => {}); // ignore errors, may already be removed
}
try {
this.handle = await open(this.filePath, "wx");
await this.handle.write(`${process.pid}\n`);
this.attachExitHandlers();
this.state = "acquired";
} catch (err: any) {
this.state = "idle";
if (err?.code === "EEXIST") {
throw new LockFileExistsError(this.filePath);
}
throw err;
}
}
/**
* Release the lock file.
* Throws LockFileBusyError if not in acquired state.
*/
async release(): Promise<void> {
if (this.state !== "acquired") {
throw new LockFileBusyError(`Cannot release lock, current state is "${this.state}"`);
}
this.state = "releasing";
this.detachExitHandlers();
// Ignored: file may have already been removed by another process
try { await this.handle?.close(); } catch {}
try { await unlink(this.filePath); } catch {}
this.handle = null;
this.state = "idle";
}
/**
* Check whether the lock is currently held by this process.
* True only if the lock is acquired and the PID in the file
* matches the current process.
*/
async isHeld(): Promise<boolean> {
if (this.state !== "acquired") {
return false;
}
try {
const pidInLock = Number(await readFile(this.filePath, "utf8"));
return pidInLock === process.pid;
} catch {
// Ignored: file may not exist or be unreadable, so lock is not held
return false;
}
}
/**
* Determine whether an existing lock is stale.
*/
async isStale(): Promise<boolean> {
try {
const pidInLock = Number(await readFile(this.filePath, "utf8"));
if (!Number.isFinite(pidInLock)) { return true; }
// signal 0 is a special case to test if a process exists
process.kill(pidInLock, 0);
return false;
} catch {
return true;
}
}
/**
* Attach process handlers, called when transitioning to "acquired"
*/
private attachExitHandlers(): void {
assert(this.state === "acquiring");
process.on("SIGINT", this.handleExit);
process.on("SIGTERM", this.handleExit);
process.on("SIGHUP", this.handleExit);
process.on("uncaughtException", this.handleExit);
// eslint-disable-next-line node/no-sync
process.on("exit", this.handleExitSync);
}
/**
* Detach process handlers, called when transitioning to "idle"
*/
private async detachExitHandlers(): Promise<void> {
assert(this.state === "releasing");
process.off("SIGINT", this.handleExit);
process.off("SIGTERM", this.handleExit);
process.off("SIGHUP", this.handleExit);
process.off("uncaughtException", this.handleExit);
// eslint-disable-next-line node/no-sync
process.off("exit", this.handleExitSync);
}
// Event handler for async signals / exceptions
private handleExit = async (): Promise<void> => {
await this.release();
};
// Event handler for synchronous process exit
private handleExitSync = (): void => {
try {
unlinkSync(this.filePath);
} catch (err) {
// Ignored: file may have already been removed by another process
}
};
}
+106 -68
View File
@@ -1,16 +1,16 @@
import fs from "fs-extra";
import path from "path";
import events from "events";
import { Static } from "@sinclair/typebox";
import { logger } from "./logging";
import { integerModVersion, ModInfo, ModPack, ModRecord } from "./data";
import TypedEventEmitter from "./TypedEventEmitter";
import { safeOutputFile } from "./file_ops";
import { Writable } from "stream";
import { ModPortalReleaseSchema, ModPortalDetailsSchema } from "./data/messages_mod";
import { ModPortalReleaseSchema, ModPortalDetailsSchema, ModNameVersionPair } from "./data/messages_mod";
import { ModInfo, ModPack, FullVersion, ApiVersion, ModVersionEquality } from "./data";
export interface ModStoreEvents {
/** A stored mod was created, updated or deleted */
change: (mod: ModInfo) => void;
"change": [ mod: ModInfo ];
}
interface ModsInfoResponse {
@@ -37,7 +37,7 @@ interface ModPortalResponse {
results: Static<typeof ModPortalDetailsSchema>[];
}
export default class ModStore extends TypedEventEmitter<keyof ModStoreEvents, ModStoreEvents> {
export default class ModStore extends events.EventEmitter<ModStoreEvents> {
constructor(
public modsDirectory: string,
public files: Map<string, ModInfo>,
@@ -45,7 +45,7 @@ export default class ModStore extends TypedEventEmitter<keyof ModStoreEvents, Mo
super();
}
getMod(name: string, version: string, 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;
@@ -87,11 +87,11 @@ export default class ModStore extends TypedEventEmitter<keyof ModStoreEvents, Mo
this.emit("change", modInfo);
}
async loadMod(name: string, version: string) {
async loadMod(name: string, version: FullVersion) {
return await this.loadFile(ModInfo.filename(name, version));
}
async deleteMod(name: string, version: string) {
async deleteMod(name: string, version: FullVersion) {
return await this.deleteFile(ModInfo.filename(name, version));
}
@@ -155,10 +155,46 @@ export default class ModStore extends TypedEventEmitter<keyof ModStoreEvents, Mo
return new this(modsDirectory, files);
}
private async downloadModFromReleases(releases: Array<Static<typeof ModPortalReleaseSchema>>, version: string,
/**
* 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.
*
* @param releases - Array of mod releases taken from the mod portal.
* @param version - The required version to install.
* @param username - Factorio username to download using.
* @param token - Factorio token to download using.
*/
private async downloadModFromReleases(
releases: Array<Static<typeof ModPortalReleaseSchema>>, version: ModVersionEquality,
username: string, token: string
) {
const selectedRelease = releases.find(release => release.version === version);
const selectedRelease = releases.find(release => version.testVersion(release.version));
if (selectedRelease === undefined) {
logger.warn(`Mod release matching version ${version} not found.`);
@@ -190,96 +226,83 @@ export default class ModStore extends TypedEventEmitter<keyof ModStoreEvents, Mo
}
}
/**
* Downloads all provided mods, does not perform chunking or filtering of builtins
*
* @param mods - An array of mods to download
* @param username - Factorio username used for auth to download mods
* @param token - Factorio token used for auth to download mods
* @param factorioVersion - Factorio version to filter for (mods are not guaranteed to work between Middle versions)
*/
private async downloadModsChunk(
modNames: string[], modVersions: string[], username: string, token: string, factorioVersion: string
mods: ModNameVersionPair[],
username: string, token: string,
factorioVersion: ApiVersion,
) {
const url = new URL("https://mods.factorio.com/api/mods");
url.searchParams.set("page_size", "max");
url.searchParams.set("version", factorioVersion);
const response = await fetch(url, {
method: "POST",
body: new URLSearchParams({ namelist: modNames.join(",") }),
body: new URLSearchParams({ namelist: mods.map(m => m.name).join(",") }),
});
if (response.status !== 200) {
throw Error(`Fetch: ${url} returned ${response.status} ${response.statusText}`);
}
const mods = (await response.json() as ModsInfoResponse).results;
await Promise.all(mods.map((mod) => {
const modReleases = (await response.json() as ModsInfoResponse).results;
await Promise.all(modReleases.map((mod) => {
const modVersion = mods.find(m => m.name === mod.name)!.version;
if (mod.releases) {
return this.downloadModFromReleases(mod.releases,
modVersions[modNames.indexOf(mod.name)], username, token);
return this.downloadModFromReleases(mod.releases, modVersion, username, token);
} else if (mod.latest_release) {
return this.downloadModFromReleases([mod.latest_release],
modVersions[modNames.indexOf(mod.name)], username, token);
return this.downloadModFromReleases([mod.latest_release], modVersion, username, token);
}
throw new Error(`Mod ${mod.name} has no releases or latest_release`);
}));
}
/**
* Downloads all provided mods to the mod dir.
*
* downloads mods to the mod dir, will not download mods for which are already in the mod directory
*
* @param modMap a map of what mods to download and what version to download
* @param username factorio username used for auth to download mods
* @param token factorio token used for auth to download mods
* @param factorioVersion factorio version to filter for (mods are not guaranteed to work between Middle versions)
* Handles chunking to prevent errors due to URL length.
* Will re-download if mod is already present, if this is undesired use filterInstalled first.
*
* @param mods - An array of mods to download.
* @param username - Factorio username used for auth to download mods.
* @param token - Factorio token used for auth to download mods.
* @param factorioVersion - Factorio version to filter for (mods are not guaranteed to work between Middle versions)
*/
async downloadMods(modMap: Map<string, string>, username: string, token: string, factorioVersion: string = "1.1") {
async downloadMods(
mods: ModNameVersionPair[], username: string, token: string, factorioVersion: ApiVersion = "1.1"
) {
const chunkSize = 500;
let futures: Promise<void>[] = [];
modMap = new Map(modMap);
// Truncate factorioVersion to major.minor (e.g., "1.1.100" -> "1.1") for API/builtin checks
const versionParts = factorioVersion.split(".");
if (versionParts.length > 2) {
factorioVersion = versionParts.slice(0, 2).join(".");
}
const futures: Promise<void>[] = [];
const builtInMods = new Set(ModPack.getBuiltinModNames(factorioVersion));
mods = mods.filter(mod => !(builtInMods.has(mod.name) || mod.name === "core"));
// get rid of mods already downloaded, built-in, or core
modMap.forEach((modVersion, modName, map) => {
const filename = `${modName}_${modVersion}.zip`;
// Filter existing, built-in from getBuiltinModNames, and explicitly filter "core"
if (this.files.has(filename) || builtInMods.has(modName) || modName === "core") {
map.delete(modName);
}
});
const modNames = Array.from(modMap.keys());
const modVersions = Array.from(modMap.values());
for (let i = 0; i < modMap.size; i += chunkSize) {
for (let i = 0; i < mods.length; i += chunkSize) {
futures.push(
this.downloadModsChunk(modNames.slice(i, i + chunkSize), modVersions.slice(i, i + chunkSize),
username, token, factorioVersion)
this.downloadModsChunk(mods.slice(i, i + chunkSize), username, token, factorioVersion)
);
}
await Promise.all(futures);
}
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;
}
/**
* Filter an array of mods based on if they are installed.
*
* @param mods - Array of mods to filer.
* @param installed - `true` to keep installed mods, `false` to keep uninstalled mods.
* @returns Filtered array of mods based on install state.
*/
filterInstalled(mods: ModNameVersionPair[], installed: boolean) {
const installedMods = Array.from(this.mods());
return mods.filter(mod => (
installed === installedMods.some(m => mod.name === m.name && mod.version.testVersion(m.version))
));
}
/**
@@ -332,4 +355,19 @@ export default class ModStore extends TypedEventEmitter<keyof ModStoreEvents, Mo
logger.info(`Successfully fetched ${allMods.length} mods for Factorio version ${factorioVersion}.`);
return allMods;
}
/**
* Fetch the full release list for a mod including dependencies.
*
* @param modName - Name of the mod to fetch the releases of
* @returns Mod portal response
*/
static async fetchModReleases(modName: string) {
const url = `https://mods.factorio.com/api/mods/${modName}/full`;
const response = await fetch(url);
if (!response.ok) {
throw Error(`Fetch: ${url} returned ${response.status} ${response.statusText}`);
}
return await response.json() as ModsInfoResponse["results"][number];
}
}
-27
View File
@@ -1,27 +0,0 @@
import EventEmitter from "events";
/**
* Type checked version of EventEmitter
*
* @example
* ```ts
* interface Events {
* "foo": (a: number, b: number) => void,
* "bar": (spam: string) => void,
* }
*
* class Emitter extends TypedEventEmitter<keyof Events, Events> {}
*
* const e = new Emitter();
* e.emit("foo", 1, 2) // type checked arguments
* ```
*/
export default abstract class TypedEventEmitter<
Keys extends string,
Events extends Record<Keys, (...args: [...any]) => void>,
> extends EventEmitter {
declare ["on"]: <E extends Keys>(event: E, listener: Events[E]) => this;
declare ["off"]: <E extends Keys>(event: E, listener: Events[E]) => this;
declare ["once"]: <E extends Keys>(event: E, listener: Events[E]) => this;
declare ["emit"]: <E extends Keys>(event: E, ...args: Parameters<Events[E]>) => boolean;
}
+64
View File
@@ -0,0 +1,64 @@
/**
* Represents an async function which can fetch a value for a ValueCache
*/
export interface ValueCacheFetch<T> {
(): Promise<T>;
}
/**
* In-memory single value cache
*
* Stores the last time a value was fetched and measures staleness
* Concurrent calls will share the same in-flight fetch
*/
export default class ValueCache<T> {
private ongoingFetch: Promise<T> | null = null;
private timestamp: number | null = null;
private value: T | null = null; // Only valid when timestamp is not null
/**
* Create a new in memory single value cache
*
* @param fetch A function that fetches and returns a fresh value
*/
constructor(
private readonly fetch: ValueCacheFetch<T>,
) {}
/**
* Get the cached value, will fetch a fresh value if stale or missing
*
* @param maxAgeMs Maximum allowed cache age in milliseconds.
* Defaults to 0, meaning it will always fetch a new value.
* @throws TypeError if maxAgeMs is invalid
* @throws Propagates errors from underlying fetch method
*/
get(maxAgeMs: number = 0): Promise<T> {
if (maxAgeMs < 0) {
throw new TypeError("maxAgeMs must be a non-negative number");
}
const now = Date.now();
if (this.timestamp && now - this.timestamp < maxAgeMs) {
return Promise.resolve(this.value as T);
}
if (this.ongoingFetch) {
return this.ongoingFetch;
}
this.ongoingFetch = this.fetch()
.then(value => {
this.timestamp = Date.now();
this.value = value;
return value;
})
.finally(() => {
this.ongoingFetch = null;
});
return this.ongoingFetch;
}
}
+3 -6
View File
@@ -1,8 +1,8 @@
// Configuration classes
import { Type, Static } from "@sinclair/typebox";
import * as fs from "fs-extra";
import events from "events";
import TypedEventEmitter from "../TypedEventEmitter";
import isDeepStrictEqual from "../is_deep_strict_equal";
import { basicType } from "../helpers";
import * as libSchema from "../schema";
@@ -152,7 +152,7 @@ function defaultValue(def: FieldDefinition) {
}
export type ConfigEvents = {
fieldChanged: (name: string, curr: FieldValue, prev: FieldValue) => void;
fieldChanged: [ name: string, curr: FieldValue, prev: FieldValue ],
}
export enum ConfigAccess {
@@ -178,10 +178,7 @@ export enum ConfigAccess {
*/
export class Config<
Fields extends { [Field in keyof Fields]: FieldValue },
> extends TypedEventEmitter<
keyof ConfigEvents,
ConfigEvents
> {
> extends events.EventEmitter<ConfigEvents> {
/**
* Mapping of config name to field meta data
* Note that mutating this object will lead to unexpected behaviour.
+10
View File
@@ -35,6 +35,7 @@ export interface ControllerConfigFields {
"controller.allow_remote_updates": boolean;
"controller.allow_plugin_updates": boolean;
"controller.allow_plugin_install": boolean;
[key: `${string}.load_plugin`]: boolean;
}
/**
@@ -267,6 +268,7 @@ export interface HostConfigFields {
"host.allow_remote_updates": boolean;
"host.allow_plugin_updates": boolean;
"host.allow_plugin_install": boolean;
[key: `${string}.load_plugin`]: boolean;
}
/**
@@ -398,6 +400,8 @@ export interface InstanceConfigFields {
"instance.id": number;
"instance.assigned_host": number | null;
"instance.auto_start": boolean;
"instance.exclude_from_start_all": boolean;
[key: `${string}.load_plugin`]: boolean;
"factorio.version": string;
"factorio.executable_path": string | null;
@@ -465,11 +469,17 @@ export class InstanceConfig extends classes.Config<InstanceConfigFields> {
type: "boolean",
initialValue: false,
},
"instance.exclude_from_start_all": {
description: "Exclude this instance from the 'Start all' button operation",
type: "boolean",
initialValue: false,
},
"factorio.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",
},
"factorio.executable_path": {
description:
+149 -17
View File
@@ -5,9 +5,128 @@ import { Type, Static } from "@sinclair/typebox";
import * as libHash from "../hash";
import * as libSchema from "../schema";
import { findRoot } from "../zip_ops";
import { ModRecord } from "./ModPack";
import { integerModVersion, integerFactorioVersion, modVersionRegExp } from "./version";
import {
ApiVersion,
ApiVersionSchema,
FullVersion, FullVersionSchema, integerFullVersion,
integerPartialVersion,
ModVersionEquality,
} from "./version";
type ModDependencyType = "incompatible" | "optional" | "hidden" | "unordered" | "required";
export type ModDependencyUnsatisfiedReason = "incompatible" | "missing_dependency" | "wrong_version";
const UnsatisfiedSeverity: Record<ModDependencyUnsatisfiedReason, number> = {
"incompatible": 1, "missing_dependency": 2, "wrong_version": 3,
};
const lettersRegex = /^[a-zA-Z]+$/;
export class ModDependency {
public type: ModDependencyType;
public name: string;
public version: ModVersionEquality | undefined;
static getTypeFromPrefix(prefix: string): ModDependencyType {
switch (prefix) {
case "!":
return "incompatible";
case "?":
return "optional";
case "(?)":
return "hidden";
case "~":
return "unordered";
case "":
return "required";
default:
throw new Error(`Invalid dependency prefix: ${prefix}`);
}
}
constructor (
public specification: string,
) {
const parts = specification.split(" ");
// Fix mods which include spaces in their name, joins consecutive words together
let index = 0;
while (index < parts.length - 1) {
const current = parts[index];
const next = parts[index + 1];
if (lettersRegex.test(current) && lettersRegex.test(next)) {
parts[index] = `${current} ${next}`;
parts.splice(index + 1, 1);
} else {
index += 1;
}
}
// Parse the dependency specification
switch (parts.length) {
case 1:
this.type = "required";
this.name = parts[0];
break;
case 2:
this.type = ModDependency.getTypeFromPrefix(parts[0]);
this.name = parts[1];
break;
case 3:
this.type = "required";
this.name = parts[0];
this.version = ModVersionEquality.fromParts(parts[1], parts[2]);
break;
case 4:
this.type = ModDependency.getTypeFromPrefix(parts[0]);
this.name = parts[1];
this.version = ModVersionEquality.fromParts(parts[2], parts[3]);
break;
default:
throw new Error(`Invalid dependency specification: ${specification}`);
}
}
checkUnsatisfiedReason(mods: (ModInfo | ModRecord)[]): ModDependencyUnsatisfiedReason | undefined {
const mod = mods.find(m => m.name === this.name);
if (mod === undefined) {
return ["unordered", "required"].includes(this.type) ? "missing_dependency" : undefined;
} else if (this.type === "incompatible") {
return "incompatible";
} else if (this.version && !this.version.testVersion(mod.version)) {
return "wrong_version";
}
return undefined;
}
isSatisfied(mods: (ModInfo | ModRecord)[]) {
return this.checkUnsatisfiedReason(mods) === undefined;
}
get incompatible() {
return this.type === "incompatible";
}
get required() {
return this.type === "unordered" || this.type === "required";
}
get optional() {
return this.type === "hidden" || this.type === "optional";
}
static jsonSchema = Type.String();
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json);
}
toJSON() {
return this.specification;
}
}
/**
* Info about a mod available on the controller.
@@ -35,13 +154,13 @@ export default class ModInfo {
* Version of the mod.
* Sourced from info.json.
*/
version = "";
version = "0.0.0" as FullVersion;
/**
* Integer representation of the version
*/
get integerVersion() {
return integerModVersion(this.version);
return integerFullVersion(this.version);
}
/**
@@ -78,21 +197,38 @@ export default class ModInfo {
* Major version of Factorio this mod supports.
* Sourced from info.json.
*/
factorioVersion = "0.12";
factorioVersion = "0.12" as "0.12" | ApiVersion;
/**
* Integer representation of the factorioVersion
* @type {number}
*/
get integerFactorioVersion() {
return integerFactorioVersion(this.factorioVersion);
return integerPartialVersion(this.factorioVersion);
}
/**
* Dependiences for this mod.
* Dependencies for this mod.
* Sourced from info.json.
*/
dependencies = ["base"];
dependencies = [new ModDependency("base")];
/**
* Dependencies for this mod.
* As they would be represented in info.json.
*/
get dependencySpecifications() {
return this.dependencies.map(d => d.specification);
}
checkDependencySatisfaction(mods: (ModInfo | ModRecord)[]) {
return this.dependencies
.map(d => d.checkUnsatisfiedReason(mods))
.filter((v): v is ModDependencyUnsatisfiedReason => Boolean(v))
.reduce<ModDependencyUnsatisfiedReason | undefined>((max, current) => (
!max || UnsatisfiedSeverity[max] > UnsatisfiedSeverity[current] ? current : max
), undefined);
}
/**
* Expected name of zip file containing this mod.
@@ -107,7 +243,7 @@ export default class ModInfo {
* @param version - Mod's version
* @returns string containing {name}_{version}.zip
*/
static filename(name: string, version: string) {
static filename(name: string, version: FullVersion) {
return `${name}_${version}.zip`;
}
@@ -137,13 +273,13 @@ export default class ModInfo {
// Content of info.json found in mod files
static infoJsonSchema = Type.Object({
"name": Type.String(),
"version": Type.String(),
"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(Type.String()),
"factorio_version": Type.Optional(ApiVersionSchema),
"dependencies": Type.Optional(Type.Array(Type.String())),
});
@@ -172,7 +308,7 @@ export default class ModInfo {
if (json.homepage) { modInfo.homepage = json.homepage; }
if (json.description) { modInfo.description = json.description; }
if (json.factorio_version) { modInfo.factorioVersion = json.factorio_version; }
if (json.dependencies) { modInfo.dependencies = json.dependencies; }
if (json.dependencies) { modInfo.dependencies = json.dependencies.map(d => new ModDependency(d)); }
// Additional data
if (json.size) { modInfo.size = json.size; }
@@ -195,8 +331,8 @@ export default class ModInfo {
if (this.homepage) { json.homepage = this.homepage; }
if (this.description) { json.description = this.description; }
if (this.factorioVersion !== "0.12") { json.factorio_version = this.factorioVersion; }
if (this.dependencies.length !== 1 || this.dependencies[0] !== "base") {
json.dependencies = this.dependencies;
if (this.dependencies.length !== 1 || this.dependencies[0].name !== "base") {
json.dependencies = this.dependencySpecifications;
}
if (this.size) { json.size = this.size; }
if (this.mtimeMs) { json.mtime_ms = this.mtimeMs; }
@@ -227,10 +363,6 @@ export default class ModInfo {
throw new Error("Mod's info.json is not valid");
}
if (!modVersionRegExp.test(modInfo.version)) {
throw new Error(`Mod's version (${modInfo.version}) is invalid`);
}
const stat = await fs.stat(modPath);
return this.fromJSON({
...modInfo,
+31 -20
View File
@@ -5,9 +5,15 @@ import * as libSchema from "../schema";
import * as libLuaTools from "../lua_tools";
import ExportManifest from "./ExportManifest";
import { integerFactorioVersion } from "./version";
import type { Logger } from "../logging";
import ModInfo, { ModDependencyUnsatisfiedReason } from "./ModInfo";
import {
PartialVersion, PartialVersionSchema, integerPartialVersion,
FullVersion, FullVersionSchema, normaliseFullVersion,
} from "./version";
export const ModSettingColor = Type.Object({
r: Type.Number(),
@@ -40,17 +46,21 @@ export interface ModRecord {
/** if mod is to be loaded. */
enabled: boolean,
/** version of the mod. */
version: string,
version: FullVersion,
/** SHA1 hash of the zip file. */
sha1?: string,
/** Used inside packages\web_ui\src\components\ModPackViewPage.tsx to define an error type. */
error?: "missing"|"bad_checksum",
error?: "missing" | "bad_checksum",
/** Used inside packages\web_ui\src\components\ModPackViewPage.tsx to define an warning type. */
warning?: ModDependencyUnsatisfiedReason | "wrong_factorio_version",
/** Used inside packages\web_ui\src\components\ModPackViewPage.tsx when there is no error. */
info?: ModInfo,
}
const ModRecordJsonSchema = Type.Object({
"name": Type.String(),
"enabled": Type.Boolean(),
"version": Type.String(),
"version": FullVersionSchema,
"sha1": Type.Optional(Type.String()),
});
@@ -82,13 +92,13 @@ export default class ModPack {
/**
* Version of Factorio this mod pack is for
*/
factorioVersion = "1.1.0";
factorioVersion = "1.1.0" as PartialVersion;
/**
* Integer representation of the factorioVersion
*/
get integerFactorioVersion() {
return integerFactorioVersion(this.factorioVersion);
return integerPartialVersion(this.factorioVersion);
}
/**
@@ -136,7 +146,7 @@ export default class ModPack {
"id": Type.Optional(Type.Integer()),
"name": Type.String(),
"description": Type.String(),
"factorio_version": Type.String(),
"factorio_version": PartialVersionSchema,
"mods": Type.Array(ModRecordJsonSchema),
"settings": Type.Object({
"startup": ModSettingsJsonSchema,
@@ -161,7 +171,7 @@ export default class ModPack {
if (json.name) { modPack.name = json.name; }
if (json.description) { modPack.description = json.description; }
if (json.factorio_version) { modPack.factorioVersion = json.factorio_version; }
if (json.mods) { modPack.mods = new Map(json.mods.map(m => [m.name, m])); }
if (json.mods) { modPack.mods = new Map(json.mods.map((m: ModRecord) => [m.name, m])); }
if (json.settings) {
modPack.settings = {
"startup": new Map(Object.entries(json.settings["startup"])),
@@ -218,7 +228,7 @@ export default class ModPack {
let buf = Buffer.from(modPackString, "base64");
try {
// eslint-disable-next-line node/no-sync
buf = zlib.inflateSync(buf);
buf = Buffer.from(zlib.inflateSync(buf));
} catch (err: any) {
if (err.code.startsWith("Z_")) {
throw new Error("Malformed mod pack string: zlib inflate failed");
@@ -291,7 +301,7 @@ export default class ModPack {
const sizeBytes = uint32Bytes(entries.length);
const itemBytes = entries.flatMap(([key, item]) => [
immutableStringBytes(key),
// eslint-disable-next-line no-use-before-define
propertyTreeBytes(item),
]);
return Buffer.concat([
@@ -385,39 +395,40 @@ export default class ModPack {
/** 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"]
...(["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.`,
factorio_version: version,
} as any)),
...["2.0"]
...(["2.0"] 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 },
{ name: "quality", enabled: true, version },
{ name: "space-age", enabled: true, version },
{ name: "elevated-rails", enabled: true, version: normaliseFullVersion(version) },
{ name: "quality", enabled: true, version: normaliseFullVersion(version) },
{ name: "space-age", enabled: true, version: normaliseFullVersion(version) },
],
} as any)),
];
/**
* Returns an array of ModInfo containing all mods included in factorio
* Returns an array of ModRecord containing all mods included in factorio
*
* @param factorioVersion - Factorio version to get the default mods for
* @return Built in mods for the given version
*/
static getBuiltinMods(factorioVersion: string) {
static getBuiltinMods(factorioVersion: PartialVersion) {
factorioVersion = normaliseFullVersion(factorioVersion);
let defaultMods: ModRecord[] = [
// "core" not included because core cannot be disabled
{ name: "base", enabled: true, version: factorioVersion },
];
const integerVersion = integerFactorioVersion(factorioVersion);
if (integerVersion >= integerFactorioVersion("1.2")) {
const integerVersion = integerPartialVersion(factorioVersion);
if (integerVersion >= integerPartialVersion("1.2")) {
defaultMods = defaultMods.concat([
{ name: "elevated-rails", enabled: false, version: factorioVersion },
{ name: "quality", enabled: false, version: factorioVersion },
@@ -428,7 +439,7 @@ export default class ModPack {
return defaultMods;
}
static getBuiltinModNames(factorioVersion: string) {
static getBuiltinModNames(factorioVersion: PartialVersion) {
return this.getBuiltinMods(factorioVersion).map(builtinMod => builtinMod.name);
}
}
+9 -9
View File
@@ -10,6 +10,8 @@ export default class Role {
public name: string,
public description: string,
public permissions: Set<string>,
public updatedAtMs = 0,
public isDeleted = false,
) { }
static jsonSchema = Type.Object({
@@ -17,22 +19,18 @@ export default class Role {
name: Type.String(),
description: Type.String(),
permissions: Type.Array(Type.String()),
updated_at_ms: Type.Optional(Type.Number()),
is_deleted: Type.Optional(Type.Boolean()),
});
static remapPermission(permission: string) {
// migrate: core.instance.save.list.subscribe was renamed in alpha 17
if (permission === "core.instance.save.list.subscribe") {
return "core.instance.save.subscribe";
}
return permission;
}
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(
json.id,
json.name,
json.description,
new Set(json.permissions.map(this.remapPermission)),
new Set(json.permissions),
json.updated_at_ms,
json.is_deleted,
);
}
@@ -42,6 +40,8 @@ export default class Role {
name: this.name,
description: this.description,
permissions: [...this.permissions],
updated_at_ms: this.updatedAtMs,
is_deleted: this.isDeleted,
};
}
+2 -1
View File
@@ -4,7 +4,7 @@
* @author Hornwitser
*/
export { default as ExportManifest } from "./ExportManifest";
export { default as ModInfo } from "./ModInfo";
export { default as ModInfo, ModDependency } from "./ModInfo";
export { ModSettingColor } from "./ModPack";
export type { ModSetting, ModRecord } from "./ModPack";
export { default as ModPack } from "./ModPack";
@@ -21,5 +21,6 @@ export * from "./messages_host";
export * from "./messages_instance";
export * from "./messages_mod";
export * from "./messages_user";
export * from "./messages_role";
export * from "./messages_plugin";
export * from "./version";
@@ -2,6 +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 } from "../external";
export class ControllerStopRequest {
declare ["constructor"]: typeof ControllerStopRequest;
@@ -391,3 +392,25 @@ export class DebugWsMessageEvent {
return new this(json.direction, json.content);
}
}
export class FactorioVersionsRequest {
declare ["constructor"]: typeof FactorioVersionsRequest;
static type = "request" as const;
static src = "control" as const;
static dst = "controller" as const;
static permission = "core.external.get_factorio_versions" 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(Type.Array(ExternalFactorioVersionSchema));
}
+3 -3
View File
@@ -1,6 +1,6 @@
// Note: Run compile_validator script after making changes to this file.
import { TSchema, Type, Static } from "@sinclair/typebox";
// eslint-disable-next-line node/no-missing-import
import messageValidate from "./message_validate"; // generated file
import { StringEnum } from "./composites";
@@ -154,7 +154,7 @@ export class Message {
static validate = messageValidate;
static fromJSON(json: any) {
/* eslint-disable no-use-before-define */
if (json.type === "hello") { return MessageHello.fromJSON(json); }
if (json.type === "registerHost") { return MessageRegisterHost.fromJSON(json); }
if (json.type === "registerControl") { return MessageRegisterControl.fromJSON(json); }
@@ -168,7 +168,7 @@ export class Message {
if (json.type === "responseError") { return MessageResponseError.fromJSON(json); }
if (json.type === "event") { return MessageEvent.fromJSON(json); }
if (json.type === "disconnect") { return MessageDisconnect.fromJSON(json); }
/* eslint-enable no-use-before-define */
throw new Error(`Unrecognized message type ${json.type}`);
}
}
+31 -7
View File
@@ -5,6 +5,7 @@ import { InstanceConfig } from "../config";
import type { IControllerUser } from "./User";
import type { MessageRequest } from "./messages_core";
import { CollectorResultSerialized } from "../prometheus";
import { TargetVersion, TargetVersionSchema, PartialVersion, PartialVersionSchema } from "./version";
export const InstanceStatus = StringEnum([
@@ -42,9 +43,10 @@ export class InstanceDetails {
public assignedHost: number | undefined,
public gamePort: number | undefined,
public status: InstanceStatus,
public factorioVersion: string | undefined,
public factorioVersion: TargetVersion | undefined,
/** Millisecond Unix timestamp this entry was last updated at */
public updatedAtMs = 0,
public excludeFromStartAll = false,
) { }
static jsonSchema = Type.Object({
@@ -56,8 +58,9 @@ export class InstanceDetails {
"unknown", "unassigned", "stopped", "starting", "running", "stopping",
"creating_save", "exporting_data", "deleted",
]),
"factorioVersion": Type.Optional(Type.String()),
"factorioVersion": Type.Optional(TargetVersionSchema),
"updatedAtMs": Type.Optional(Type.Number()),
"excludeFromStartAll": Type.Optional(Type.Boolean()),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
@@ -69,6 +72,7 @@ export class InstanceDetails {
json.status,
json.factorioVersion,
json.updatedAtMs,
json.excludeFromStartAll,
);
}
@@ -284,6 +288,26 @@ export class InstanceStartRequest {
}
}
export class InstanceRestartRequest {
declare ["constructor"]: typeof InstanceRestartRequest;
static type = "request" as const;
static src = ["control", "controller"] as const;
static dst = "instance" as const;
static permission = "core.instance.restart" as const;
constructor(
public save?: string,
) { }
static jsonSchema = Type.Object({
"save": Type.Optional(Type.String()),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.save);
}
}
export class SaveDetails {
constructor(
public instanceId: number,
@@ -721,8 +745,8 @@ export class HostInstanceUpdate {
constructor(
public config: Static<typeof InstanceConfig.jsonSchema>,
public status: InstanceStatus,
public gamePort: undefined | number,
public factorioVersion: undefined | string,
public gamePort: number | undefined,
public factorioVersion: PartialVersion | undefined,
) { }
static jsonSchema = Type.Object({
@@ -731,7 +755,7 @@ export class HostInstanceUpdate {
"stopped", "starting", "running", "stopping", "creating_save", "exporting_data",
]),
"gamePort": Type.Optional(Type.Number()),
"factorioVersion": Type.Optional(Type.String()),
"factorioVersion": Type.Optional(PartialVersionSchema),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
@@ -829,7 +853,7 @@ export class InstanceStatusChangedEvent {
public instanceId: number,
public status: InstanceStatus,
public gamePort?: number,
public factorioVersion?: string,
public factorioVersion?: TargetVersion,
) { }
static jsonSchema = Type.Object({
@@ -838,7 +862,7 @@ export class InstanceStatusChangedEvent {
"stopped", "starting", "running", "stopping", "creating_save", "exporting_data",
]),
"gamePort": Type.Optional(Type.Integer()),
"factorioVersion": Type.Optional(Type.String()),
"factorioVersion": Type.Optional(TargetVersionSchema),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
+110 -30
View File
@@ -1,8 +1,14 @@
import { Type, Static } from "@sinclair/typebox";
import ModInfo from "./ModInfo";
import ModInfo, { ModDependency } from "./ModInfo";
import ModPack from "./ModPack";
import { JsonString, jsonArray } from "./composites";
import {
FullVersion, FullVersionSchema,
ApiVersion, ApiVersionSchema, normaliseApiVersion,
ModVersionEquality,
} from "./version";
export class ModPackGetRequest {
declare ["constructor"]: typeof ModPackGetRequest;
@@ -113,13 +119,13 @@ export class ModGetRequest {
constructor(
public name: string,
public version: string,
public version: FullVersion,
public sha1?: string,
) { }
static jsonSchema = Type.Object({
"name": Type.String(),
"version": Type.String(),
"version": FullVersionSchema,
"sha1": Type.Optional(Type.String()),
});
@@ -148,7 +154,7 @@ export class ModSearchRequest {
constructor(
public query: string,
public factorioVersion: string,
public factorioVersion: ApiVersion,
public page: number,
public pageSize?: number,
public sort?: string,
@@ -157,7 +163,7 @@ export class ModSearchRequest {
static jsonSchema = Type.Object({
"query": Type.String(),
"factorioVersion": Type.String(),
"factorioVersion": ApiVersionSchema,
"page": Type.Integer(),
"pageSize": Type.Optional(Type.Integer()),
"sort": Type.Optional(Type.String()),
@@ -199,9 +205,9 @@ export class ModSearchRequest {
// Define the structure for the latest release info from the portal
export const ModPortalReleaseSchema = Type.Object({
version: Type.String(),
version: FullVersionSchema,
// Match the structure from ModStore's ModRelease/ModDetails
info_json: Type.Object({ factorio_version: Type.String() }),
info_json: Type.Object({ factorio_version: ApiVersionSchema }),
released_at: Type.String(), // ISO 8601 date string
download_url: Type.String(),
file_name: Type.String(),
@@ -229,20 +235,13 @@ export class ModPortalGetAllRequest {
static dst = "controller" as const;
static permission = "core.mod.search_portal" as const;
// Define allowed Factorio versions
static allowedVersions = [
Type.Literal("0.13"), Type.Literal("0.14"), Type.Literal("0.15"),
Type.Literal("0.16"), Type.Literal("0.17"), Type.Literal("0.18"),
Type.Literal("1.0"), Type.Literal("1.1"), Type.Literal("2.0"),
] as const;
constructor(
public factorioVersion: Static<typeof ModPortalGetAllRequest.allowedVersions[number]>,
public factorioVersion: ApiVersion,
public hide_deprecated?: boolean,
) { }
static jsonSchema = Type.Object({
"factorioVersion": Type.Union([...this.allowedVersions]), // Use Union for validation
"factorioVersion": ApiVersionSchema,
"hide_deprecated": Type.Optional(Type.Boolean()),
});
@@ -277,13 +276,13 @@ export class ModDownloadRequest {
constructor(
public name: string,
public version: string,
public version: FullVersion,
public sha1?: string,
) { }
static jsonSchema = Type.Object({
"name": Type.String(),
"version": Type.String(),
"version": FullVersionSchema,
"sha1": Type.Optional(Type.String()),
});
@@ -303,12 +302,12 @@ export class ModDeleteRequest {
constructor(
public name: string,
public version: string,
public version: FullVersion,
) { }
static jsonSchema = Type.Object({
"name": Type.String(),
"version": Type.String(),
"version": FullVersionSchema,
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
@@ -356,6 +355,16 @@ export class ModUpdatesEvent {
}
}
export interface ModNameVersionPair {
name: string,
version: ModVersionEquality,
}
export const ModNameVersionPairSchema = Type.Object({
"name": Type.String(),
"version": ModVersionEquality.jsonSchema,
});
/**
* Request mods to be downloaded from the Factorio mod portal to the controller.
*
@@ -363,8 +372,7 @@ export class ModUpdatesEvent {
* Requires Factorio credentials to be configured on the controller if the portal
* requires authentication for downloads.
*
* @param name - Name of the mod to download.
* @param version - Version of the mod to download.
* @param mods - Array of mods to be downloaded.
* @param factorioVersion - Factorio version context for the download.
*/
export class ModPortalDownloadRequest {
@@ -373,21 +381,93 @@ export class ModPortalDownloadRequest {
static src = "control" as const;
static dst = "controller" as const;
static permission = "core.mod.download_from_portal" as const;
static Response = ModInfo;
static Response = jsonArray(ModInfo);
constructor(
public name: string,
public version: string,
public factorioVersion: string,
public mods: ModNameVersionPair[],
public factorioVersion: ApiVersion,
) { }
static jsonSchema = Type.Object({
"name": Type.String(),
"version": Type.String(),
"factorioVersion": Type.String(),
"mods": Type.Array(ModNameVersionPairSchema),
"factorioVersion": ApiVersionSchema,
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.name, json.version, json.factorioVersion);
return new this(
json.mods.map(mod => ({
name: mod.name, version: ModVersionEquality.fromJSON(mod.version),
})),
json.factorioVersion,
);
}
}
/**
* Request mods dependencies to be resolved using the Factorio mod portal.
*
* @param mods - Array of dependencies to resolve.
* @param factorioVersion - Factorio version context for resolution.
*/
export class ModDependencyResolveRequest {
declare ["constructor"]: typeof ModDependencyResolveRequest;
static type = "request" as const;
static src = "control" as const;
static dst = "controller" as const;
static permission = "core.mod.search_portal" as const;
constructor(
public mods: ModDependency[],
public factorioVersion: ApiVersion,
public checkForUpdates: boolean = false,
) { }
static jsonSchema = Type.Object({
"mods": Type.Array(ModDependency.jsonSchema),
"factorioVersion": ApiVersionSchema,
"checkForUpdates": Type.Boolean(),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.mods.map(spec => new ModDependency(spec)), json.factorioVersion, json.checkForUpdates);
}
static fromModPack(modPack: ModPack, checkForUpdates?: boolean) {
const equality = checkForUpdates ? ">=" : "=";
return new this(
[...modPack.mods.values()]
.map(mod => new ModDependency(`${mod.name} ${equality} ${mod.version}`)),
normaliseApiVersion(modPack.factorioVersion),
checkForUpdates,
);
}
static fromModPackEnabled(modPack: ModPack, checkForUpdates?: boolean) {
const equality = checkForUpdates ? ">=" : "=";
return new this(
[...modPack.mods.values()]
.filter(mod => mod.enabled)
.map(mod => new ModDependency(`${mod.name} ${equality} ${mod.version}`)),
normaliseApiVersion(modPack.factorioVersion),
checkForUpdates,
);
}
static Response = class ModDependencyResolveResponse {
constructor(
public dependencies: ModInfo[],
public incompatible: string[],
public missing: string[],
) {}
static jsonSchema = Type.Object({
"dependencies": Type.Array(ModInfo.jsonSchema),
"incompatible": Type.Array(Type.String()),
"missing": Type.Array(Type.String()),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.dependencies.map(d => ModInfo.fromJSON(d)), json.incompatible, json.missing);
}
};
}
+134
View File
@@ -0,0 +1,134 @@
import { Type, Static } from "@sinclair/typebox";
import { JsonNumber, jsonArray } from "./composites";
import Permission from "./Permission";
import Role from "./Role";
export class PermissionListRequest {
declare ["constructor"]: typeof PermissionListRequest;
static type = "request" as const;
static src = "control" as const;
static dst = "controller" as const;
static permission = "core.permission.list" as const;
static Response = jsonArray(Permission);
}
export class RoleListRequest {
declare ["constructor"]: typeof RoleListRequest;
static type = "request" as const;
static src = "control" as const;
static dst = "controller" as const;
static permission = "core.role.list" as const;
static Response = jsonArray(Role);
}
export class RoleUpdatesEvent {
declare ["constructor"]: typeof RoleUpdatesEvent;
static type = "event" as const;
static src = "controller" as const;
static dst = "control" as const;
static permission = "core.role.subscribe" as const;
constructor(
public updates: Role[],
) { }
static jsonSchema = Type.Object({
"updates": Type.Array(Role.jsonSchema),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.updates.map(update => Role.fromJSON(update)));
}
}
export class RoleCreateRequest {
declare ["constructor"]: typeof RoleCreateRequest;
static type = "request" as const;
static src = "control" as const;
static dst = "controller" as const;
static permission = "core.role.create" as const;
constructor(
public name: string,
public description: string,
public permissions: string[],
) { }
static jsonSchema = Type.Object({
name: Type.String(),
description: Type.String(),
permissions: Type.Array(Type.String()),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.name, json.description, json.permissions);
}
static Response = JsonNumber;
}
export class RoleUpdateRequest {
declare ["constructor"]: typeof RoleUpdateRequest;
static type = "request" as const;
static src = "control" as const;
static dst = "controller" as const;
static permission = "core.role.update" as const;
constructor(
public id: number,
public name: string,
public description: string,
public permissions: string[],
) { }
static jsonSchema = Type.Object({
id: Type.Integer(),
name: Type.String(),
description: Type.String(),
permissions: Type.Array(Type.String()),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.id, json.name, json.description, json.permissions);
}
}
export class RoleGrantDefaultPermissionsRequest {
declare ["constructor"]: typeof RoleGrantDefaultPermissionsRequest;
static type = "request" as const;
static src = "control" as const;
static dst = "controller" as const;
static permission = "core.role.update" as const;
constructor(
public id: number,
) { }
static jsonSchema = Type.Object({
id: Type.Integer(),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.id);
}
}
export class RoleDeleteRequest {
declare ["constructor"]: typeof RoleDeleteRequest;
static type = "request" as const;
static src = "control" as const;
static dst = "controller" as const;
static permission = "core.role.delete" as const;
constructor(
public id: number,
) { }
static jsonSchema = Type.Object({
id: Type.Integer(),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.id);
}
}
+1 -113
View File
@@ -1,120 +1,8 @@
import { Type, Static } from "@sinclair/typebox";
import Permission from "./Permission";
import Role from "./Role";
import User, { IControllerUser } from "./User";
import { JsonNumber, StringEnum, jsonArray, plainJson } from "./composites";
import { StringEnum, jsonArray, plainJson } from "./composites";
import { MessageRequest } from "./messages_core";
export class PermissionListRequest {
declare ["constructor"]: typeof PermissionListRequest;
static type = "request" as const;
static src = "control" as const;
static dst = "controller" as const;
static permission = "core.permission.list" as const;
static Response = jsonArray(Permission);
}
export class RoleListRequest {
declare ["constructor"]: typeof RoleListRequest;
static type = "request" as const;
static src = "control" as const;
static dst = "controller" as const;
static permission = "core.role.list" as const;
static Response = jsonArray(Role);
}
export class RoleCreateRequest {
declare ["constructor"]: typeof RoleCreateRequest;
static type = "request" as const;
static src = "control" as const;
static dst = "controller" as const;
static permission = "core.role.create" as const;
constructor(
public name: string,
public description: string,
public permissions: string[],
) { }
static jsonSchema = Type.Object({
name: Type.String(),
description: Type.String(),
permissions: Type.Array(Type.String()),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.name, json.description, json.permissions);
}
static Response = JsonNumber;
}
export class RoleUpdateRequest {
declare ["constructor"]: typeof RoleUpdateRequest;
static type = "request" as const;
static src = "control" as const;
static dst = "controller" as const;
static permission = "core.role.update" as const;
constructor(
public id: number,
public name: string,
public description: string,
public permissions: string[],
) { }
static jsonSchema = Type.Object({
id: Type.Integer(),
name: Type.String(),
description: Type.String(),
permissions: Type.Array(Type.String()),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.id, json.name, json.description, json.permissions);
}
}
export class RoleGrantDefaultPermissionsRequest {
declare ["constructor"]: typeof RoleGrantDefaultPermissionsRequest;
static type = "request" as const;
static src = "control" as const;
static dst = "controller" as const;
static permission = "core.role.update" as const;
constructor(
public id: number,
) { }
static jsonSchema = Type.Object({
id: Type.Integer(),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.id);
}
}
export class RoleDeleteRequest {
declare ["constructor"]: typeof RoleDeleteRequest;
static type = "request" as const;
static src = "control" as const;
static dst = "controller" as const;
static permission = "core.role.delete" as const;
constructor(
public id: number,
) { }
static jsonSchema = Type.Object({
id: Type.Integer(),
});
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.id);
}
}
export class UserGetRequest {
declare ["constructor"]: typeof UserGetRequest;
static type = "request" as const;
+224 -16
View File
@@ -1,33 +1,241 @@
// Helper functions for dealing with factorio version numbers
import { Static, Type } from "@sinclair/typebox";
/**
* Integer representation of a mod version
* Integer representation of a version string.
*
* Suitable for sorting and comparing version numbers which may have different
* number of leading zeros in them.
* @param version - string representation of version.
* @returns numeric representation.
*/
export function integerModVersion(version: string) {
export type IntegerVersion = number & { [integerVersionSymbol]: void };
declare const integerVersionSymbol: unique symbol;
/**
* Allowed version equalities as defined within mod info json
* https://lua-api.factorio.com/latest/auxiliary/mod-structure.html#Dependency
*/
const versionEqualities = ["<", "<=", "=", ">=", ">"] as const;
export const VersionEqualitySchema = Type.Union(versionEqualities.map(v => Type.Literal(v)));
export type VersionEquality = Static<typeof VersionEqualitySchema>;
export function isVersionEquality(input: string): input is VersionEquality {
return versionEqualities.includes(input as any);
}
/**
* 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"] as const;
export const ApiVersionSchema = Type.Union(ApiVersions.map(v => Type.Literal(v)));
export type ApiVersion = Static<typeof ApiVersionSchema>;
export function isApiVersion(input: string): input is ApiVersion {
return ApiVersions.includes(input as any);
}
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 apiVersion;
}
/**
* Matches valid mod versions, where all parts are specified.
*/
const fullVersionRegExp = /^\d+\.\d+\.\d+$/;
export type FullVersion = `${number}.${number}.${number}`;
export const FullVersionSchema = Type.Unsafe<FullVersion>(
Type.String({ pattern: fullVersionRegExp.source })
);
export function isFullVersion(input: string): input is FullVersion {
return fullVersionRegExp.test(input);
}
export function integerFullVersion(version: FullVersion) {
const [major, minor, sub] = version.split(".").map(n => Number.parseInt(n, 10));
return major * 0x100000000 + minor * 0x10000 + sub; // Can't use bitwise here because this is 48-bits.
// Can't use bitwise here because this is 48-bits.
return major * 0x100000000 + minor * 0x10000 + sub as IntegerVersion;
}
export function normaliseFullVersion(version: PartialVersion) {
const parts = version.split(".");
return (parts.length === 3 ? version : `${version}.0`) as FullVersion;
}
/**
* Integer representation of a factorio version
*
* Suitable for sorting and comparing version numbers which may have different
* number of leading zeros in them.
* @param version - string representation of version.
* @returns numeric representation.
* Matches valid factorio versions and mod dependencies specifications where 2 or 3 parts are specified.
*/
export function integerFactorioVersion(version: string) {
const [main, major, minor] = version.split(".").map(n => Number.parseInt(n, 10));
return main * 0x100000000 + major * 0x10000 + (minor || 0); // Can't use bitwise here because this is 48-bits.
const partialVersionRegExp = /^\d+\.\d+(?:\.\d+)?$/;
export type PartialVersion = FullVersion | `${number}.${number}`;
export const PartialVersionSchema = Type.Unsafe<PartialVersion>(
Type.String({ pattern: partialVersionRegExp.source })
);
export function isPartialVersion(input: string): input is PartialVersion {
return partialVersionRegExp.test(input);
}
export function integerPartialVersion(version: PartialVersion) {
const [major, minor, sub] = version.split(".").map(n => Number.parseInt(n, 10));
// Can't use bitwise here because this is 48-bits. sub is optional and defaults to 0.
return major * 0x100000000 + minor * 0x10000 + (sub || 0) as IntegerVersion;
}
/**
* Matches valid mod versions.
* Matches valid factorio target versions, this is the same as partial but also accepts "latest".
*/
export const modVersionRegExp = /^\d+\.\d+\.\d+$/;
export type TargetVersion = PartialVersion | "latest";
export const TargetVersionSchema = Type.Union([
PartialVersionSchema,
Type.Literal("latest"),
]);
export function isTargetVersion(input: string): input is TargetVersion {
return input === "latest" || partialVersionRegExp.test(input);
}
/**
* Represents a mod version paired with an equality which can be tested against
*/
export class ModVersionEquality {
public integerVersion: IntegerVersion;
constructor(
public equality: VersionEquality,
public version: PartialVersion,
) {
this.integerVersion = integerPartialVersion(version);
if (!isVersionEquality(this.equality)) {
throw new Error("Invalid equality");
}
if (!isPartialVersion(this.version)) {
throw new Error("Invalid version");
}
}
testIntegerVersion(other: IntegerVersion) {
switch (this.equality) {
case "<":
return other < this.integerVersion;
case "<=":
return other <= this.integerVersion;
case "=":
return other === this.integerVersion;
case ">=":
return other >= this.integerVersion;
case ">":
return other > this.integerVersion;
default:
throw new Error("unreachable");
}
}
testVersion(version: PartialVersion) {
return this.testIntegerVersion(integerPartialVersion(version));
}
toString() {
return `${this.equality} ${this.version}`;
}
static fromString(version: string) {
const parts = version.split(" ");
return parts.length === 1 ? this.fromParts("=", parts[0]) : this.fromParts(parts[0], parts[1]);
}
static fromParts(equality: string, version: string) {
if (!isVersionEquality(equality)) {
throw new Error(`Unknown version equality: ${equality}`);
}
if (!isPartialVersion(version)) {
throw new Error(`Malformed version string: ${version}`);
}
return new this(equality, version);
}
static jsonSchema = Type.String();
static fromJSON(json: Static<typeof this.jsonSchema>) {
return this.fromString(json);
}
toJSON() {
return this.toString();
}
}
/**
* Represents a range of mod versions which can be tested against
*/
export class ModVersionRange {
constructor(
public minVersion = new ModVersionEquality(">=", "0.0.0"),
public maxVersion = new ModVersionEquality("<=", "65535.65535.65535"),
) {
if (minVersion.equality === "<" || minVersion.equality === "<=") {
throw new Error("Minimum version can not use < or <=");
}
if (maxVersion.equality === ">" || maxVersion.equality === ">=") {
throw new Error("Maximum version can not use > or >=");
}
}
get valid() {
return this.minVersion.testIntegerVersion(this.maxVersion.integerVersion)
&& this.maxVersion.testIntegerVersion(this.minVersion.integerVersion);
}
invalidate() {
this.maxVersion = new ModVersionEquality(">", "0.0.0");
}
testIntegerVersion(other: IntegerVersion) {
return this.minVersion.testIntegerVersion(other) && this.maxVersion.testIntegerVersion(other);
}
testVersion(version: PartialVersion) {
return this.testIntegerVersion(integerPartialVersion(version));
}
combineVersion(other: ModVersionEquality) {
switch (other.equality) {
case "<":
if (other.integerVersion <= this.maxVersion.integerVersion) {
this.maxVersion = other;
}
break;
case "<=":
if (other.integerVersion < this.maxVersion.integerVersion) {
this.maxVersion = other;
}
break;
case "=":
if (this.testIntegerVersion(other.integerVersion)) {
this.minVersion = other;
this.maxVersion = other;
} else {
// Make invalid as it could not be satisfied
this.invalidate();
}
break;
case ">=":
if (other.integerVersion > this.minVersion.integerVersion) {
this.minVersion = other;
}
break;
case ">":
if (other.integerVersion >= this.minVersion.integerVersion) {
this.minVersion = other;
}
break;
default:
throw new Error("unreachable");
}
}
}
+29 -22
View File
@@ -1,11 +1,11 @@
import path from "path";
import fs from "fs-extra";
import { EventEmitter } from "events";
import { safeOutputFile } from "./file_ops";
import { SubscribableValue } from "./subscriptions";
import { logger } from "./logging";
import { EventEmitter } from "stream";
import { ControllerConfig } from "./config";
import path from "path";
type DatastoreKey = string | number;
type DatastoreValue = string | number | boolean | object;
@@ -30,7 +30,7 @@ export class MemoryDatastoreProvider<
V extends DatastoreValue,
> extends DatastoreProvider<K, V> {
constructor(
private value: Map<K, V> = new Map()
private value = new Map<K, V>()
) {
super();
}
@@ -39,34 +39,33 @@ export class MemoryDatastoreProvider<
this.value = new Map(data);
}
async load() {
async load(): Promise<Map<K, V>> {
return new Map(this.value);
}
}
// Store all data within a json object file and validate the result against a schema
export class JsonDatastoreProvider<
K extends DatastoreKey,
V extends DatastoreValue,
J, // Intermediate type returned from migrations
> extends DatastoreProvider<K, V> {
> extends DatastoreProvider<string, V> {
constructor(
private filePath: string,
private fromJson: (json: J) => V = v => v as any,
private migrations: (rawJson: Record<DatastoreKey, unknown>) => Record<DatastoreKey, J> = v => v as any,
private fromJson: (json: J) => V = v => v as any as V,
private migrations: (rawJson: Record<string, unknown>) => Record<string, J> = v => v as Record<string, J>,
private finalise: (obj: V) => V = v => v,
) {
super();
}
// Save the data to the json file
async save(data: Map<K, V>) {
async save(data: Map<string, V>) {
logger.verbose(`Saving ${this.filePath}`);
await safeOutputFile(this.filePath, JSON.stringify(Object.fromEntries(data), null, "\t"));
}
// Load the data from the json file
async load() {
async load(): Promise<Map<string, V>> {
// Read the raw json from the file
let rawJson;
try {
@@ -89,27 +88,26 @@ export class JsonDatastoreProvider<
// Store all data within a json array file where each element has a unique id
export class JsonIdDatastoreProvider<
K extends DatastoreKey,
V extends DatastoreValue & { id: K },
V extends DatastoreValue & { id: DatastoreKey },
J, // Intermediate type returned from migrations
> extends DatastoreProvider<K, V> {
> extends DatastoreProvider<V["id"], V> {
constructor(
private filePath: string,
private fromJson: (json: J) => V = v => v as any,
private migrations: (rawJson: Array<unknown>) => Array<J> = v => v as any,
private fromJson: (json: J) => V = v => v as any as V,
private migrations: (rawJson: Array<unknown>) => Array<J> = v => v as Array<J>,
private finalise: (obj: V) => V = v => v,
) {
super();
}
// Save the data to the json file
async save(data: Map<K, V>) {
async save(data: Map<V["id"], V>) {
logger.verbose(`Saving ${this.filePath}`);
await safeOutputFile(this.filePath, JSON.stringify([...data.values()], null, "\t"));
}
// Load the data from the json file
async load() {
async load(): Promise<Map<V["id"], V>> {
// Read the raw json from the file
let rawJson;
try {
@@ -137,7 +135,10 @@ export class JsonIdDatastoreProvider<
export abstract class Datastore<
K extends DatastoreKey,
V extends DatastoreValue,
> extends EventEmitter {
U = V,
> extends EventEmitter<{
"update": [ updates: U[] ],
}> {
private dirty = false;
constructor(
@@ -167,7 +168,7 @@ export abstract class Datastore<
}
// Set the dirty flag and call update handlers
protected touch(updates: any[]) {
protected touch(updates: U[]) {
if (updates.length) {
this.dirty = true;
this.emit("update", updates);
@@ -208,13 +209,17 @@ export abstract class Datastore<
valuesMutable() {
return this.data.values();
}
keys() {
return this.data.keys();
}
}
// General key value mapping which can be saved and loaded with the file system
export class KeyValueDatastore<
K extends DatastoreKey,
V extends DatastoreValue,
> extends Datastore<K, V> {
> extends Datastore<K, V, [key: K, value: V, isDeleted?: boolean]> {
// Set the value in the datastore, be careful of race conditions if you await any functions before calling set
set(key: K, value: V) {
this.data.set(key, value);
@@ -232,8 +237,10 @@ export class KeyValueDatastore<
// Delete a value in the datastore
delete(key: K) {
const value = this.data.get(key);
this.data.delete(key);
this.touch([[key, value!, true]]);
if (value !== undefined) {
this.data.delete(key);
this.touch([[key, value, true]]);
}
}
// Delete many values at once from an array of keys
+69
View File
@@ -0,0 +1,69 @@
import { Static, Type } from "@sinclair/typebox";
import { FullVersionSchema, integerFullVersion, isFullVersion } from "../data/version";
const ARCHIVE_URL = "https://factorio.com/download/archive";
/**
* Represents the parsed result of a factorio version
*/
export const ExternalFactorioVersionSchema = Type.Object({
stable: Type.Boolean(),
version: FullVersionSchema,
headlessUrl: Type.String(),
});
export type ExternalFactorioVersion = Static<typeof ExternalFactorioVersionSchema>
/**
* Fetch all factorio versions that support headless
*
* @returns A list of all factorio versions and their download URL
* @throws Network errors and non-ok status responses
*/
export async function fetchFactorioVersions(): Promise<ExternalFactorioVersion[]> {
let res;
try {
res = await fetch(ARCHIVE_URL);
} catch (err) {
throw new Error(`Failed to fetch Factorio versions from ${ARCHIVE_URL}: ${err}`);
}
if (!res.ok) {
throw new Error(`Failed to fetch Factorio versions from ${ARCHIVE_URL}: HTTP ${res.status} ${res.statusText}`);
}
const html = await res.text();
return parseFactorioVersions(html);
}
/**
* Parse all versions from the factorio archive webpage
*
* @param html The HTML content of the archive page
* @returns A list of all factorio versions and their download URL
*/
function parseFactorioVersions(html: string): ExternalFactorioVersion[] {
const versions = new Map<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;
let match: RegExpExecArray | null;
const minimumMultiplayerVersion = integerFullVersion("0.12.35");
while ((match = anchorRegex.exec(html)) !== null) {
const kind = match.groups?.kind;
const version = match.groups?.version;
if (kind && version && isFullVersion(version) && integerFullVersion(version) >= minimumMultiplayerVersion) {
versions.set(version, {
version: version,
stable: kind === "stable",
headlessUrl: `www.factorio.com/get-download/${version}/headless/linux64`,
});
}
}
return [...versions.values()];
}
+5
View File
@@ -0,0 +1,5 @@
/**
* Library for interfacing with external apis
* @module lib/external
*/
export * from "./FactorioVersions";
+1 -1
View File
@@ -414,7 +414,7 @@ export function readMapExchangeString(exchangeString: string) {
let buf = Buffer.from(exchangeString.slice(3, -3), "base64");
try {
// eslint-disable-next-line node/no-sync
buf = zlib.inflateSync(buf);
buf = Buffer.from(zlib.inflateSync(buf));
} catch (err: any) {
if (err.code.startsWith("Z_")) {
throw new Error("Malformed map exchange string: zlib inflate failed");
+3 -3
View File
@@ -1,8 +1,8 @@
/* eslint-disable node/global-require */
"use strict";
// eslint-disable-next-line node/no-process-env
if (process.env.APP_ENV === "browser") {
// eslint-disable-next-line node/no-extraneous-require
module.exports = require("fast-deep-equal/es6");
} else {
module.exports = require("util").isDeepStrictEqual;
+1 -1
View File
@@ -1,3 +1,3 @@
"use strict";
// eslint-disable-next-line node/no-process-env
module.exports = process.env.APP_ENV === "browser" ? window.WebSocket : require("ws");
+46 -23
View File
@@ -1,8 +1,8 @@
// Connection adapters for Links
import { strict as assert } from "assert";
import events from "events";
import WebSocket from "./WebSocket";
import WebSocket from "./WebSocket";
import * as libData from "../data";
import * as libErrors from "../errors";
import { logger } from "../logging";
@@ -36,12 +36,21 @@ export enum ConnectionClosed {
RecoveryMode = 4001,
};
type BaseConnectorEvents = {
"message": [ message: libData.Message ],
"error": [ err: any ],
"invalidate": [],
"reset": [],
};
/**
* Base connector for links
*
* @extends events.EventEmitter
*/
export abstract class BaseConnector extends events.EventEmitter {
export abstract class BaseConnector<
E extends Record<string, any[]> | [never] = [never], // Should be "extends EventMap = DefaultEventMap"
> extends events.EventEmitter<E | BaseConnectorEvents> {
protected _seq = 1;
constructor(
@@ -53,10 +62,12 @@ export abstract class BaseConnector extends events.EventEmitter {
_reset() {
this._seq = 1;
this.emit("reset");
}
_invalidate() {
this._seq = 1;
this.emit("invalidate");
}
protected abstract send(message: libData.MessageRoutable): void;
@@ -131,6 +142,15 @@ export abstract class BaseConnector extends events.EventEmitter {
type ConnectorState = "closed" | "connecting" | "connected" | "resuming";
type WebSocketBaseConnectorEvents = {
"disconnectReady": [],
"disconnectPrepare": [],
"connect": [ data: any ],
"resume": [],
"close": [],
"drop": [],
};
export type WebSocketClusterio = WebSocket.WebSocket & {
clusterio_ignore_dump?: boolean;
};
@@ -140,7 +160,9 @@ export type WebSocketClusterio = WebSocket.WebSocket & {
*
* @extends module:lib.BaseConnector
*/
export abstract class WebSocketBaseConnector extends BaseConnector {
export abstract class WebSocketBaseConnector<
E extends Record<string, any[]> | [never] = [never], // Should be "extends EventMap = DefaultEventMap"
> extends BaseConnector<E | WebSocketBaseConnectorEvents> {
// One of closed, connecting (client only), connected and resuming.
_state: ConnectorState = "closed";
_closing = false;
@@ -166,7 +188,7 @@ export abstract class WebSocketBaseConnector extends BaseConnector {
_invalidate() {
this._state = "connecting";
assert(this._closing === false);
assert(this._socket);
// We do not care about the socket state
this._lastHeartbeatMs = null;
assert(this._heartbeatId === null);
this._heartbeatInterval = null;
@@ -175,6 +197,11 @@ export abstract class WebSocketBaseConnector extends BaseConnector {
super._invalidate();
}
_close() {
this._reset();
this.emit("close");
}
_check(...expectedStates: ConnectorState[]) {
if (!expectedStates.includes(this._state)) {
throw new Error(
@@ -244,7 +271,7 @@ export abstract class WebSocketBaseConnector extends BaseConnector {
this._check("connected");
if (Date.now() - this._lastHeartbeatMs! > 2000 * this._heartbeatInterval!) {
logger.verbose("Connector | closing after heartbeat timed out");
// eslint-disable-next-line node/no-process-env
if (process.env.APP_ENV === "browser") {
this._socket!.close(ConnectionClosed.Timeout, "Heartbeat timeout");
} else {
@@ -385,12 +412,16 @@ export abstract class WebSocketBaseConnector extends BaseConnector {
}
}
type WebSocketClientConnectorEvents = {
"hello": [ data: any ],
};
/**
* Connector for controller clients
*
* @extends module:lib.WebSocketBaseConnector
*/
export abstract class WebSocketClientConnector extends WebSocketBaseConnector {
export abstract class WebSocketClientConnector extends WebSocketBaseConnector<WebSocketClientConnectorEvents> {
_reconnectId?: ReturnType<typeof setTimeout>;
_sessionToken: string | null = null;
_sessionTimeout: number | null = null;
@@ -418,8 +449,8 @@ export abstract class WebSocketClientConnector extends WebSocketBaseConnector {
this._sessionToken = null;
this._sessionTimeout = null;
this._startedResumingMs = null;
super._reset();
this.src = undefined as any;
super._reset();
}
_invalidate() {
@@ -427,8 +458,8 @@ export abstract class WebSocketClientConnector extends WebSocketBaseConnector {
this._sessionToken = null;
this._sessionTimeout = null;
this._startedResumingMs = null;
super._invalidate();
this.src = undefined as any;
super._invalidate();
}
/**
@@ -463,8 +494,7 @@ export abstract class WebSocketClientConnector extends WebSocketBaseConnector {
await events.once(this, "close");
} else {
this._reset();
this.emit("close");
this._close();
}
}
@@ -489,8 +519,7 @@ export abstract class WebSocketClientConnector extends WebSocketBaseConnector {
this._socket!.close(ConnectionClosed.NormalClosure, "Connector closing");
} else {
this._reset();
this.emit("close");
this._close();
}
}
@@ -515,7 +544,7 @@ export abstract class WebSocketClientConnector extends WebSocketBaseConnector {
// Open WebSocket to controller
logger.verbose(`Connector | connecting to ${url}`);
// eslint-disable-next-line node/no-process-env
if (process.env.APP_ENV === "browser") {
this._socket = new WebSocket(url) as WebSocketClusterio;
@@ -546,13 +575,10 @@ export abstract class WebSocketClientConnector extends WebSocketBaseConnector {
&& this._startedResumingMs! + this._sessionTimeout! * 1000 < Date.now() + delayMs
) {
logger.error("Connector | Session timed out trying to resume");
this._reset();
if (this._closing) {
this._reset();
this.emit("close");
this._close();
} else {
this._state = "connecting";
this.emit("invalidate");
this._invalidate();
}
}
logger.verbose(
@@ -604,8 +630,7 @@ export abstract class WebSocketClientConnector extends WebSocketBaseConnector {
} else {
// eslint-disable-next-line no-lonely-if
if (this._closing) {
this._reset();
this.emit("close");
this._close();
} else {
this.reconnect();
@@ -711,11 +736,9 @@ export abstract class WebSocketClientConnector extends WebSocketBaseConnector {
} else if (type === "invalidate") {
logger.warn("Connector | session invalidated by controller");
if (this._closing) {
this._reset();
this.emit("close");
this._close();
} else {
this._invalidate();
this.emit("invalidate");
this.register();
}
}
+3 -3
View File
@@ -113,7 +113,7 @@ export class Link {
// Process messages received by the connector
connector.on("message", payload => {
try {
this._processMessage(payload);
this._processMessage(payload as MessageRoutable);
} catch (err) {
if (err instanceof libErrors.InvalidMessage) {
logger.error(err.message);
@@ -128,10 +128,10 @@ export class Link {
}
});
// The events below exist only on websocket connectors, we can't early return because of test mocking
const webSocketConnector = this.connector as WebSocketBaseConnector;
connector.on("disconnectPrepare", () => {
this.prepareDisconnect().finally(() => {
// Only WebSocket connectors issue disconnection events.
const webSocketConnector = this.connector as WebSocketBaseConnector;
if (webSocketConnector.hasSession) {
webSocketConnector.sendDisconnectReady();
}
+12 -6
View File
@@ -4,6 +4,7 @@ import * as host from "../data/messages_host";
import * as instance from "../data/messages_instance";
import * as mod from "../data/messages_mod";
import * as user from "../data/messages_user";
import * as role from "../data/messages_role";
import * as plugin from "../data/messages_plugin";
import * as subscriptions from "../subscriptions";
import type { RequestClass, EventClass } from "./link";
@@ -34,6 +35,7 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
controller.SystemInfoUpdateEvent,
controller.DebugDumpWsRequest,
controller.DebugWsMessageEvent,
controller.FactorioVersionsRequest,
subscriptions.SubscriptionRequest,
@@ -62,6 +64,7 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
instance.InstanceAssignRequest,
instance.InstanceMetricsRequest,
instance.InstanceStartRequest,
instance.InstanceRestartRequest,
instance.InstanceSaveDetailsListRequest,
instance.InstanceSaveDetailsUpdatesEvent,
instance.InstanceCreateSaveRequest,
@@ -106,13 +109,16 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
mod.ModPackUpdatesEvent,
mod.ModUpdatesEvent,
mod.ModPortalDownloadRequest,
mod.ModDependencyResolveRequest,
role.PermissionListRequest,
role.RoleListRequest,
role.RoleUpdatesEvent,
role.RoleCreateRequest,
role.RoleUpdateRequest,
role.RoleGrantDefaultPermissionsRequest,
role.RoleDeleteRequest,
user.PermissionListRequest,
user.RoleListRequest,
user.RoleCreateRequest,
user.RoleUpdateRequest,
user.RoleGrantDefaultPermissionsRequest,
user.RoleDeleteRequest,
user.UserGetRequest,
user.UserListRequest,
user.UserCreateRequest,
+2 -2
View File
@@ -519,7 +519,7 @@ export async function queryLog(logDirectory: string, filter: QueryLogFilter, ind
}
export function handleUnhandledErrors() {
/* eslint-disable node/no-process-exit */
process.on("uncaughtException", err => {
logger.fatal(`Uncaught exception:\n${err.stack}`);
process.exit(1);
@@ -528,7 +528,7 @@ export function handleUnhandledErrors() {
logger.fatal(`Unhandled rejection:\n${reason.stack ? reason.stack : reason}`);
process.exit(1);
});
/* eslint-enable node/no-process-exit */
}
// for testing only
+22 -4
View File
@@ -7,6 +7,7 @@
* @module lib/users
*/
import { Permission, Role } from "./data";
import { SubscribableDatastore } from "./datastore";
export const permissions = new Map<string, Permission>();
@@ -66,7 +67,7 @@ export function definePermission({
* @param roles - role storage to modify.
* @returns the default admin role
*/
export function ensureDefaultAdminRole(roles: Map<number, Role>) {
export function ensureDefaultAdminRole(roles: SubscribableDatastore<Role>) {
let admin = roles.get(0);
if (!admin) {
admin = Role.fromJSON({
@@ -75,9 +76,9 @@ export function ensureDefaultAdminRole(roles: Map<number, Role>) {
description: "Cluster wide administrator.",
permissions: [],
});
roles.set(0, admin);
}
admin.permissions.add("core.admin");
roles.set(admin);
return admin;
}
@@ -90,7 +91,7 @@ export function ensureDefaultAdminRole(roles: Map<number, Role>) {
*
* @param roles - role storage to modify.
*/
export function ensureDefaultPlayerRole(roles: Map<number, Role>) {
export function ensureDefaultPlayerRole(roles: SubscribableDatastore<Role>) {
let player = roles.get(1);
if (!player) {
player = Role.fromJSON({
@@ -99,9 +100,9 @@ export function ensureDefaultPlayerRole(roles: Map<number, Role>) {
description: "Default player role.",
permissions: [],
});
roles.set(1, player);
}
player.grantDefaultPermissions();
roles.set(player);
}
@@ -306,6 +307,11 @@ definePermission({
title: "Start instance",
description: "Start instances.",
});
definePermission({
name: "core.instance.restart",
title: "Restart instance",
description: "Restart instances.",
});
definePermission({
name: "core.instance.load_scenario",
title: "Load scenario",
@@ -431,6 +437,12 @@ definePermission({
description: "Get the full list of roles and their permissions in the cluster.",
grantByDefault: true,
});
definePermission({
name: "core.role.subscribe",
title: "Subscribe to role updates",
description: "Subscribe to be notified on updates on the details of roles.",
grantByDefault: true,
});
definePermission({
name: "core.role.create",
title: "Create role",
@@ -556,3 +568,9 @@ definePermission({
title: "Dump WebSocket",
description: "Dump all WebSocket communicatation from the controller.",
});
definePermission({
name: "core.external.get_factorio_versions",
title: "Get factorio versions",
description: "Get list of all factorio versions from Wube",
});
+9 -3
View File
@@ -1,9 +1,10 @@
/* eslint-disable @typescript-eslint/no-var-requires */
/**
* Helpers for loading plugins in Node.js
* @module lib/plugin_loader
*/
import path from "path";
import fs from "fs/promises";
import * as libErrors from "./errors";
import * as libPlugin from "./plugin";
import { logger } from "./logging";
@@ -29,8 +30,13 @@ export async function loadPluginInfos(pluginList: Map<string, string>) {
// Check if plugin path exists, otherwise remove it
try {
require.resolve(pluginPath);
} catch (err) {
logger.error(`Plugin path ${pluginPath} does not exist, not loading ${pluginName}`);
} catch {
let errMsg = `Plugin path ${pluginPath} does not exist`;
try {
await fs.access(pluginPath, fs.constants.F_OK);
errMsg = `Plugin path ${pluginPath} missing index or main file`;
} catch {}
logger.error(`${errMsg}, not loading ${pluginName}`);
pluginList.delete(pluginName);
continue;
}
+3
View File
@@ -275,6 +275,9 @@ function keyToLabels(key: string) {
if (key !== "") {
for (let pair of key.split(",")) {
let [name, value] = pair.split("=", 2);
if (!name || value === undefined) {
continue;
}
labels.set(name, value
.slice(1, -1)
.replace(/\\"/g, "\"")
+1 -1
View File
@@ -6,7 +6,7 @@ import { RequestError } from "./errors";
import { PluginNodeEnvInfo } from "./plugin";
const execAsync = util.promisify(exec);
async function isDev() {
function isDev() {
// dev: <devRoot>/packages/lib/dist/src/rce_ops.js
// prod: <prodRoot>/node_modules/@clusterio/lib/dist/src/rce_ops.js
return __dirname.split(path.sep).at(-5) === "packages"; // opposed to "@clusterio"
+8 -2
View File
@@ -8,6 +8,7 @@ import * as libConfig from "./config";
import * as libFileOps from "./file_ops";
import { logger } from "./logging";
import * as libHelpers from "./helpers";
import { LockFile } from "./LockFile";
function print(...content: any[]) {
@@ -58,7 +59,7 @@ export async function handlePluginCommand(
let pluginInfo: { name: string };
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
pluginInfo = require(pluginPath).plugin;
} catch (err: any) {
logger.error(`Unable to import plugin info from ${args.path}:\n${err.stack}`);
@@ -130,11 +131,12 @@ export function configCommand(yargs: any) {
*
* @param args - yargs args object.
* @param instance - Config instance.
* @param configPath - Path to configuration file.
* @param lockFile - Lockfile required if attempting to write
*/
export async function handleConfigCommand(
args: Record<string, unknown>,
instance: libConfig.Config<any>,
lockFile: LockFile,
) {
let command = (args._ as string[])[1];
@@ -155,6 +157,8 @@ export async function handleConfigCommand(
}
} else if (command === "set") {
await lockFile.acquire();
if (args.stdin) {
args.value = (await libHelpers.readStream(process.stdin)).toString().replace(/\r?\n$/, "");
@@ -172,5 +176,7 @@ export async function handleConfigCommand(
throw err;
}
}
await lockFile.release();
}
}
+70 -14
View File
@@ -39,30 +39,45 @@ export class SubscriptionRequest {
public eventName: string,
public subscribe: boolean,
public lastRequestTimeMs: number = 0,
public filters: string | string[] | undefined = undefined,
) {
if (!Link._eventsByName.has(eventName)) {
throw new Error(`Unregistered Event class ${eventName}`);
}
}
static jsonSchema = Type.Tuple([
Type.String(),
Type.Boolean(),
Type.Number(),
static jsonSchema = Type.Union([
Type.Tuple([
Type.String(),
Type.Boolean(),
Type.Number(),
]),
Type.Tuple([
Type.String(),
Type.Boolean(),
Type.Number(),
Type.Union([Type.String(), Type.Array(Type.String())]),
]),
]);
toJSON() {
if (this.filters !== undefined) {
return [this.eventName, this.subscribe, this.lastRequestTimeMs, this.filters];
}
return [this.eventName, this.subscribe, this.lastRequestTimeMs];
}
static fromJSON(json: Static<typeof SubscriptionRequest.jsonSchema>): SubscriptionRequest {
return new this(...json);
const [eventName, subscribe, lastRequestTimeMs, filters] = json;
return new this(eventName, subscribe, lastRequestTimeMs, filters);
}
}
type Subscriber = {
link: Link,
dst: Address,
/** Optional filters this subscriber is interested in. Undefined means all events */
filters?: string[],
}
type EventData = {
@@ -77,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
@@ -102,7 +124,7 @@ export class SubscriptionController {
* Broadcast an event to all subscribers of that event
* @param event - Event to broadcast.
*/
broadcast<T>(event: Event<T>) {
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}`);
@@ -112,10 +134,19 @@ export class SubscriptionController {
throw new Error(`Event ${entry.name} is not a registered as subscribable`);
}
const toRemove: Subscriber[] = [];
const filtersToMatch = SubscriptionController.normalizeFilters(filter);
for (const subscriber of eventData.subscriptions) {
if ((subscriber.link.connector as WebSocketBaseConnector).closing) {
toRemove.push(subscriber);
} else {
continue;
}
if (
filtersToMatch === undefined
|| subscriber.filters === undefined
|| subscriber.filters.some(f => filtersToMatch.includes(f))
) {
subscriber.link.sendTo(subscriber.dst, event);
}
}
@@ -164,13 +195,36 @@ export class SubscriptionController {
throw new Error(`Event ${event.eventName} is not a registered as subscribable`);
}
if (event.subscribe === false) {
const index = eventData.subscriptions.findLastIndex(subscriber => subscriber.dst.addressedTo(src));
if (index >= 0) {
eventData.subscriptions.splice(index, 1);
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);
}
}
} else {
if (!eventData.subscriptions.some(subscriber => subscriber.dst.addressedTo(src))) {
eventData.subscriptions.push({ link: link, dst: src });
// 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);
}
}
}
if (eventData.subscriptionUpdate) {
const eventReplay = await eventData.subscriptionUpdate(event, src, dst);
@@ -222,10 +276,12 @@ export class EventSubscriber<
public control: Link,
) {
control.handle(Event, this._handleEvent.bind(this));
control.connector.on("connect", () => {
// The events below exist only on websocket connectors, we can't early return because of test mocking
const webSocketConnector = control.connector as WebSocketBaseConnector;
webSocketConnector.on("connect", () => {
this.handleConnectionEvent("connect");
});
control.connector.on("close", () => {
webSocketConnector.on("close", () => {
this.handleConnectionEvent("close");
});
}
+3
View File
@@ -6,6 +6,8 @@
"./src/api.ts",
"./src/config/*.ts",
"./src/data/*.ts",
"./src/external/*.ts",
"./src/datastore.ts",
"./src/errors.ts",
"./src/factorio/exchange_string.ts",
"./src/helpers.ts",
@@ -24,6 +26,7 @@
"./src/ExponentialBackoff.ts",
"./src/RateLimiter.ts",
"./src/TypedEventEmitter.ts",
"./src/ValueCache.ts",
],
"compilerOptions": {
"rootDir": ".",
+2 -2
View File
@@ -3,7 +3,7 @@
"description": "Clusterio web interface implementation",
"author": "Hornwitser",
"license": "MIT",
"version": "2.0.0-alpha.21",
"version": "2.0.0-alpha.22b",
"browser": "src/index.ts",
"main": "dist/browser/src/index.js",
"scripts": {
@@ -21,7 +21,7 @@
"@ant-design/icons": "^5.6.1",
"@clusterio/lib": "workspace:*",
"@sinclair/typebox": "^0.30.4",
"@swc/core": "^1.11.1",
"@swc/core": "^1.13.3",
"antd": "^5.24.2",
"assert": "^2.0.0",
"browserify-zlib": "^0.2.0",
+4
View File
@@ -9,6 +9,8 @@ import App from "./components/App";
import BaseWebPlugin, { InputComponent } from "./BaseWebPlugin";
import InputRole from "./components/InputRole";
import InputModPack from "./components/InputModPack";
import InputTargetVersion from "./components/InputTargetVersion";
import InputPartialVersion from "./components/InputPartialVersion";
import { Control, ControlConnector } from "./util/websocket";
const { ConsoleTransport, WebConsoleFormat, logger } = lib;
@@ -109,6 +111,8 @@ async function loadPlugins(pluginInfos: lib.PluginWebpackEnvInfo[], control: Con
function inputComponentsFromPlugins(plugins: Map<string, BaseWebPlugin>) {
const inputComponents: Record<string, InputComponent> = {
"partial_version": InputPartialVersion,
"target_version": InputTargetVersion,
"mod_pack": InputModPack,
"role": InputRole,
};
@@ -226,7 +226,7 @@ export default function BaseConfigTree(props: BaseConfigTreeProps) {
</>;
}
// eslint-disable-next-line complexity
function computeTreeData(
control: Control,
form: FormInstance<any>,
@@ -1,5 +1,5 @@
import React, {useContext, useState} from "react";
import { Button, Descriptions, Flex, Popconfirm, Space, Tooltip, Typography } from "antd";
import { Descriptions, Flex, Popconfirm, Space, Tooltip, Typography } from "antd";
import { ExclamationCircleOutlined } from "@ant-design/icons";
import * as lib from "@clusterio/lib";
@@ -19,60 +19,78 @@ import PageLayout from "./PageLayout";
import { notifyErrorHandler } from "../util/notify";
import webUiPackage from "../../package.json";
import { hasNpmButtonPermission, NpmButton } from "./NpmButton";
import VariableDropdownButton, { VariableDropdownButtonProps } from "./VariableDropdownButton";
const { Title } = Typography;
type ControllerControlButtonProps = { canRestart?: boolean, restartRequired?: boolean };
function ControllerControlButton({ canRestart, restartRequired }: ControllerControlButtonProps) {
const account = useAccount();
const control = useContext(ControlContext);
const actions: VariableDropdownButtonProps["actions"] = [];
if (account.hasPermission("core.controller.restart") && canRestart) {
actions.push({
key: "restart",
label: (
!restartRequired ? "Restart" : <Tooltip title={"Restart Required"}><Space>
<ExclamationCircleOutlined style={{ color: "yellow" }}/>
Restart
</Space></Tooltip>
),
onClick: () => {
control.send(
new lib.ControllerRestartRequest()
).catch(notifyErrorHandler("Error restarting controller"));
},
});
}
if (account.hasPermission("core.controller.stop")) {
actions.push({
key: "stop",
danger: true,
label: (
<Popconfirm
title={<>
Stopping the controller will leave the cluster inoperable until someone with
access to the system it runs on manually starts it again.<br />
Are you sure you want to stop the controller?
</>}
placement="bottomRight"
okText="Stop"
okButtonProps={{ danger: true }}
onConfirm={() => {
control.send(
new lib.ControllerStopRequest()
).catch(notifyErrorHandler("Error stopping controller"));
}}
>
Stop
</Popconfirm>
),
});
}
return <VariableDropdownButton actions={actions} />;
}
export default function ControllerPage() {
let account = useAccount();
const control = useContext(ControlContext);
const account = useAccount();
const [systems] = useSystems();
const system = systems.get("controller");
const [maxLevel, setMaxLevel] = useState<keyof typeof lib.levels>("info");
const canShowNpm = hasNpmButtonPermission(true);
return <PageLayout nav={[{ name: "Controller" }]}>
<PageHeader
title="Controller"
extra={<Space>
<ControllerControlButton canRestart={system?.canRestart} restartRequired={system?.restartRequired}/>
{
account.hasPermission("core.controller.stop")
&& <Popconfirm
title={<>
Stopping the controller will leave the cluster inoperable until someone with
access to the system it runs on manually starts it again.<br />
Are you sure you want to stop the controller?
</>}
placement="bottomRight"
okText="Stop"
okButtonProps={{ danger: true }}
onConfirm={() => {
control.send(
new lib.ControllerStopRequest()
).catch(notifyErrorHandler("Error stopping controller"));
}}
>
<Button danger>Stop</Button>
</Popconfirm>
}
{
account.hasPermission("core.controller.restart")
&& <Tooltip title={system?.restartRequired ? "Restart Required" : null}>
<Button
disabled={system?.canRestart === false}
onClick={() => {
control.send(
new lib.ControllerRestartRequest()
).catch(notifyErrorHandler("Error restarting controller"));
}}
>
Restart
{!system?.restartRequired ? undefined
: <ExclamationCircleOutlined style={{ color: "yellow" }}/>}
</Button>
</Tooltip>
}
{
hasNpmButtonPermission(true)
canShowNpm
&& <NpmButton target="controller" canRestart={system?.canRestart}/>
}
</Space>}
+62 -44
View File
@@ -22,9 +22,65 @@ import {
import { formatTimestamp } from "../util/time_format";
import { useSystems } from "../model/system";
import { hasNpmButtonPermission, NpmButton } from "./NpmButton";
import VariableDropdownButton, { VariableDropdownButtonProps } from "./VariableDropdownButton";
const { Title } = Typography;
type HostControlButtonProps = { hostId: number, canRestart?: boolean, restartRequired?: boolean };
function HostControlButton({ hostId, canRestart, restartRequired }: HostControlButtonProps) {
const account = useAccount();
const control = useContext(ControlContext);
const actions: VariableDropdownButtonProps["actions"] = [];
if (account.hasPermission("core.host.restart") && canRestart) {
actions.push({
key: "restart",
label: (
!restartRequired ? "Restart" : <Tooltip title={"Restart Required"}><Space>
<ExclamationCircleOutlined style={{ color: "yellow" }}/>
Restart
</Space></Tooltip>
),
onClick: () => {
control.sendTo(
{ hostId },
new lib.HostRestartRequest()
).catch(notifyErrorHandler("Error restarting host"));
},
});
}
if (account.hasPermission("core.host.stop")) {
actions.push({
key: "stop",
danger: true,
label: (
<Popconfirm
title={<>
Stopping this host will leave it inaccessible until someone with
access to the system it runs on manually starts it again.<br />
Are you sure you want to stop the host?
</>}
placement="bottomRight"
okText="Stop"
okButtonProps={{ danger: true }}
onConfirm={() => {
control.sendTo(
{ hostId },
new lib.HostStopRequest()
).catch(notifyErrorHandler("Error stopping host"));
}}
>
Stop
</Popconfirm>
),
});
}
return <VariableDropdownButton actions={actions} />;
}
export default function HostViewPage() {
let params = useParams();
let hostId = Number(params.id);
@@ -35,6 +91,7 @@ export default function HostViewPage() {
const system = systems.get(hostId);
const [host, synced] = useHost(hostId);
const [maxLevel, setMaxLevel] = useState<keyof typeof lib.levels>("info");
const canShowNpm = hasNpmButtonPermission(false);
const hostInstances = new Map([...instances].filter(([_id, instance]) => instance.assignedHost === hostId));
let nav = [{ name: "Hosts", path: "/hosts" }, { name: host?.name ?? String(hostId) }];
@@ -49,7 +106,8 @@ export default function HostViewPage() {
</PageLayout>;
}
let hostButtons = <Space>
let hostButtons = <Space wrap>
<HostControlButton hostId={hostId} canRestart={system?.canRestart} restartRequired={system?.restartRequired}/>
{
account.hasPermission("core.host.revoke_token")
&& <Popconfirm
@@ -68,49 +126,9 @@ export default function HostViewPage() {
</Button>
</Popconfirm>
}
{
account.hasPermission("core.host.stop")
&& <Popconfirm
title={<>
Stopping this host will leave it inaccessible until someone with
access to the system it runs on manually starts it again.<br />
Are you sure you want to stop the host?
</>}
placement="bottomRight"
okText="Stop"
okButtonProps={{ danger: true }}
onConfirm={() => {
control.sendTo(
{ hostId },
new lib.HostStopRequest()
).catch(notifyErrorHandler("Error stopping host"));
}}
>
<Button danger>Stop</Button>
</Popconfirm>
}
{
account.hasPermission("core.host.restart")
&& <Tooltip title={system?.restartRequired ? "Restart Required" : null}>
<Button
disabled={system?.canRestart === false}
onClick={() => {
control.sendTo(
{ hostId },
new lib.HostRestartRequest()
).catch(notifyErrorHandler("Error restarting host"));
}}
>
Restart
{!system?.restartRequired ? undefined
: <ExclamationCircleOutlined style={{ color: "yellow" }} />}
</Button>
</Tooltip>
}
{
hasNpmButtonPermission(false)
&& <NpmButton target={{ hostId }} canRestart={system?.canRestart} disabled={!host["connected"]}/>
}
{canShowNpm && (
<NpmButton target={{ hostId }} canRestart={system?.canRestart} disabled={!host["connected"]}/>
)}
</Space>;
return <PageLayout nav={nav}>
Loaded 100 of 199 files, more files were not shown because too many files have changed in this diff. Show more