Compare commits

..
Author SHA1 Message Date
Cooldude2606 95cd830c01 2.0.0-alpha.27 2026-07-19 00:10:11 +01:00
Cooldude2606 ddc03eaf30 Bump engines requirement 2026-07-18 23:50:33 +01:00
Cooldude2606 b5d8cde93f Fix test for player auth controller plugin 2026-07-18 23:48:32 +01:00
Cooldude2606 4e8a833d62 Fix write permission in publish ci 2026-07-18 23:32:48 +01:00
Cooldude2606 3add08b100 Update naming within exchange string lib 2026-07-18 23:32:15 +01:00
Daniel f2b28968a0 Merge pull request #943 from Cooldude2606/ci/oidc
OIDC Publish, Node version bump, and 2.1 tests working
2026-07-19 00:20:31 +02:00
Daniel 599cf3ed22 Merge pull request #915 from Cooldude2606/feature/player-auth-display-version
Add version and connect button to player auth
2026-07-18 23:29:14 +02:00
Cooldude2606 ff28357d7a Remove hard coded versions 2026-07-18 22:12:15 +01:00
Cooldude2606 8b4c8ad337 New exchange string format for enemy expansion settings 2026-07-18 22:12:15 +01:00
Cooldude2606 07b030d045 Add 2.1 version to clusterio_lib 2026-07-18 22:12:14 +01:00
Cooldude2606 7f799c8490 Bump node version 2026-07-18 22:12:14 +01:00
Cooldude2606 91a5108b85 Add OIDC permissions to publish workflow 2026-07-18 22:12:13 +01:00
Cooldude2606 90c8188952 Address feedback
* Fix typo in toJSON
* Handle the case of hostId being 0
* Hide address when host address is empty string
2026-07-18 22:07:54 +01:00
Cooldude2606 198ea76674 Add support for recommended mod dependencies (#946) 2026-07-18 21:57:50 +01:00
Daniel 5758cd94b6 Merge pull request #945 from Cooldude2606/fix/duplicate-instances
Setting filepath on config makes it dirty
2026-07-18 21:14:56 +02:00
Cooldude2606 eab1a42e07 Make showing the connect address a config option 2026-07-11 23:04:42 +01:00
Cooldude2606 fdfd77df9a Add modal containing details of how to connect to server 2026-07-11 22:46:53 +01:00
Cooldude2606 58f2ad9810 Merge pull request #930 from bbassie/feature/table-url-state
Persist table sort/filter state in the URL
2026-07-10 23:13:53 +01:00
Cooldude2606 3cc99a1ea6 Fix commit filter logic for empty search strings 2026-07-10 23:10:54 +01:00
Cooldude2606 15781bb038 Setting filepath on config makes it dirty 2026-07-10 22:00:42 +01:00
Cooldude2606 9ec12ac7d7 Merge pull request #944 from Cooldude2606/fix/quick-bar-slots
Add string encoding/decoding and update quick bar support
2026-07-10 20:59:37 +01:00
Cooldude2606 2559c0478c Use item name only for better size usage 2026-07-10 20:59:06 +01:00
Cooldude2606 07fcdb0d4e Merge pull request #942 from Cooldude2606/refactor/sub-filters-again
Make SubscriberFilter extend Set and expose errors in EventSubscriber
2026-07-08 08:30:37 +01:00
bbassieandClaude Opus 4.8 40407e52d6 Fix clearing a live-but-uncommitted search re-committing the old value
commitFilter now reads the live overrides from a ref that setFilter updates
synchronously, so committing within the same event that changed a filter no
longer sees a stale render closure. antd's Input.Search fires onSearch (before
onChange) when the clear button is clicked, so onSearch now commits the cleared
state instead of closing the dropdown, which had re-committed the still-live
typed value.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 22:02:25 +00:00
bbassieandClaude Opus 4.8 2898fdf5d3 Write the URL immediately when a search filter is cleared
The search box clear (X) button cleared the filter live but only wrote the URL
when the dropdown closed. commitFilter now accepts an explicit value so the
clear handlers can commit right away (avoiding the stale-state race of reading
the live override in the same event), for both useColumnSearch and the Users
filter.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 21:37:32 +00:00
bbassieandClaude Opus 4.8 bb4c82ea5a Make all table search live and only write the URL on close
Add a live filter override to useTableQueryState: setFilter updates the filter
immediately (so every table's search filters as you type) without touching the
URL, and commitFilter writes it to the URL when the filter dropdown closes.
useColumnSearch and useUserFilter now filter live via setFilter and commit on
close, so the Users table no longer rewrites the URL on every keystroke and the
other tables gain live search.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 00:14:32 +00:00
bbassieandClaude Opus 4.8 1f2730a64f Add name-column search to the Mods and Mod Packs tables
These were the only tables without the column search filter; wire useColumnSearch
into both name columns with URL-persisted filteredValue, matching the other tables.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 23:58:16 +00:00
Cooldude2606 43238feada Merge pull request #941 from bbassie/feature/lenient-mod-version-parsing
Parse mod versions leniently like the game does
2026-07-02 00:50:06 +01:00
Cooldude2606 573274875c Rename to SourceVersion 2026-07-02 00:27:05 +01:00
Cooldude2606 2e27c4f499 Maintain strict tagging of GameVersion 2026-07-02 00:08:09 +01:00
bbassieandClaude Opus 4.8 8029ad3b2d Resolve GameVersion type as a plain-string supertype
Cooldude's GameVersion brand was disjoint from the clean version types in
both directions, forcing safeVersion/safeFactorioVersion companions and
breaking widening from FullVersion at file-name call sites.

Make GameVersion a plain string alias: the raw, un-normalised version as the
game reads it. The stricter template-literal types (FullVersion,
MajorMinorVersion, PartialVersion) now widen into it for free, while a
GameVersion still can't be used where a normalised version is required
without going through normaliseGameVersion/integerGameVersion. Runtime
validation stays in GameVersionSchema.

- Drop safeVersion/safeFactorioVersion; ModInfo.version stays raw (verbatim so
  it matches the mod's file name), integerVersion normalises via the new
  integerGameVersion. factorioVersion reverts to a normalised MajorMinorVersion
  since it is never part of a file name.
- ModRecord.version becomes a GameVersion so mod packs round-trip the exact
  file-name version.
- ModVersionEquality.testVersion / ModVersionRange.testVersion accept a raw
  GameVersion and normalise it; the equality bound stays strictly validated.
- Migration only pads clean partial versions; raw versions are kept verbatim.
- Cover the leading-zeros case: version kept verbatim in the file name while
  comparing equal to its normalised form.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 13:54:06 +00:00
Cooldude2606 eca5cdcd9e Merge pull request #939 from bbassie/feature/resizable-console
Make the Web UI console resizable
2026-07-01 00:25:28 +01:00
Cooldude2606 523293d4d9 Manual adjustments to GameVersion and ReleaseChannel types
Through the creation of this commit, it was discovered that ModInfo.version must be typed as GameVersion due to file names. This results in wide spread propagation of the unsafe type.  The full impact including on ModRecord and ModVersion.testVersion have no been implemented until a path forward is decided.
2026-06-30 23:39:08 +01:00
bbassieandClaude Opus 4.8 5d20f8fc84 Revert strict ModInfo.jsonSchema and cover the factorio_version error
The strict major.minor jsonSchema did not improve any consuming type (the
factorioVersion field is already typed) and only complicated the fromJSON
signature, so revert it (and drop the now-unused MajorMinorVersionSchema).
Add a test for the unparseable factorio_version branch in fromJSON.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 23:20:37 +00:00
bbassie e27ad5fdf9 Merge remote-tracking branch 'upstream/master' into feature/lenient-mod-version-parsing
# Conflicts:
#	packages/lib/src/data/version.ts
2026-06-29 22:57:48 +00:00
bbassieandClaude Opus 4.8 5b1f7d796d Make ModInfo.jsonSchema strict about the major.minor factorio_version
The stored ModInfo always normalises factorio_version to a major.minor (see
fromJSON), so the serialised schema can use the strict MajorMinorVersionSchema
while infoJsonSchema keeps the lenient GameVersionSchema for raw info.json.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 22:49:59 +00:00
bbassie 0874c26208 Merge remote-tracking branch 'upstream/master' into feature/table-url-state
# Conflicts:
#	packages/web_ui/src/components/HostsPage.tsx
#	packages/web_ui/src/components/InstanceList.tsx
#	packages/web_ui/src/components/ModsPage.tsx
#	packages/web_ui/src/components/PluginsPage.tsx
#	packages/web_ui/src/components/RolesPage.tsx
#	packages/web_ui/src/components/UserViewPage.tsx
#	packages/web_ui/src/components/UsersTable.tsx
#	packages/web_ui/src/index.ts
2026-06-29 22:37:03 +00:00
bbassieandClaude Opus 4.8 1e7b8f08cf Address review: tidy pagination handling and comments
Let useTableQueryState accept a full TablePaginationConfig (only defaultPageSize
is read) so UsersTable passes its pagination prop straight through instead of
extracting and casting it. Drop the redundant pagination: false from the tables
that just want it disabled (that is already the default). Clarify the comment on
the Last Seen online-only default to mention the onlyOnline prop, and explain why
onChange only uses the first sorter.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 22:26:39 +00:00
bbassieandClaude Opus 4.8 7c2793752d Add MajorMinorVersion type and dedupe the game version regex
Address review feedback: introduce a MajorMinorVersion type with
isMajorMinorVersion, rename majorMinorVersion to normaliseMajorMinorVersion,
and type ModInfo.factorioVersion as MajorMinorVersion so already-reduced
values are not re-reduced (ModPackViewPage uses mod.info.factorioVersion
directly). Reuse the single gameVersionRegExp in both GameVersionSchema and
normaliseGameVersion instead of duplicating the pattern.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 22:12:36 +00:00
bbassieandClaude Opus 4.8 5cde390c24 Use a spacer element for console scroll headroom
Replace the padding-top scroll headroom with a dedicated .console-spacer
element so the pin-to-bottom keeps working without padding inflating the
measured height, and trim the style.css comments.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 22:01:06 +00:00
Cooldude2606 46ebb13fda Update quick bar to support 2.1 and quality 2026-06-29 02:17:03 +01:00
Cooldude2606 4775a5fd06 Add encode_string and decode_string to compat 2026-06-29 01:35:05 +01:00
Cooldude2606 7e89e7fb6f Fix assert in test case 2026-06-26 22:15:28 +01:00
Cooldude2606 95ea073b77 Expose error on event subscriber snapshot 2026-06-26 22:04:06 +01:00
Cooldude2606 e135d37878 Change SubscriptionFilters to extend Set 2026-06-26 21:41:48 +01:00
bbassieandClaude Opus 4.8 2dedb44372 Fix being unable to shrink the console after enlarging it
The resize observer measured offsetHeight, which on the content-box console
includes the pin-to-bottom padding-top. Feeding that back into the height
(and thus the padding) meant the padding inflated the measured height, so
the console could be enlarged but never dragged smaller again.

Make the console border-box so its height already includes the padding and
offsetHeight matches the dragged height, breaking the feedback. The default
height is bumped to 600 so the rendered size stays the same as the previous
content-box height + padding.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 16:38:35 +00:00
bbassieandClaude Opus 4.8 97a6c32838 Parse mod versions leniently like the game does
The mod portal "ApiVersion" enum was validated strictly when loading a
mod's info.json, which rejected versions the game itself accepts. Versions
are now normalised the same lenient way the game reads them: leading
whitespace is allowed, the major and minor are required, the patch is
optional, any trailing content is ignored, and numbers are read as integers.

This applies to both a mod's version and its factorio_version, validated by
a regex schema rather than the fixed enum. The enum is no longer used to
validate versions at all - the mod portal is the source of truth, so its
error is now surfaced when a version is unsupported. The known-version list
kept for the web interface fallback gains 2.1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 16:24:43 +00:00
BastiaanandClaude Opus 4.8 a56fcbd785 Fix antd deprecation and TreeSelect duplicate-value warnings (#940)
Rename the deprecated Modal `destroyOnClose` prop to `destroyOnHidden`
(antd 5.25+) across the web UI.

Skip a version-tree child equal to its major.minor group key in
InputVersion, which triggered "Same value exist in the tree" warnings when
the version list contained bare major.minor entries (the ApiVersions
fallback shown before the version fetch resolves).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 17:21:52 +01:00
Cooldude2606 e16603bbd0 Merge pull request #937 from bbassie/feature/instance-version-channels
Add release channels (stable/experimental) as instance version targets
2026-06-26 17:00:44 +01:00
bbassieandClaude Opus 4.8 dfe132699e Add integration test for starting an instance with a release channel
Configures an instance with the "experimental" channel and starts it,
exercising channel resolution end to end across the controller and host.
The resolved version may or may not be installed depending on the host's
Factorio install, so the test asserts the instance reaches a terminal state
rather than a specific outcome.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 15:38:29 +00:00
bbassieandClaude Opus 4.8 f793ec33b3 Add tests for release channel resolution and caching
Cover fetchLatestReleases and resolveReleaseChannel, the controller's
latest-releases cache with its on-disk offline fallback, the
LatestReleasesRequest round-trip, and FactorioServer.setTargetVersion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 15:10:06 +00:00
Cooldude2606 ce8bddad8f Merge pull request #931 from bbassie/feature/open-in-new-tab
Make navigation open in new tabs (real links)
2026-06-26 15:47:33 +01:00
bbassieandClaude Opus 4.8 3fe0c85055 Make the Web UI console resizable
The instance console was locked to a fixed 300px height. It can now be
drag-resized vertically (native resize handle), and the chosen height is
persisted with useLocalStorage so it is restored on the next visit.

Both the height and the scroll-anchor padding are driven by a
--console-height CSS variable, so the pin-to-bottom behaviour keeps working
at any size.

Closes #938

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 14:47:32 +00:00
bbassieandClaude Opus 4.8 9da48d5387 Add release channels as instance version targets
An instance's factorio.version can now target a release channel such as
"stable" or "experimental" (any key from factorio.com/api/latest-releases)
in addition to "latest" and specific X.Y / X.Y.Z versions.

The controller proxies the latest-releases API through a ValueCache and
persists the last successful response to latest-releases.json, serving that
copy when the API is unreachable. On instance start the host resolves a
channel to the concrete version it points to and feeds it into the existing
checkForUpdates/findVersion path, so a channel auto-installs and runs like a
pinned version. The version selector in the web UI lists the available
channels alongside "latest".

Also clarifies the host log/error when a newer version cannot be downloaded
automatically because the host uses a direct (single-version) Factorio
install rather than a versioned directory.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 14:19:34 +00:00
bbassieandClaude Opus 4.8 2bd9bc6138 Fill table link cells with a block link instead of an overlay
Replace the absolutely-positioned a::after overlay with display:block on
the link. The overlay covered sibling cell content (the restart-required
icon), swallowing its hover so the tooltip never triggered. A block link
fills the cell width without overlapping siblings.

Also skip rendering the assigned-host link when an instance has no
assigned host, avoiding a dead /hosts/undefined/view link.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 12:24:34 +00:00
Daniel a274fe74e1 Merge pull request #936 from Cooldude2606/fix/925
Add "recycler" as builtin mod for 2.1
2026-06-26 10:25:41 +02:00
Daniel ea5c35198f Merge pull request #924 from bbassie/feature/sidebar-layout
Rework site layout into a fixed sidebar
2026-06-26 10:06:23 +02:00
Cooldude2606 0f6f3bbb8a Add "recycler" as builtin mod for 2.1 2026-06-26 02:20:32 +01:00
bbassieandClaude Opus 4.8 448eb9223d Make table link cells fill the cell and fix focus colour
Stretch the navigation link over the whole table cell (a position:relative
cell with an ::after overlay) so the entire name cell - and the Instances
Assigned Host cell - is clickable and right-click "open in new tab"-able,
not just the text. Also force color:inherit on the sidebar and account links
so the focus left behind by a middle-click no longer shows them blue.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 20:55:29 +00:00
bbassieandClaude Opus 4.8 cc0be69019 Enlarge the sidebar logo to 56px
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 18:41:25 +00:00
Cooldude2606andClaude Opus 4.8 9c627f4c16 Add cluster overview landing page (#933)
* Add cluster overview landing page

Add an Overview page at / (top of the sidebar, filling the previously
blank root route) summarising the cluster: hosts connected, instances
running, users online, aggregate CPU/memory/disk usage, controller
uptime, a recent-errors panel (LogConsole), and a link to Grafana when
configured. Each card is gated by the relevant permission.

Adds a controller.grafana_url config field, read on the page via
ControllerConfigGetRequest, for the Grafana link.

Closes #928.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Add saves card and tidy the overview grid

Add a "Saves loaded" card (loaded vs total save size) to the overview, lay
the cards out four-per-row on desktop, and add a compact date format option
to MetricRelativeDate used for the controller uptime card.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Change order of cards and fix permission check

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Cooldude2606 <25043174+Cooldude2606@users.noreply.github.com>
2026-06-25 16:27:55 +01:00
Cooldude2606 118154a43a Change order of cards and fix permission check 2026-06-25 16:20:48 +01:00
BastiaanandClaude Opus 4.8 b29f4db52c Show the public address on the instance details page (#935)
The instance list shows the public connect address but the instance detail
page did not. Add a Public address row (host:port) with a copy button, and
lay the details out 2x2 so the extra row stays balanced.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 16:15:18 +01:00
Cooldude2606 5efa257a36 Merge pull request #932 from bbassie/feature/remember-console-toggle
Remember the instance console chat/log toggle
2026-06-25 16:14:44 +01:00
bbassieandClaude Opus 4.8 09ec6d6ec5 Add saves card and tidy the overview grid
Add a "Saves loaded" card (loaded vs total save size) to the overview, lay
the cards out four-per-row on desktop, and add a compact date format option
to MetricRelativeDate used for the controller uptime card.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 02:40:22 +00:00
bbassieandClaude Opus 4.8 8cacdc5112 Use inline sort/filter getters instead of mapping columns
Replace the satisfies-ColumnsType + .map(applyColumn) approach with inline tableState.sortOrder()/filteredValue() getters set per column, so the column arrays stay plain literals (no satisfies, no constraints; UsersTable stays any[]). Move useColumnSearch into util/ next to useTableQueryState.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 02:32:04 +00:00
bbassieandClaude Opus 4.8 d4394f1190 Link the sidebar logo to the overview page
Make the Clusterio logo in the top bar a link to / so it lands on the overview/dashboard page.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 02:32:03 +00:00
bbassieandClaude Opus 4.8 6feb9c2762 Add useLocalStorage hook for the console toggle
Move the instance console chat/log toggle persistence into a generic useLocalStorage hook that defers the write so it never blocks the toggle update.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 02:31:49 +00:00
bbassieandClaude Opus 4.8 79c6bd1ac4 Add cluster overview landing page
Add an Overview page at / (top of the sidebar, filling the previously
blank root route) summarising the cluster: hosts connected, instances
running, users online, aggregate CPU/memory/disk usage, controller
uptime, a recent-errors panel (LogConsole), and a link to Grafana when
configured. Each card is gated by the relevant permission.

Adds a controller.grafana_url config field, read on the page via
ControllerConfigGetRequest, for the Grafana link.

Closes #928.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 01:21:17 +00:00
bbassieandClaude Opus 4.8 4e94092730 Type UsersTable columns instead of any
UsersTable declared its columns as any[], which masked a few type
mismatches. Type them as ColumnType<lib.UserDetails>[] (matching
InstanceList) and fix what surfaced: the Roles column and onFilterUser
value types (antd keys include bigint) and useUserFilter's filterDropdown
prop type (FilterDropdownProps instead of a hand-written string[] shape).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 00:55:47 +00:00
bbassieandClaude Opus 4.8 82629a57c6 Always persist table state; drop the persistState prop
Every render site of InstanceList and UsersTable already passed persistState,
so the prop and its uncontrolled fallback branch were dead config. Remove the
prop and the conditionals so these tables always persist their state to the
URL. No runtime behaviour change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 00:40:21 +00:00
bbassieandClaude Opus 4.8 92f607a864 Remember the instance console chat/log toggle
The Console "Chat/Log" filter toggle on the instance page now persists its
state to localStorage, so turning it off (to see full logs) is remembered
across reloads. It still defaults to on (chat only) when nothing is stored.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 00:30:12 +00:00
bbassieandClaude Opus 4.8 245483cb35 Adopt agreed URL scheme and drop the dist-based test
Following discussion on the PR, the sort value now packs the direction as
`<field>.<asc|desc>` (e.g. user.sort=name.desc) instead of a separate
`.order` param, page size uses `.size` instead of `.pageSize`, and the
reserved system keys are sort/page/size. `.sort` is read tolerant of repeats
so multi-column sort can be added later without changing the URL shape.

Filterable columns must not use the reserved system names. Removed the
web_ui unit test since it required importing from the built dist.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 00:24:31 +00:00
bbassieandClaude Opus 4.8 f04fc68513 Use real links for navigation so they open in new tabs
In-app navigation was done with JS onClick -> navigate() handlers instead of
real <a href> anchors, so the browser's right-click "Open in new tab" (and
middle-click / ctrl-click) was unavailable. (closes #929)

- Sidebar nav items and the account "user" entry now render their label as the
  existing Link component (a real anchor via react-router useHref), keeping the
  Menu onClick for plain left-clicks; stopPropagation avoids double navigation.
- The "back to instances" alert action becomes a Link.
- Table rows (Hosts, Roles, Plugins, Instances, Users, the user instance-stats
  table, Mod Packs): the name cell becomes a real Link (color inherit, so it
  looks unchanged) which carries the right-click context menu, and a new
  useRowNavigation hook handles the whole row — left-click navigates in place,
  middle-click / ctrl-click open the target in a new tab. The hook ignores
  clicks landing on a real link inside the row so the anchor handles those.

Genuine actions (download/delete/modify/logout), post-create navigation and
external links are left as-is.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 23:52:15 +00:00
bbassieandClaude Opus 4.8 49eb594e5b Persist table sort/filter state in the URL
Tables such as Hosts, Instances, Users, Roles, etc. now write their sort
order, column filters and pagination into the URL query string, so reload,
link-sharing and browser back/forward restore the same view (closes #927).

Every parameter is namespaced with the table name so the URL is
self-describing and two tables can share a page without colliding, e.g.
?user.sort=name&user.name=bob&user.admin=true or ?instance.name=foo.

Adds two reusable hooks, exported from @clusterio/web_ui so plugins can use
them too:
- useTableQueryState: makes an antd Table controlled and round-trips its
  sort/filter/pagination through the URL via useSearchParams. The pure
  serialisation helpers live in util/tableQuery.ts and are unit tested.
- useColumnSearch: a column-header text-search filter (matching the Users
  table style), persisted through the same filter mechanism.

Applied to Hosts, Roles, Plugins, the instance list (standalone and on the
host detail page), Users (main table and the online-players list on the
instance detail page), instance saves, the per-instance user stats, the mod
packs and stored mods tables, and the subspace_storage storage page. The
storage page's resource search moves from a standalone box into the Resource
column header, keeping its regex matching.

The bundled user filter persists as separate readable params (user.name,
user.admin, ...) via a per-column FilterCodec rather than one JSON blob.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 23:23:54 +00:00
bbassieandClaude Opus 4.8 ed84a476c6 Add top bar and overlay nav drawer on mobile
Reintroduce a slim top bar to host the menu toggle, branding and the
account menu, which gives the hamburger a natural home now that the
header is gone. The bar is more compact than the original header and
uses the dark sider colour for better contrast.

On phone screens the sider becomes an overlay Drawer (opening below the
bar and closing on navigation), following normal hamburger conventions
instead of shifting the content across. On desktop the same toggle
collapses the sider, with its width animation disabled to avoid the
content reflow glitch while resizing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 19:19:36 +00:00
Cooldude2606 eb77ca566c 2.0.0-alpha.26 2026-06-23 00:39:47 +01:00
bbassieandClaude Opus 4.8 262357acef Rework site layout into a fixed sidebar
Remove the top header and move its contents into the sidebar, which is
now sticky and full height: logo and version at the top, navigation in
the middle, and the account menu pinned to the bottom (opening to the
right).

The sidebar is rebuilt with antd Flex, Typography and Menu so almost all
styling comes from antd, reducing the custom css to a single sticky rule.
The nav menus also use theme="dark" to match the sider and fix the low
contrast active item.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 21:16:49 +00:00
Cooldude2606 9e727f9a13 Merge pull request #917 from Cooldude2606/feature/user-status-filter
Enhance user filters and improve UI components
2026-06-13 15:18:22 +01:00
Cooldude2606 d35e8caa2b Merge pull request #914 from Cooldude2606/feature/view-changelog
Add viewable changelog
2026-06-12 16:14:19 +01:00
Cooldude2606 b9c073e26f Fix typo in ci publish workflow 2026-06-12 16:08:37 +01:00
Cooldude2606 b160fecac4 Make icons local to package 2026-06-12 16:04:39 +01:00
Cooldude2606 dab727f90f Fix import in AboutModal 2026-06-10 22:39:26 +01:00
Cooldude2606 0e36df8ae3 Add AboutModal 2026-06-10 22:35:00 +01:00
Cooldude2606 f7d3516c24 Remove internal div from changelog 2026-06-10 20:18:05 +01:00
Cooldude2606 e6665d0803 Fix incorrect use of path.parse within create template (#919) 2026-06-10 20:01:53 +01:00
Cooldude2606 6fd4654c21 Merge pull request #913 from Cooldude2606/refactor/subscriptions-2
Refactor subscription handling and add new filter classes
2026-06-02 22:51:13 +01:00
Cooldude2606 b5c99fad2a Fix incorrect doc comment 2026-06-02 22:50:41 +01:00
Cooldude2606 f588f7f8d4 Guard against div0 in cpuUsage (#918) 2026-06-02 22:49:45 +01:00
Cooldude2606 4fe83cbcbc Add duration filter to first seen column 2026-06-01 23:12:47 +01:00
Cooldude2606 069a050f39 Unify column for instance and no instance 2026-06-01 23:04:57 +01:00
Cooldude2606 577615803b Add loading={!synced} to table and inputs 2026-06-01 22:45:04 +01:00
Cooldude2606 680ade5975 Add instance filter to users page 2026-06-01 22:44:13 +01:00
Cooldude2606 6914645c19 Make Username reusable and fix active icon issue 2026-06-01 21:58:27 +01:00
Cooldude2606 1d0b6b9a62 Add user status filters 2026-06-01 21:47:33 +01:00
Cooldude2606 0482f4ae08 Added test for invalid assigned host 2026-06-01 01:06:06 +01:00
Cooldude2606 16ce748ffa Improved test coverage 2026-06-01 00:58:25 +01:00
Cooldude2606 2c199028ea Add tests 2026-06-01 00:31:29 +01:00
Cooldude2606 22b16a7ac2 Small refactor of verifyToken effect 2026-06-01 00:13:57 +01:00
Cooldude2606 f2ccde0dd6 Add version and connect button to player auth 2026-05-31 23:55:43 +01:00
Cooldude2606 dd80d49989 Add viewable changelog 2026-05-31 03:52:42 +01:00
Cooldude2606 e3d5f434a4 Better toString and test coverage 2026-05-29 20:14:23 +01:00
Cooldude2606 11b8a0ff97 Fix bad snapshot initialisation 2026-05-29 19:15:27 +01:00
Cooldude2606 e54b292fce Fix tests relating to permissions 2026-05-29 18:38:07 +01:00
Cooldude2606 41222343e3 Add tests 2026-05-29 17:45:14 +01:00
Cooldude2606 4676f1c820 Add filteredHandler to EventSubscriber 2026-05-29 14:09:19 +01:00
Cooldude2606 e49b82842d Add ValueSubscriber and MapSubscriber 2026-05-28 01:12:05 +01:00
Cooldude2606 e0a39e5630 Add option for events to define get filters 2026-05-28 01:11:38 +01:00
Cooldude2606 21b050b220 Call subscriptionUpdate for all update cases 2026-05-28 00:38:15 +01:00
Cooldude2606 9bed1223fd Add option to replace filters 2026-05-28 00:33:45 +01:00
Cooldude2606 4735f0725d Add SubscriptionFilters class 2026-05-28 00:25:29 +01:00
Cooldude2606 1f27617a27 Use addressIndex and a map for subscriptions 2026-05-27 23:35:31 +01:00
Cooldude2606 ee460d677d Rename libAddress.index to requestIndex and add addressIndex 2026-05-27 23:23:29 +01:00
Cooldude2606 f2ca465ec6 Merge pull request #909 from Cooldude2606/fix/small-things
Multiple small changes
2026-05-14 13:08:06 +01:00
Cooldude2606 40e6961711 Fix conflict between defaultFilteredValue and responsive 2026-05-14 12:51:04 +01:00
Cooldude2606 1bb51988ac Fix popconfirm on instance kill button 2026-04-25 23:32:30 +01:00
Cooldude2606 2c30fa4a94 Add user section to changelog script 2026-04-25 22:38:23 +01:00
Cooldude2606 a22186ca01 Add a newline after each section title in change log 2026-04-25 22:19:49 +01:00
Cooldude2606 746b11ff65 Add scrollable div to player auth server list 2026-04-25 22:18:32 +01:00
Cooldude2606 de51741835 Use workspace:^ rather than workspace:* 2026-04-25 22:06:36 +01:00
Cooldude2606 a633c42904 Fix default filter for online users table 2026-04-25 22:03:02 +01:00
Cooldude2606 7522424517 2.0.0-alpha.25 2026-04-24 17:41:16 +01:00
Cooldude2606 57f15d7ba4 Merge pull request #894 from Cooldude2606/fix/892
Fix accessing main inventory when in map view
2026-04-24 11:30:56 +01:00
Cooldude2606 1af672aa1d Always exit remote view on sync 2026-04-23 17:25:13 +01:00
Cooldude2606 41f8f493aa Fix nil table key error in factorio 1.1 2026-04-23 00:19:20 +01:00
Cooldude2606 8be95c140a Fix edge cases with physical location and controller 2026-04-22 22:37:42 +01:00
Cooldude2606 add0088704 Switch to xpcall for better trace backs 2026-04-22 00:49:45 +01:00
Cooldude2606 037f06808e Support remote controller type within deserialize 2026-04-22 00:45:31 +01:00
Daniel 01456818a8 Merge pull request #897 from Cooldude2606/feature/custom-version-input
Allow custom versions to be selected as a target
2026-04-20 22:26:11 +02:00
Cooldude2606 5c3182fb56 Merge pull request #902 from Cooldude2606/feature/improved-role-page
Improved RoleViewPage
2026-04-19 22:52:19 +01:00
Cooldude2606 f515b2db80 Disable the select all and clear buttons using canUpdate 2026-04-19 22:47:05 +01:00
Daniel eb2c2b5aec Merge pull request #901 from Cooldude2606/fix/instance-config-update
Add missing instanceConfigUpdated to InstanceConfigSetRequest
2026-04-19 22:48:22 +02:00
Cooldude2606 6f181f357b Change colour of groups to match input colour 2026-04-19 16:53:27 +01:00
Cooldude2606 1bcc152368 Improved RoleViewPage
Add groups, search, better description format, and default / dangerous icons.
2026-04-19 03:28:09 +01:00
Cooldude2606 26df9d2caa Add missing instanceConfigUpdated to InstanceConfigSetRequest 2026-04-18 22:05:05 +01:00
Cooldude2606 45adb1e88c Merge pull request #896 from Cooldude2606/feature/ctl-double-interrupt
Add double interrupt logic to ctl to force exit
2026-04-15 22:22:58 +01:00
Cooldude2606 6f5b8ce68d Update log statement to fatal 2026-04-15 22:15:14 +01:00
Cooldude2606 16a249d5bf Have version selectors share a common base component 2026-04-14 23:32:16 +01:00
Cooldude2606 a52e4320ec Improve UX on keyboard 2026-04-14 22:50:39 +01:00
Cooldude2606 e8cabb7196 Standardise shutdown signal guarding 2026-04-14 22:10:01 +01:00
Cooldude2606 7e82ad89ff Change config errors to be subclasses of RequestError (#895)
This prevents them from being logged when raised during an event handler.
2026-04-14 21:38:11 +01:00
Cooldude2606 2219b58000 Fix two missed cases of get_main_inventory 2026-04-14 21:31:26 +01:00
Cooldude2606 3667b4733a Fix webui error on setting host config (#893) 2026-04-14 21:21:00 +01:00
Hornwitser 490e9c0e5f Remove download headless Factorio in installer (#890)
This feature was made obsolete by hosts being able to download a
configured target version on instance startup in #831.
2026-04-13 12:27:20 +02:00
Cooldude2606 7e87201c24 Allow custom versions to be selected as a target
Add a custom version input field which can be used to enter any version number.
2026-04-12 23:18:13 +01:00
Cooldude2606 e9653c13eb Add double interrupt logic to ctl to force exit 2026-04-12 22:37:50 +01:00
Cooldude2606 255cc4c325 Fix accessing main inventory when in map view 2026-04-12 22:15:24 +01:00
Cooldude2606 fc05b229f1 2.0.0-alpha.24 2026-04-10 12:54:27 +01:00
Hornwitser da4599f219 Fix missing ) in changelog script parser output 2026-04-09 23:46:35 +00:00
Hornwitser a7620d3cdc Add target variables in changelog script parser
Add variables specifying the repository and branch that the changelog
script parser operates on in order to make it easier to use this script
on other projects.
2026-04-09 23:46:35 +00:00
Hornwitser 2e48b728dd Remove fs-extra and klaw (#882)
These packages are obsoleted by the promises API to the built-in Node fs
module.  The fs-extra package is also causing problems with broken APIs
that don't behave the same as Node's fs module, and throwing errors
without code properties on them.
2026-04-09 22:53:13 +02:00
Cooldude2606 130dca4261 Merge pull request #885 from Cooldude2606/feature/load-plugin-validation
Allow plugins to specify requiring save patching / script commands
2026-04-09 21:48:27 +01:00
Cooldude2606 2afd7aab38 Fix failing tests 2026-03-24 18:25:53 +00:00
Cooldude2606 622d372776 Add feature flags to first party plugins 2026-03-24 17:44:30 +00:00
Cooldude2606 790c2834f2 Add validator to load_plugin 2026-03-24 17:36:19 +00:00
Hornwitser 71dfb8ca24 Support node: prefix in web_ui (#881)
The Node.js documentation's examples all use this prefix, and there are
now builtin modules in Node.js only accessible via it.  Add support for
using it in lib code shared in the web_ui.
2026-03-21 01:39:24 +01:00
Cooldude2606 81e419dad4 Merge pull request #856 from Cooldude2606/feature/config-validation
Add extended config validation
2026-03-21 00:15:16 +00:00
Cooldude2606 93271730eb Make configs readonly inside validators 2026-03-20 18:56:55 +00:00
Cooldude2606 5d0985cafc Fix tests not checking that validators are called 2026-03-20 17:42:48 +00:00
Cooldude2606 6ade75e4cd Fix incorrect config validators 2026-03-20 17:07:54 +00:00
Hornwitser 86eefab9f5 Refactor file write streams (#877)
A lot of code is repeated in various different ways in order to handle
streaming content to files without overwriting them, whether it's from
web resources or local streams.

Refactor the handling of these streams to all use the same utility
function for creating a write stream, and for the places downloading
urls use the same downloadFile function.
2026-03-19 21:55:52 +01:00
Hornwitser 7701010fee Merge pull request #875 from Hornwitser/refactor-export
Refactor export
2026-03-19 21:55:04 +01:00
Hornwitser b3dc262862 Remove error prone refCount logic
This can simply use the length of the callbacks array instead.
2026-03-19 16:10:54 +01:00
Cooldude2606 f0fb52ab74 Revert change to mock exports 2026-03-18 00:17:17 +00:00
Cooldude2606 ed4fe3230d Fixed test causing missed coverage 2026-03-18 00:03:44 +00:00
Cooldude2606 8217acef3f Add coverage of config requests 2026-03-17 23:42:41 +00:00
Hornwitser 019737286f Handle missing export in locale and metadata store
In the future, removing exports is likely going to be a feature.  Handle
the case where the export data is removed from a mod pack in the locale
and prototype metadata stores.
2026-03-17 23:32:04 +01:00
Hornwitser d90ac998aa Handle incorrect usage of subscribe in stores
If the unsubscribe function is called multiple times the indexOf
function will return -1 which will end up removing random other
subscription callbacks.  Prevent the incorrect callabck removial
in this case.
2026-03-17 23:23:03 +01:00
Hornwitser 781b98b1c1 Explicitly compare refCount with 0 2026-03-17 23:21:56 +01:00
Cooldude2606 4986c9af99 Complete coverage of config classes 2026-03-17 21:31:46 +00:00
Cooldude2606 4868ed2d63 Merge branch 'master' into feature/config-validation 2026-03-16 23:22:54 +00:00
Cooldude2606 2510d47023 Add tests 2026-03-16 23:20:40 +00:00
Hornwitser 0d28a3ac3c Refactor web_ui handling of exports
The handling of exported data such as locales and icons in web_ui was
based on a model where there is only one set of data exported per
cluster.  This model changed to have one set of exports per mod pack
when mod packs were introduced, but the existing web_ui code was
bodged to use exports from a "default mod pack" on the controller.

Refactor the code loading exported data to take the mod pack as an
argument for selecting which exported data to load.  The different kinds
of prototype metadata that can be loaded is consolidated into one
`useExportPrototypeMetadata` hook.  The logic for displaying icons is
put into a `FactorioIcon` component.  And the obsolete
`useExportManifest` hook is removed.

These changes allow the `/icon-reference` page to display exports on a
per mod pack basis.  Which has now been linked on the bottom of the mod
pack view page.
2026-03-16 17:11:47 +01:00
Hornwitser f4ee2005e3 Fix bad changelog entries for alpha 23 [skip ci] (#873) 2026-03-16 13:38:41 +01:00
Hornwitser 4b85367d4e Expose lib.isDeepStrictEqual
This utility is useful when comparing deep structures for equality in
places like the web_ui.
2026-03-13 18:34:17 +01:00
Hornwitser 433cc3c712 Add missing model exports to web_ui package
A lot of the models were not available to plugins due to the index page
not exposing them on the package.
2026-03-13 18:33:57 +01:00
Hornwitser ab5162e581 Add changelog script parsing entries from PRs (#871)
Add script which uses GitHub's REST API to fetch issues and pull
requests that have changed since the last release, parse out the
changelog entries from merged pull requests, and print a formatted
version of the changelog suitable for copying into the release
description, the CHANGELOG.md file and the Discord announcement.

The script expects Pull Requests to have a `### Changelog` section in
them with a code block containing changelog entries.  As an added bonus
the script can parse out `#<number>` references so that you do not need to
write it out in the ugly `[#id](long-url-with-id-repeated)` format.

Updates documentation and pull request template to describe the
changelog format and usage of the formatting script.

Includes improvements from review and a second look.
2026-03-13 15:33:24 +01:00
Hornwitser 3b8058de81 Refactor icon export to be based on prototypes
Use the defines.prototypes data structure from the game to find which
prototype sub-types belong in the same base type, and export icons based
on these base type.

This renames the planets category to space-location, signal to
virtual-signal and plits item up into item and fluid.  And changes the
exported metadata to include prototypes with no icons, as future code
might want to use it to for example enumerate all item prototypes.
2026-03-12 18:26:04 +01:00
Hornwitser f4916594b8 Update jimp to version 1.6.0
Resolves #854
2026-03-12 14:45:48 +01:00
Hornwitser 4c01f6a789 Fix export missing localised name
The change in #838 removed localised names from the exported metadata.
Add it back again so that items with overriden names are translated.
2026-03-12 14:45:48 +01:00
Hornwitser 076fb933dc Share structured data types in export
Place shared types of the export functionality into lib and use in the
web interface to make sure the data is not incorrectly handled.

Resolves #526
2026-03-12 14:45:48 +01:00
Hornwitser bcabad567e Distinguish simple and layered icons
This allows the type to be automatically inferred and removes the need
for type casts.
2026-03-12 14:45:48 +01:00
Cooldude2606 414ef0bc8b Some fixes to config setting 2026-03-11 00:28:36 +00:00
Cooldude2606 9d807fc6d6 Update webui config to use bulk set 2026-03-10 23:38:22 +00:00
Cooldude2606 c43f752eaf Add ConfigSet requests 2026-03-10 23:16:58 +00:00
Cooldude2606 6e8290cc08 Add full staging to config class 2026-03-10 22:56:50 +00:00
Cooldude2606 bbd3cc7fdb Remove pnpm version from publish workflow 2026-03-10 19:13:29 +00:00
Cooldude2606 d76902cebe Add imperfect dependency validation 2026-03-05 00:01:55 +00:00
Cooldude2606 0fa96521c5 Rename extendedValidation to validator 2026-03-04 21:08:20 +00:00
Cooldude2606 61232e3537 Export validators on Config class as static 2026-03-04 21:07:30 +00:00
Cooldude2606 014b18caf8 Fix type of controller.auth_secret 2026-03-04 21:02:39 +00:00
Cooldude2606andHornwitser 687a384fe4 Use an async arrow function for webui rules
Co-authored-by: Hornwitser <github@hornwitser.no>
2026-03-04 14:34:09 +00:00
Cooldude2606 b2daf26e6c Add warnings to web ui 2026-03-04 00:28:29 +00:00
Cooldude2606 d0604f892b Add simple validators to config 2026-03-03 23:55:23 +00:00
Cooldude2606 d05a529c52 Improve config field definition type 2026-03-03 22:22:59 +00:00
213 changed files with 10482 additions and 3780 deletions

No files matched your search

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