Merge pull request #1130 from bbassie/build/vite-plus

Draft: Replace webpack with Vite and load plugins as ES modules (vote)
This commit is contained in:
Cooldude2606 authored and GitHub committed 2026-10-28 23:17:45 +00:00
commit 188de979b6
94 files changed
+1196 -1212

No files matched your search

+3 -5
View File
@@ -31,17 +31,15 @@ runs:
key: pnpm-store-${{ runner.os }}-${{ steps.store.outputs.week }}-${{ hashFiles('**/package.json', 'pnpm-workspace.yaml') }}
restore-keys: pnpm-store-${{ runner.os }}-
# The prepare scripts run tsc --build and webpack, both incremental when
# their previous output is present. Every run saves its output and the
# next run starts from the most recent one.
# The prepare scripts run tsc --build, which is incremental when its
# previous output is present. Every run saves its output and the next run
# starts from the most recent one.
- if: inputs.ignore-scripts != 'true'
uses: actions/cache@v6
with:
path: |
packages/*/dist
plugins/*/dist
packages/*/node_modules/.cache
plugins/*/node_modules/.cache
key: build-${{ runner.os }}-${{ github.sha }}
restore-keys: build-${{ runner.os }}-
+458
View File
@@ -0,0 +1,458 @@
// Formatting rules come from @stylistic as Oxlint does not implement them.
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": [
"node"
],
"jsPlugins": [
"@stylistic/eslint-plugin"
],
"categories": {
"correctness": "off"
},
"env": {
"builtin": true,
"node": true
},
"ignorePatterns": [
"**/dist/",
"dist/"
],
"rules": {
"@stylistic/indent": [
"error",
"tab",
{
"ignoredNodes": [
"JSXFragment"
],
"ignoreComments": true,
"SwitchCase": 1
}
],
"accessor-pairs": "error",
"array-callback-return": "error",
"arrow-body-style": "error",
"block-scoped-var": "error",
"complexity": [
"error",
{
"max": 25
}
],
"curly": "error",
"default-case": "error",
"default-case-last": "error",
"eqeqeq": "error",
"func-name-matching": "error",
"func-style": [
"error",
"declaration",
{
"allowArrowFunctions": true
}
],
"grouped-accessor-pairs": "error",
"guard-for-in": "error",
"id-match": "error",
"max-depth": "error",
"max-nested-callbacks": "error",
"new-cap": [
"error",
{
"capIsNewExceptions": [
"StringEnum",
"StringKey"
],
"capIsNewExceptionPattern": "^Type\\.",
"newIsCapExceptions": [
"constructor"
]
}
],
"no-alert": "error",
"no-array-constructor": "error",
"no-caller": "error",
"no-console": "error",
"no-constant-condition": [
"error",
{
"checkLoops": false
}
],
"no-constructor-return": "error",
"no-div-regex": "error",
"no-duplicate-imports": "error",
"no-else-return": "error",
"no-eq-null": "error",
"no-eval": "error",
"no-extend-native": "error",
"no-extra-bind": "error",
"no-extra-label": "error",
"no-implicit-coercion": "error",
"no-implicit-globals": "error",
"no-implied-eval": "error",
"no-iterator": "error",
"no-label-var": "error",
"no-lone-blocks": "error",
"no-lonely-if": "error",
"no-loop-func": "error",
"no-multi-assign": "error",
"no-multi-str": "error",
"no-nested-ternary": "error",
"no-new": "error",
"no-new-func": "error",
"no-new-wrappers": "error",
"no-plusplus": [
"error",
{
"allowForLoopAfterthoughts": true
}
],
"no-proto": "error",
"no-restricted-globals": "error",
"no-restricted-imports": "error",
"no-restricted-properties": "error",
"no-return-assign": "error",
"no-script-url": "error",
"no-self-compare": "error",
"no-sequences": "error",
"no-shadow": [
"error",
{
"allow": [
"Event",
"Request",
"yargs"
]
}
],
"no-template-curly-in-string": "error",
"no-throw-literal": "error",
"no-unmodified-loop-condition": "error",
"no-unneeded-ternary": "error",
"no-unused-expressions": "error",
"no-useless-backreference": "error",
"no-useless-call": "error",
"no-useless-computed-key": "error",
"no-useless-concat": "error",
"no-useless-rename": "error",
"no-useless-return": "error",
"no-var": "error",
"no-void": [
"error",
{
"allowAsStatement": true
}
],
"prefer-arrow-callback": "error",
"prefer-exponentiation-operator": "error",
"prefer-numeric-literals": "error",
"prefer-object-spread": "error",
"prefer-promise-reject-errors": "error",
"prefer-regex-literals": "error",
"prefer-rest-params": "error",
"prefer-spread": "error",
"prefer-template": "error",
"radix": [
"error",
"always"
],
"symbol-description": "error",
"unicode-bom": [
"error",
"never"
],
"vars-on-top": "error",
"yoda": [
"error",
"never",
{
"exceptRange": true
}
],
"node/no-sync": [
"error",
{
"ignores": [
"TechnologySync",
"techsToSync"
]
}
],
"@stylistic/array-bracket-newline": "off",
"@stylistic/array-bracket-spacing": [
"error",
"never"
],
"@stylistic/array-element-newline": "off",
"@stylistic/arrow-parens": "off",
"@stylistic/arrow-spacing": [
"error",
{
"after": true,
"before": true
}
],
"@stylistic/block-spacing": "error",
"@stylistic/brace-style": [
"error",
"1tbs",
{
"allowSingleLine": true
}
],
"@stylistic/comma-dangle": [
"error",
{
"arrays": "always-multiline",
"objects": "always-multiline",
"imports": "always-multiline",
"exports": "always-multiline",
"functions": "only-multiline",
"generics": "only-multiline",
"enums": "always-multiline",
"tuples": "always-multiline"
}
],
"@stylistic/comma-spacing": "error",
"@stylistic/comma-style": [
"error",
"last"
],
"@stylistic/computed-property-spacing": [
"error",
"never"
],
"@stylistic/dot-location": [
"error",
"property"
],
"@stylistic/eol-last": "error",
"@stylistic/function-call-argument-newline": "off",
"@stylistic/function-paren-newline": "off",
"@stylistic/generator-star-spacing": [
"error",
{
"before": false,
"after": true
}
],
"@stylistic/implicit-arrow-linebreak": "error",
"@stylistic/jsx-quotes": "error",
"@stylistic/key-spacing": "error",
"@stylistic/keyword-spacing": "error",
"@stylistic/line-comment-position": "off",
"@stylistic/linebreak-style": [
"error",
"unix"
],
"@stylistic/lines-around-comment": "off",
"@stylistic/lines-between-class-members": [
"error",
"always",
{
"exceptAfterSingleLine": true
}
],
"@stylistic/max-len": [
"error",
{
"code": 120
}
],
"@stylistic/max-statements-per-line": "off",
"@stylistic/multiline-comment-style": [
"error",
"separate-lines"
],
"@stylistic/multiline-ternary": "off",
"@stylistic/new-parens": "error",
"@stylistic/newline-per-chained-call": "off",
"@stylistic/no-confusing-arrow": "error",
"@stylistic/no-extra-parens": "off",
"@stylistic/no-floating-decimal": "error",
"@stylistic/no-mixed-operators": [
"error",
{
"groups": [
[
"&",
"|",
"^",
"~",
"<<",
">>",
">>>"
],
[
"==",
"!=",
"===",
"!==",
">",
">=",
"<",
"<="
],
[
"in",
"instanceof"
]
]
}
],
"@stylistic/no-multi-spaces": "error",
"@stylistic/no-multiple-empty-lines": "error",
"@stylistic/no-tabs": "off",
"@stylistic/no-trailing-spaces": "error",
"@stylistic/no-whitespace-before-property": "error",
"@stylistic/nonblock-statement-body-position": "error",
"@stylistic/object-curly-newline": "error",
"@stylistic/object-curly-spacing": "off",
"@stylistic/one-var-declaration-per-line": "error",
"@stylistic/operator-linebreak": [
"error",
"after",
{
"overrides": {
"?": "before",
":": "before",
"||": "before",
"&&": "before",
"??": "before",
"|": "before",
"&": "before"
}
}
],
"@stylistic/padded-blocks": "off",
"@stylistic/padding-line-between-statements": "error",
"@stylistic/quote-props": "off",
"@stylistic/quotes": [
"error",
"double",
{
"avoidEscape": true
}
],
"@stylistic/rest-spread-spacing": [
"error",
"never"
],
"@stylistic/semi": "error",
"@stylistic/semi-spacing": "error",
"@stylistic/semi-style": "off",
"@stylistic/space-before-blocks": "error",
"@stylistic/space-before-function-paren": "off",
"@stylistic/space-in-parens": [
"error",
"never"
],
"@stylistic/space-infix-ops": "off",
"@stylistic/space-unary-ops": "error",
"@stylistic/spaced-comment": [
"error",
"always",
{
"line": {
"markers": [
"/"
]
}
}
],
"@stylistic/switch-colon-spacing": "error",
"@stylistic/template-curly-spacing": [
"error",
"never"
],
"@stylistic/template-tag-spacing": "error",
"@stylistic/wrap-iife": "error",
"@stylistic/wrap-regex": "off",
"@stylistic/yield-star-spacing": "error"
},
"overrides": [
{
"files": [
"{packages,plugins}/**/*.{ts,tsx}"
],
"rules": {
"no-unused-vars": "off",
"typescript/ban-ts-comment": "error",
"typescript/no-duplicate-enum-values": "error",
"typescript/no-empty-object-type": "error",
"typescript/no-explicit-any": "off",
"typescript/no-extra-non-null-assertion": "error",
"typescript/no-misused-new": "error",
"typescript/no-namespace": "error",
"typescript/no-non-null-asserted-optional-chain": "error",
"typescript/no-require-imports": "off",
"typescript/no-this-alias": "error",
"typescript/no-unnecessary-type-constraint": "error",
"typescript/no-unsafe-declaration-merging": "error",
"typescript/no-unsafe-function-type": "error",
"typescript/no-wrapper-object-types": "error",
"typescript/prefer-as-const": "error",
"typescript/prefer-namespace-keyword": "error",
"typescript/triple-slash-reference": "off",
"no-shadow": [
"error",
{
"allow": [
"Event",
"Request",
"yargs"
]
}
],
"no-useless-constructor": "error"
},
"plugins": [
"typescript"
]
},
{
"files": [
"test/**/*.js",
"plugins/*/test/**/*.js"
],
"rules": {
"prefer-arrow-callback": "off"
},
"env": {
"mocha": true
}
},
{
"files": [
"packages/web_ui/src/**/*.{ts,tsx}",
"packages/lib/browser.ts",
"packages/create/templates/*/web/**/*.{ts,js,tsx,jsx}",
"{packages,plugins}/*/web/**/*.{ts,tsx}"
],
"globals": {
"SpeechRecognitionAlternative": "readonly",
"SpeechRecognitionResult": "readonly",
"SpeechRecognitionResultList": "readonly"
},
"env": {
"browser": true
}
},
{
"files": [
"packages/create/templates/**/*.{js,cjs,ts,cts,jsx,tsx}"
],
"rules": {
"@stylistic/indent": "off",
"@stylistic/spaced-comment": "off",
"@stylistic/comma-spacing": "off",
"@stylistic/max-len": "off"
},
"jsPlugins": [
"@stylistic/eslint-plugin"
]
}
]
}
+1 -1
View File
@@ -18,7 +18,7 @@ Use the detailed guides for the affected area:
- Read the relevant source and tests before changing behavior.
If documentation and implementation disagree, report the discrepancy and distinguish observed behavior from intended policy.
Update affected documentation in the same change.
- Follow nearby code and `eslint.config.mjs`: tabs, double quotes, semicolons, and `.js` extensions in relative TypeScript imports.
- Follow nearby code and `.oxlintrc.json`: tabs, double quotes, semicolons, and `.js` extensions in relative TypeScript imports.
Import shared public APIs through `@clusterio/lib` and check Node/browser exports when changing them.
- Run commands from the root using the pinned pnpm version and the [documented Node requirements](docs/contributing.md#supported-nodejs-and-factorio-versions).
Install with `pnpm install --no-frozen-lockfile`; the lockfile is intentionally ignored.
+1 -1
View File
@@ -49,7 +49,7 @@ async function removeBuildArtifacts() {
tryRemove(path.join(basePath, packageName, "dist"))
);
tasks.push(
tryRemove(path.join(basePath, packageName, "node_modules", ".cache"))
tryRemove(path.join(basePath, packageName, "node_modules", ".vite"))
);
}
}
+1 -1
View File
@@ -103,7 +103,7 @@ Defaults to null meaning assume localhost.
Used to override where static assets such as the web bundle is loaded from.
This can be used if you want to put the static assets on a separate cdn url like `https://cdn.example.com/static/` while keeping the API hosted directly by clusterio.
Due to a limitation on how the webpack assets are built the pathname component of the url have to be `/static/`, values with any other path are rejected.
Due to a limitation on how the web assets are built the pathname component of the url have to be `/static/`, values with any other path are rejected.
See also the `clusteriocontroller copy-static` command for how to obtain the static asset files that need to be hosted.
+5 -6
View File
@@ -42,7 +42,7 @@ Clusterio manages distributed Factorio servers through a controller, hosts, and
| `packages/host/` | Instance lifecycle, Factorio processes, save patching, and exports. Start with `src/Host.ts`, `src/Instance.ts`, `src/server.ts`, and `src/patch.ts`. |
| `packages/ctl/` | Command-line administration and plugin commands. |
| `packages/lib/` | Shared messages, routing, config, permissions, plugin loading, logging, and utilities for Node and browser consumers. |
| `packages/web_ui/` | React/Ant Design interface and browser control connection; bundled through the controller's webpack build. |
| `packages/web_ui/` | React/Ant Design interface and browser control connection; bundled through the controller's Vite build. |
| `packages/create/` | Installer and JavaScript/TypeScript plugin templates. |
| `plugins/` | Bundled plugins, including their Node, web, Lua, and test code. |
| `external_plugins/` | Plugins developed in independent repositories and included in the local workspace. |
@@ -119,13 +119,12 @@ To have TypeScript watch for changes and rebuild immediadly you may run `pnpm wa
For web development on the controller there are also the following flags:
`--dev` - Recompile the `web_ui` package on the fly providing live reloading of it.
`--dev` - Serve the `web_ui` package from source through Vite's development server with hot module replacement.
`--dev-plugin [name]` - Recompile the web module for the given plugin on the fly proving live reloading of it.
`--dev-plugin [name]` - Serve the web module for the given plugin from source the same way.
These options can be combined and multiple plugins can be actived for dev mode.
Keep in mind if you're working on external plugins the `--dev-plugin` option is likely to only work if the plugin is placed inside the `external_plugins` directory and its dependencies are installed with pnpm as part of the clusterio repository workspace.
Note that webpack is configured to delete the web interface build when using these options, so you will have to run `pnpm -r run prepare` before you start the controller without these flags again.
### Build outputs
@@ -138,7 +137,7 @@ Note that webpack is configured to delete the web interface build when using the
| `pnpm build-mod` | Factorio mod archives in root `dist/`. |
| `pnpm docs` | Generated API documentation. |
`pnpm build` alone does not regenerate the standalone validator or webpack bundles.
`pnpm build` alone does not regenerate the standalone validator or web bundles.
After changing `packages/lib/src/data/messages_core.ts`, run `pnpm -r run prepare` to compile the schema and regenerate its validator.
@@ -228,7 +227,7 @@ Inspect `node clean.js --help` and preview the selected scope with `--dry-run` b
| Command | Removal scope |
| --- | --- |
| `node clean.js --dry-run` | Preview workspace package/plugin build output and webpack caches; omit `--dry-run` to clean, or use `pnpm clean`. |
| `node clean.js --dry-run` | Preview workspace package/plugin build output and Vite dependency caches; omit `--dry-run` to clean, or use `pnpm clean`. |
| `node clean.js tests --dry-run` | Also preview root `.nyc_output/`, `coverage/`, the whole `temp/` directory, and `static/`. |
| `node clean.js all --dry-run` | Also preview runtime databases, instances, logs, mods, local configs, `FactorioAdminToken.txt`, and `plugin-list.json`. |
+14 -12
View File
@@ -18,7 +18,7 @@ If there is a particular issue or concern you would like the team to comment on
- [Naming Times and Durations](#naming-times-and-durations)
- [Strings](#strings)
3. Code Style (TS / JS / Web)
- [ESLint](#eslint)
- [Oxlint](#oxlint)
- [Imports Within `lib` Package](#imports-within-lib-package)
- [Name Style](#name-style-ts)
4. Code Style (Lua)
@@ -38,7 +38,7 @@ If there is a particular issue or concern you would like the team to comment on
- [React](#react)
- [Separation of Host and Controller](#separation-of-host-and-controller)
- [Typescript](#typescript)
- [Webpack](#webpack)
- [Vite](#vite)
8. Supportability
- [Breaking Changes](#breaking-changes)
- [Distribution](#distribution)
@@ -157,14 +157,15 @@ Therefore, we selected double quotes as our standard.
## Code Style (TS / JS / Web)
### ESLint
### Oxlint
🧾 Time Immemorial
🧾 20th October 2026
> Use ESLint for style checking.
> Use Oxlint for style checking, with formatting rules from `@stylistic/eslint-plugin`.
We needed to maintain a consistent style across all our files.
Therefore, we utilise the industry-standard ESLint, as it is familiar and highly configurable.
We needed to maintain a consistent style across all our files and used ESLint for it from the start.
Oxlint runs the same rules about three times faster, reads ESLint disable comments, and does not depend on the TypeScript compiler API.
The formatting rules ESLint deprecated run through `@stylistic/eslint-plugin` loaded as an Oxlint JS plugin, so the code style did not change.
### Imports Within `lib` Package
@@ -347,14 +348,15 @@ Transitioning to any other langauge would be too significant an undertaking.
Initially, we supported multiple languages for plugins, including Rust and Go; however, this approach was quickly abandoned due to its complexity.
### Webpack
### Vite
🧾 Time Immemorial
🧾 20th October 2026
> Our web stack uses Webpack and associated plugins.
> Our web stack uses Vite. Plugin web bundles are ES modules that import shared dependencies through an import map.
We required a bundler for our web build, and at the time, Webpack was the standard.
While we have considered alternatives since, transitioning to another solution would be a significant undertaking.
We used Webpack with Module Federation for loading plugins from the start, as it was the standard at the time.
Vite builds the web interface several times faster, its development server updates modules without reloading the page, and plugin bundles no longer depend on one bundler's runtime.
See [#1127](https://github.com/clusterio/clusterio/issues/1127) for the discussion.
## Supportability
+35
View File
@@ -24,3 +24,38 @@ The config errors `lib.InvalidValue`, `lib.InvalidField` and `lib.InvalidAccess`
Errors of classes that are not registered, like a `TypeError` from a bug, are received as `InternalServerError`.
Plugins that define their own error classes need to list them in `errors` of the plugin declaration.
## Build the web module with Vite
The web interface is now built with Vite instead of webpack, and plugin web modules are loaded as ES modules instead of through Module Federation.
A plugin built with webpack fails to load with the error "Incompatible dist/web/manifest.json, the plugin must be rebuilt with Vite".
Delete `webpack.config.cjs` and add a `vite.config.js` next to `package.json`:
```js
import { defineConfig } from "vite";
import { clusterioPlugin } from "@clusterio/web_ui/vite.mjs";
export default defineConfig({
plugins: [clusterioPlugin()],
});
```
In `package.json` replace `webpack-cli --mode production` in the `prepare` script with `vite build`, and replace the `webpack`, `webpack-cli` and `webpack-merge` dev dependencies with `vite`.
Add `vite.config.js` to `.npmignore` if you had `webpack.config.js` there.
`clusterioPlugin` exposes `index.ts` (or `index.js`) as `"./"`, `package.json` as `"./package.json"` and `web/index.tsx` (or `.ts`, `.jsx`, `.js`) as `"./web"`.
These are the same keys the plugin template used with webpack, so `webEntrypoint: "./web"` stays as it is.
If your webpack config exposed other paths, move the code so it is reachable from `web/index.tsx`.
The web interface provides `@clusterio/lib`, `@clusterio/web_ui`, `@sinclair/typebox`, `antd`, `react`, `react-dom`, `react-router` and `react-router-dom` to plugins.
These are left as imports in the plugin bundle and resolved by the browser, there is no need to list them as shared.
Everything else is bundled into the plugin, including `@ant-design/icons`.
Import `react-dom/client` and `react/jsx-runtime` by those names, other subpaths of the shared packages are bundled as a separate copy.
Imports of `assert`, `buffer`, `events`, `os`, `path`, `process`, `stream`, `util` and `zlib` resolve to the same browser packages as before, provided by `@clusterio/web_ui`.
The `container` property of `WebPluginContext` is now the plugin's ES module namespace, `await container.get("./web")` returns the module instead of a factory function.
`lib.PluginWebpackEnvInfo` is renamed to `lib.PluginWebEnvInfo`.
+2 -2
View File
@@ -138,8 +138,8 @@ The following properties are recognized:
Object mapping ctl configuration fields to `FieldDefinition` objects.
**webEntrypoint**:
Optional Module Federation exposure key for the web entrypoint, such as `"./web"`.
It must match the key in the plugin's webpack `exposes` configuration; it is not a Node.js file path.
Optional key of the web entrypoint in the plugin's web bundle, this should be `"./web"`.
The `clusterioPlugin` Vite plugin exposes `web/index.tsx` (or `.ts`, `.jsx`, `.js`) under this key; it is not a Node.js file path.
**migrationEntrypoint**:
Path to a Node.js module relative to the plugin directory which exports the migration entrypoint function for this plugin.
-504
View File
@@ -1,504 +0,0 @@
import {
defineConfig,
globalIgnores,
} from "eslint/config";
import typescriptEslint from "@typescript-eslint/eslint-plugin";
import node from "eslint-plugin-node";
import globals from "globals";
import tsParser from "@typescript-eslint/parser";
import js from "@eslint/js";
import stylistic from "@stylistic/eslint-plugin";
import {
FlatCompat,
} from "@eslint/eslintrc";
const compat = new FlatCompat({
baseDirectory: import.meta.dirname,
recommendedConfig: js.configs.recommended,
allConfig: js.configs.all,
});
export default defineConfig([{
plugins: {
"@typescript-eslint": typescriptEslint,
"@stylistic": stylistic,
node,
},
languageOptions: {
globals: {
...globals.node,
...globals.commonjs,
},
},
"rules": {
"@stylistic/indent": ["error", "tab", {
"ignoredNodes": [
"JSXFragment", // TODO: Linter crashes if this is removed
],
"ignoreComments": true,
"SwitchCase": 1, // Indent case statements one level from switch
}],
"accessor-pairs": "error",
"array-bracket-newline": "off",
"array-bracket-spacing": ["error", "never"],
"array-callback-return": "error",
"array-element-newline": "off",
"arrow-body-style": "error",
"arrow-parens": "off",
"arrow-spacing": ["error", {
"after": true,
"before": true,
}],
"block-scoped-var": "error",
"block-spacing": "error",
"brace-style": ["error", "1tbs", {
"allowSingleLine": true,
}],
"camelcase": "off",
"capitalized-comments": "off",
"class-methods-use-this": "off",
"comma-dangle": ["error", {
"arrays": "always-multiline",
"objects": "always-multiline",
"imports": "always-multiline",
"exports": "always-multiline",
"functions": "only-multiline",
}],
"comma-spacing": "error",
"comma-style": ["error", "last"],
"complexity": ["error", { "max": 25 }],
"computed-property-spacing": ["error", "never"],
"consistent-return": "error",
"consistent-this": "error",
"curly": "error",
"default-case": "error",
"default-case-last": "error",
"default-param-last": "off",
"dot-location": ["error", "property"],
"dot-notation": ["error", {
"allowPattern": "^[a-z]+(_[a-z]+)*$",
}],
"eol-last": "error",
"eqeqeq": "error",
"func-call-spacing": "error",
"func-name-matching": "error",
"func-names": "off",
"func-style": ["error", "declaration", {
"allowArrowFunctions": true,
}],
"function-call-argument-newline": "off",
"function-paren-newline": "off",
"generator-star-spacing": ["error", {
"before": false,
"after": true,
}],
"grouped-accessor-pairs": "error",
"guard-for-in": "error",
"id-blacklist": "error",
"id-length": "off",
"id-match": "error",
"implicit-arrow-linebreak": "error",
"indent-legacy": "off",
"init-declarations": "off",
"jsx-quotes": "error",
"key-spacing": "error",
"keyword-spacing": "error",
"line-comment-position": "off",
"linebreak-style": ["error", "unix"],
"lines-around-comment": "off",
"lines-around-directive": "off",
"lines-between-class-members": ["error", "always", {
"exceptAfterSingleLine": true,
}],
"max-classes-per-file": "off",
"max-depth": "error",
"max-len": ["error", {
"code": 120,
}],
"max-lines": "off",
"max-lines-per-function": "off",
"max-nested-callbacks": "error",
"max-params": "off",
"max-statements": "off",
"max-statements-per-line": "off",
"multiline-comment-style": ["error", "separate-lines"],
"multiline-ternary": "off",
"new-cap": ["error", {
"capIsNewExceptions": ["StringEnum", "StringKey"],
"capIsNewExceptionPattern": "^Type\\.",
"newIsCapExceptions": ["constructor"],
}],
"new-parens": "error",
"newline-after-var": "off",
"newline-before-return": "off",
"newline-per-chained-call": "off",
"no-alert": "error",
"no-array-constructor": "error",
"no-await-in-loop": "off",
"no-bitwise": "off",
"no-buffer-constructor": "error",
"no-caller": "error",
"no-catch-shadow": "off",
"no-confusing-arrow": "error",
"no-console": "error",
"no-constant-condition": ["error", {
"checkLoops": false,
}],
"no-constructor-return": "error",
"no-continue": "off",
"no-div-regex": "error",
"no-duplicate-imports": "error",
"no-else-return": "error",
"no-empty-function": "off",
"no-eq-null": "error",
"no-eval": "error",
"no-extend-native": "error",
"no-extra-bind": "error",
"no-extra-label": "error",
"no-extra-parens": "off",
"no-floating-decimal": "error",
"no-implicit-coercion": "error",
"no-implicit-globals": "error",
"no-implied-eval": "error",
"no-inline-comments": "off",
"no-invalid-this": "error",
"no-iterator": "error",
"no-label-var": "error",
"no-lone-blocks": "error",
"no-lonely-if": "error",
"no-loop-func": "error",
"no-magic-numbers": "off",
"no-mixed-operators": ["error", {
"groups": [
["&", "|", "^", "~", "<<", ">>", ">>>"],
["==", "!=", "===", "!==", ">", ">=", "<", "<="],
["in", "instanceof"],
],
}],
"no-multi-assign": "error",
"no-multi-spaces": "error",
"no-multi-str": "error",
"no-multiple-empty-lines": "error",
"no-native-reassign": "error",
"no-negated-condition": "off",
"no-negated-in-lhs": "error",
"no-nested-ternary": "error",
"no-new": "error",
"no-new-func": "error",
"no-new-object": "error",
"no-new-wrappers": "error",
"no-octal-escape": "error",
"no-param-reassign": "off",
"no-plusplus": ["error", {
"allowForLoopAfterthoughts": true,
}],
"no-proto": "error",
"no-restricted-exports": "error",
"no-restricted-globals": "error",
"no-restricted-imports": "error",
"no-restricted-properties": "error",
"no-restricted-syntax": "error",
"no-return-assign": "error",
"no-return-await": "off",
"no-script-url": "error",
"no-self-compare": "error",
"no-sequences": "error",
"no-shadow": ["error", {
"allow": ["Event", "Request", "yargs"],
}],
"no-spaced-func": "error",
"no-tabs": "off",
"no-template-curly-in-string": "error",
"no-ternary": "off",
"no-throw-literal": "error",
"no-trailing-spaces": "error",
"no-undef-init": "error",
"no-undefined": "off",
"no-unmodified-loop-condition": "error",
"no-unneeded-ternary": "error",
"no-unused-expressions": "error",
"no-use-before-define": "off",
"no-useless-backreference": "error",
"no-useless-call": "error",
"no-useless-computed-key": "error",
"no-useless-concat": "error",
"no-useless-constructor": "off",
"no-useless-rename": "error",
"no-useless-return": "error",
"no-var": "error",
"no-void": ["error", {
"allowAsStatement": true,
}],
"no-warning-comments": "off",
"no-whitespace-before-property": "error",
"nonblock-statement-body-position": "error",
"object-curly-newline": "error",
"object-curly-spacing": "off",
"object-shorthand": "off",
"one-var": "off",
"one-var-declaration-per-line": "error",
"operator-assignment": "off",
"operator-linebreak": ["error", "after", {
"overrides": {
"?": "before",
":": "before",
"||": "before",
"&&": "before",
"??": "before",
},
}],
"padded-blocks": "off",
"padding-line-between-statements": "error",
"prefer-arrow-callback": "error",
"prefer-const": "off",
"prefer-destructuring": "off",
"prefer-exponentiation-operator": "error",
"prefer-named-capture-group": "off",
"prefer-numeric-literals": "error",
"prefer-object-spread": "error",
"prefer-promise-reject-errors": "error",
"prefer-reflect": "off",
"prefer-regex-literals": "error",
"prefer-rest-params": "error",
"prefer-spread": "error",
"prefer-template": "error",
"quote-props": "off",
"quotes": ["error", "double", {
"avoidEscape": true,
}],
"radix": ["error", "always"],
"require-atomic-updates": "off",
"require-await": "off",
"require-jsdoc": "off",
"require-unicode-regexp": "off",
"rest-spread-spacing": ["error", "never"],
"semi": "error",
"semi-spacing": "error",
"semi-style": "off",
"sort-imports": "off",
"sort-keys": "off",
"sort-vars": "off",
"space-before-blocks": "error",
"space-before-function-paren": "off",
"space-in-parens": ["error", "never"],
"space-infix-ops": "off",
"space-unary-ops": "error",
"spaced-comment": ["error", "always", {
"line": {
"markers": ["/"],
},
}],
"switch-colon-spacing": "error",
"symbol-description": "error",
"template-curly-spacing": ["error", "never"],
"template-tag-spacing": "error",
"unicode-bom": ["error", "never"],
"vars-on-top": "error",
"wrap-iife": "error",
"wrap-regex": "off",
"yield-star-spacing": "error",
"yoda": ["error", "never", {
"exceptRange": true,
}],
"node/callback-return": "off",
"node/exports-style": "off",
"node/file-extension-in-import": "off",
"node/global-require": "off",
"node/handle-callback-err": "off",
"node/no-callback-literal": "off",
"node/no-deprecated-api": "off",
"node/no-exports-assign": "off",
"node/no-extraneous-import": "off",
"node/no-extraneous-require": "off",
"node/no-missing-import": "off",
"node/no-missing-require": "off",
"node/no-mixed-requires": "off",
"node/no-new-require": "off",
"node/no-path-concat": "off",
"node/no-process-env": "off",
"node/no-process-exit": "off",
"node/no-restricted-require": "off",
"node/no-sync": "error",
"node/no-unpublished-bin": "off",
"node/no-unpublished-import": "off",
"node/no-unpublished-require": "off",
"node/no-unsupported-features/es-builtins": "off",
"node/no-unsupported-features/es-syntax": "off",
"node/no-unsupported-features/node-builtins": "off",
"node/prefer-global/buffer": "off",
"node/prefer-global/console": "off",
"node/prefer-global/process": "off",
"node/prefer-global/text-decoder": "off",
"node/prefer-global/text-encoder": "off",
"node/prefer-global/url-search-params": "off",
"node/prefer-global/url": "off",
"node/prefer-promises/dns": "off",
"node/prefer-promises/fs": "off",
"node/process-exit-as-throw": "off",
"node/shebang": "off",
},
}, {
files: ["{packages,plugins}/**/*.ts?(x)"],
languageOptions: {
parser: tsParser,
},
extends: compat.extends("plugin:@typescript-eslint/recommended"),
"rules": {
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-useless-constructor": "error",
"@typescript-eslint/no-unused-vars": "off",
"@typescript-eslint/triple-slash-reference": "off",
"@typescript-eslint/no-shadow": ["error", {
"allow": ["Event", "Request", "yargs"],
}],
// Allow require imports where necessary (dynamic loading, webpack configs, etc.)
"@typescript-eslint/no-require-imports": "off",
"prefer-const": "off",
"no-shadow": "off",
},
}, {
files: ["test/**/*.js", "plugins/*/test/**/*.js"],
languageOptions: {
globals: {
...globals.mocha,
},
sourceType: "module",
parserOptions: {},
},
"rules": {
"prefer-arrow-callback": "off",
"no-invalid-this": "off",
"node/no-unpublished-import": "off",
"node/no-unpublished-require": "off",
},
}, {
files: [
"packages/web_ui/src/**/*.ts?(x)",
"packages/lib/browser.ts",
"packages/create/templates/*/web/**/*.{t,j}s?(x)",
"{packages,plugins}/*/web/**/*.ts?(x)",
],
languageOptions: {
globals: {
...globals.browser,
},
"sourceType": "module",
parserOptions: {
"ecmaFeatures": {
"jsx": true,
},
},
},
"rules": {
"node/callback-return": "off",
"node/exports-style": "off",
"node/file-extension-in-import": "off",
"node/global-require": "off",
"node/handle-callback-err": "off",
"node/no-callback-literal": "off",
"node/no-deprecated-api": "off",
"node/no-exports-assign": "off",
"node/no-extraneous-import": "off",
"node/no-extraneous-require": "off",
"node/no-missing-import": "off",
"node/no-missing-require": "off",
"node/no-mixed-requires": "off",
"node/no-new-require": "off",
"node/no-path-concat": "off",
"node/no-process-env": "off",
"node/no-process-exit": "off",
"node/no-restricted-require": "off",
"node/no-sync": "error",
"node/no-unpublished-bin": "off",
"node/no-unpublished-import": "off",
"node/no-unpublished-require": "off",
"node/no-unsupported-features/es-builtins": "off",
"node/no-unsupported-features/es-syntax": "off",
"node/no-unsupported-features/node-builtins": "off",
"node/prefer-global/buffer": "off",
"node/prefer-global/console": "off",
"node/prefer-global/process": "off",
"node/prefer-global/text-decoder": "off",
"node/prefer-global/text-encoder": "off",
"node/prefer-global/url-search-params": "off",
"node/prefer-global/url": "off",
"node/prefer-promises/dns": "off",
"node/prefer-promises/fs": "off",
"node/process-exit-as-throw": "off",
"node/shebang": "off",
},
}, {
files: ["packages/create/templates/**/*.?(c){t,j}s?(x)"],
"rules": {
"node/no-missing-require": "off",
"node/no-missing-import": "off",
"@stylistic/indent": "off",
"spaced-comment": "off",
"comma-spacing": "off",
"max-len": "off",
"indent": "off",
},
}, globalIgnores(["**/dist/", "dist/"])]);
+3 -10
View File
@@ -15,8 +15,8 @@
"cover": "c8 pnpm test",
"fast-cover": "FAST_TEST=y c8 pnpm test",
"ci-cover": "c8 --reporter=lcovonly pnpm test",
"lint": "eslint packages plugins test",
"lint-fix": "eslint --fix packages plugins test",
"lint": "oxlint packages plugins test",
"lint-fix": "oxlint --fix packages plugins test",
"clean": "node ./clean.js",
"docs": "typedoc",
"bundle-dependencies": "bundle-dependencies"
@@ -43,21 +43,14 @@
"@clusterio/host": "workspace:^",
"@clusterio/lib": "workspace:^",
"@clusterio/web_ui": "workspace:^",
"@eslint/eslintrc": "^3.3.7",
"@eslint/js": "^10.0.1",
"@stylistic/eslint-plugin": "^5.10.0",
"@swc/core": "catalog:",
"@types/node": "catalog:",
"c8": "^12.0.0",
"@typescript-eslint/eslint-plugin": "^8.71.2",
"@typescript-eslint/parser": "^8.71.2",
"eslint": "^10.13.0",
"eslint-plugin-node": "^11.1.0",
"express": "catalog:",
"form-data": "^4.0.6",
"globals": "^17.13.0",
"jsonwebtoken": "catalog:",
"mocha": "^11.8.0",
"oxlint": "^1.88.0",
"typedoc": "^0.28.20",
"typescript": "catalog:",
"yargs": "catalog:",
+1 -1
View File
@@ -1,2 +1,2 @@
webpack.config.js
vite.config.js
dist/browser
+4 -6
View File
@@ -25,7 +25,7 @@
"type": "module",
"main": "dist/node/index.js",
"scripts": {
"prepare": "tsc --build && webpack-cli --mode production",
"prepare": "tsc --build && vite build",
"test": "echo \"Error: no test specified\" && exit 1"
},
"bin": {
@@ -51,7 +51,6 @@
},
"devDependencies": {
"@clusterio/web_ui": "workspace:^",
"@swc/core": "catalog:",
"@types/busboy": "^1.5.5",
"@types/compression": "^1.8.1",
"@types/express": "catalog:",
@@ -61,13 +60,12 @@
"@types/semver": "catalog:",
"@types/ws": "catalog:",
"@types/yargs": "catalog:",
"antd": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"react-router": "catalog:",
"react-router-dom": "catalog:",
"typescript": "catalog:",
"webpack": "catalog:",
"webpack-cli": "catalog:",
"webpack-dev-middleware": "^8.3.0",
"webpack-merge": "catalog:"
"vite": "catalog:"
}
}
@@ -107,7 +107,7 @@ export class ControllerHooks extends lib.AsyncHookCollection {
* @param curr - The current value of the field.
* @param prev - The previous value of the field.
*/
// eslint-disable-next-line max-len
// eslint-disable-next-line @stylistic/max-len
readonly instanceConfigFieldChanged: lib.AsyncHook<[instance: InstanceRecord, field: string, curr: unknown, prev: unknown]>;
/**
@@ -165,7 +165,7 @@ export class ControllerHooks extends lib.AsyncHookCollection {
* The connection the event occured on.
* @param event - one of connect, drop, resume and close
*/
// eslint-disable-next-line max-len
// eslint-disable-next-line @stylistic/max-len
readonly hostConnectionEvent: lib.AsyncHook<[connection: HostConnection, event: "connect" | "drop" | "resume" | "close"]>;
/**
@@ -202,7 +202,7 @@ export class ControllerHooks extends lib.AsyncHookCollection {
* The connection the event occured on.
* @param event - one of connect, drop, resume, and close.
*/
// eslint-disable-next-line max-len
// eslint-disable-next-line @stylistic/max-len
readonly controlConnectionEvent: lib.AsyncHook<[connection: ControlConnection, event: "connect" | "drop" | "resume" | "close"]>;
/**
+63 -47
View File
@@ -52,7 +52,7 @@ type ControllerDebugEvents = {
type ControllerEvents = {
"stop": [],
}
};
/**
* Manages all controller related operations
@@ -106,7 +106,7 @@ export default class Controller {
_registeredEvents = new Map<lib.EventClass<unknown>, lib.EventHandler<unknown>>();
_snoopedEvents = new Map();
devMiddleware: any | null = null;
devServer: import("vite").ViteDevServer | null = null;
startedAtMs = Date.now();
autosaveInterval?: ReturnType<typeof setInterval>;
@@ -378,7 +378,7 @@ export default class Controller {
}, 600e3);
logSizeGauge.callback = async () => { logSizeGauge.set(await this.logSize()); };
// Start webpack development server if enabled
// Start Vite development server if enabled
if (args.dev || args.devPlugin) {
await this._startDevServer(args);
}
@@ -416,15 +416,21 @@ export default class Controller {
}
Controller.addAppRoutes(this.app, this.pluginInfos);
if (this.devServer) {
this.app.use(this.devServer.middlewares);
}
if (!args.dev) {
let manifestPath = path.join(import.meta.dirname, "..", "..", "web", "manifest.json");
let manifest = await Controller.loadJsonObject(manifestPath);
if (!manifest["main.js"]) {
logger.error("Missing main.js entry in dist/web/manifest.json");
if (!manifest.main) {
logger.error("Missing main entry in dist/web/manifest.json");
}
this.app.locals.mainBundle = manifest["main.js"] || "no_web_build";
this.app.locals.webBundle = {
main: manifest.main || "no_web_build",
imports: manifest.imports || {},
};
}
this.app.locals.staticRoot = this.config.get("controller.static_url");
@@ -492,37 +498,44 @@ export default class Controller {
}
async _startDevServer(args: ControllerArgs) {
logger.warn("Webpack development mode enabled");
logger.warn("Vite development mode enabled");
const webpack = (await import("webpack")).default;
const webpackDevMiddleware = (await import("webpack-dev-middleware")).default;
const webpackConfigs = [];
const vite = await import("vite");
const webUiVite = await import("@clusterio/web_ui/vite.mjs");
if (args.dev) {
// @ts-expect-error this file is run from a different folder
webpackConfigs.push((await import("../../../webpack.config.cjs")).default({})); // Path outside of build
}
if (args.devPlugin) {
let devPlugins = new Map();
for (let name of args.devPlugin) {
let info = this.pluginInfos.find(i => i.name === name);
if (!info) {
throw new lib.StartupError(`No plugin named ${name}`);
}
const webpackConfig = path.posix.join(info.requirePath, "webpack.config.cjs");
let config = (await import(
path.isAbsolute(webpackConfig) ? pathToFileURL(webpackConfig).href : webpackConfig
)).default({});
devPlugins.set(name, webpackConfigs.length);
webpackConfigs.push(config);
const devPlugins = new Map<string, string>();
const pluginDirs = new Map<string, string>();
for (let name of (args.devPlugin ?? []).map(String)) {
let info = this.pluginInfos.find(i => i.name === name);
if (!info) {
throw new lib.StartupError(`No plugin named ${name}`);
}
this.app.locals.devPlugins = devPlugins;
pluginDirs.set(name, path.dirname(info.packagePath));
devPlugins.set(name, webUiVite.devPluginUrl(name));
}
this.app.locals.devPlugins = devPlugins;
const compiler = webpack(webpackConfigs);
this.devMiddleware = webpackDevMiddleware(compiler, { serverSideRender: true });
this.app.use(this.devMiddleware);
const controllerDir = path.join(import.meta.dirname, "..", "..", "..");
this.devServer = await vite.createServer({
configFile: path.join(controllerDir, "vite.config.js"),
root: controllerDir,
base: "/static/",
appType: "custom",
server: {
middlewareMode: true,
preTransformRequests: false,
},
plugins: [webUiVite.clusterioDevPlugins(pluginDirs)],
});
if (args.dev) {
this.app.locals.webBundle = {
main: this.devServer.config.plugins.find(p => p.name === "clusterio:app")!.api.entry,
imports: Object.fromEntries(webUiVite.sharedModules.map(
name => [name, webUiVite.devSharedUrl(name)]
)),
};
}
this.app.locals.devServer = this.devServer;
}
/**
@@ -573,8 +586,8 @@ export default class Controller {
await this.clusterLogIndex.save();
}
if (this.devMiddleware) {
await new Promise((resolve, reject) => { this.devMiddleware.close(resolve); });
if (this.devServer) {
await this.devServer.close();
}
await this.hooks.shutdown.invoke();
@@ -1357,26 +1370,29 @@ export default class Controller {
let depth = routeDepth + Number(req.path.slice(-1) === "/");
let webRoot = "../".repeat(depth) || "./";
let staticRoot = res.app.locals.staticRoot ?? `${webRoot}static/`;
let mainBundle: string = "";
if (res.app.locals.mainBundle) {
mainBundle = res.app.locals.mainBundle;
} else {
let stats = res.locals.webpack.devMiddleware.stats.stats[0];
mainBundle = stats.toJson().assetsByChunkName["main"][0];
}
mainBundle = routes.stripStaticPrefix(mainBundle);
const { main, imports } = res.app.locals.webBundle;
const importMap = JSON.stringify({
imports: Object.fromEntries(Object.entries(imports).map(
([name, file]) => [name, `${staticRoot}${routes.stripStaticPrefix(file)}`]
)),
}).replace(/</g, "\\u003c");
fs.readFile(
path.join(import.meta.dirname, "..", "..", "..", "web", "index.html"),
"utf8",
).then((content) => {
res.type("text/html");
res.send(content
).then(async (content) => {
content = content
.replace(/__CLUSTER_NAME__/g, res.app.locals.controller.config.get("controller.name"))
.replace(/__WEB_ROOT__/g, webRoot)
.replace(/__STATIC_ROOT__/g, staticRoot)
.replace(/__MAIN_BUNDLE__/g, mainBundle)
);
.replace(/__IMPORT_MAP__/g, () => importMap)
.replace(/__MAIN_BUNDLE__/g, () => routes.stripStaticPrefix(main))
;
if (res.app.locals.devServer) {
content = await res.app.locals.devServer.transformIndexHtml(req.originalUrl, content);
}
res.type("text/html");
res.send(content);
}).catch(err => {
next(err);
});
+9 -12
View File
@@ -20,9 +20,10 @@ declare global {
namespace Express {
export interface Locals {
controller: Controller,
mainBundle: string,
webBundle: { main: string, imports: Record<string, string> },
devServer?: import("vite").ViteDevServer,
staticRoot: string | null,
devPlugins: Map<string, number>,
devPlugins?: Map<string, string>,
streams: Map<string, ProxyStream>,
pendingMetrics?: Promise<string>,
}
@@ -124,7 +125,7 @@ async function getMetrics(req: Request, res: Response) {
/**
* Removes the static/ prefix webpack adds to bundle paths so they can be
* Removes the static/ prefix from bundle paths in manifest.json so they can be
* resolved relative to staticRoot.
*/
export function stripStaticPrefix(bundlePath: string) {
@@ -140,22 +141,18 @@ function getPlugins(req: Request, res: Response) {
// Note: Cast through unknown is needed because load_plugin is
// defined at runtime and no other fields in the controller config
// currently have the boolean type.
let web: { main:any, error:string|undefined } = { main: undefined, error: undefined};
let web: { main: any, error: string|undefined } = { main: undefined, error: undefined};
let devPlugins = req.app.locals.devPlugins;
if (devPlugins && devPlugins.has(name)) {
let stats = res.locals.webpack.devMiddleware.stats.stats[devPlugins.get(name)!];
web.main = stats.toJson().assetsByChunkName[name][0];
web.main = devPlugins.get(name);
} else if (pluginInfo.manifest) {
web.main = pluginInfo.manifest[`${pluginInfo.name}.js`];
web.main = pluginInfo.manifest.main;
if (!web.main) {
web.error = `Missing ${pluginInfo.name}.js entry in manifest.json`;
web.error = "Incompatible dist/web/manifest.json, the plugin must be rebuilt with Vite";
}
} else if (lib.pluginNeedsWebBuild(pluginInfo)) {
web.error = "Missing dist/web/manifest.json";
}
if (web.main === "remoteEntry.js") {
web.error = "Incompatible old remoteEntry.js entrypoint.";
}
if (web.main) {
web.main = stripStaticPrefix(web.main);
}
@@ -308,7 +305,7 @@ type ProxyStreamEvents = {
"close": [],
"timeout": [],
"source": [],
}
};
export interface ProxyStream {
id: string;
+6
View File
@@ -0,0 +1,6 @@
import { defineConfig } from "vite";
import { clusterioApp } from "@clusterio/web_ui/vite.mjs";
export default defineConfig({
plugins: [clusterioApp({ entry: "web/index.tsx" })],
});
+3 -3
View File
@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#2a2a2a" />
<title>__CLUSTER_NAME__</title>
<script type="importmap">__IMPORT_MAP__</script>
<style>
html {
color: #fff;
@@ -26,7 +27,6 @@
<div id="root">
<div id="bootstrap-msg">Loading web interface...</div>
</div>
<script id="script-bundle" src="__STATIC_ROOT____MAIN_BUNDLE__" defer></script>
<script>
webRoot = new URL("__WEB_ROOT__", document.location).pathname;
staticRoot = new URL("__STATIC_ROOT__", document.location).href;
@@ -41,9 +41,9 @@
bootstrapMsg.textContent = `${errorMsg}\nSee web console for details.`;
}
}
const bundleScript = document.getElementById("script-bundle");
bundleScript.addEventListener("error", () => scriptError("Main bundle failed to load."));
window.addEventListener("error", event => scriptError(event.error));
</script>
<script id="script-bundle" type="module" src="__STATIC_ROOT____MAIN_BUNDLE__"
onerror="scriptError('Main bundle failed to load.')"></script>
</body>
</html>
+1
View File
@@ -1,3 +1,4 @@
// eslint-disable-next-line no-implicit-globals
declare function scriptError(err: any): void;
import("@clusterio/web_ui")
.then(webUi => webUi.bootstrap())
-31
View File
@@ -1,31 +0,0 @@
"use strict";
const path = require("path");
const webpack = require("webpack");
const { merge } = require("webpack-merge");
const common = require("@clusterio/web_ui/webpack.common");
module.exports = (env = {}, argv = {}) => merge(common(env, argv), {
context: __dirname,
entry: "./web/index.tsx",
devServer: {
contentBase: "./dist/web",
},
output: {
path: path.resolve(__dirname, "dist", "web"),
},
plugins: [
new webpack.container.ModuleFederationPlugin({
name: "controller",
shared: {
"@clusterio/lib": { singleton: true },
"@clusterio/web_ui": { singleton: true },
"antd": { singleton: true },
"react": { singleton: true },
"react-dom": { singleton: true },
"react-router": { singleton: true },
"react-router-dom": { singleton: true },
},
}),
],
});
+7 -7
View File
@@ -88,7 +88,7 @@ export async function copyPluginTemplates(pluginName, templates) {
const config = !templates.includes("no_config") && templates.some(value => (
["controller", "host", "instance", "ctl"].includes(value)
));
const webpack = config || templates.includes("controller") || templates.includes("web");
const webBuild = config || templates.includes("controller") || templates.includes("web");
// A count of the number of isolated contexts the plugin runs under
let pluginContexts = 0;
@@ -113,15 +113,15 @@ export async function copyPluginTemplates(pluginName, templates) {
files.set("tsconfig.json", path.join(templatePath, "tsconfig.json"));
files.set("tsconfig.node.json", path.join(templatePath, "tsconfig.node.json"));
files.set("tsconfig.base.json", path.join(templatePath, "tsconfig.base.json"));
if (webpack) {
if (webBuild) {
files.set("tsconfig.browser.json", path.join(templatePath, "tsconfig.browser.json"));
}
}
// Files and dependences to support webpack
if (webpack) {
prepare.push("webpack-cli --mode production");
files.set("webpack.config.cjs", path.join(commonPath, "webpack.config.cjs"));
// Files and dependences to support the web build
if (webBuild) {
prepare.push("vite build");
files.set("vite.config.js", path.join(commonPath, "vite.config.js"));
if (templates.includes("web")) {
files.set(`web/index.${ext}x`, path.join(templatePath, `web/plugin.${ext}x`));
pluginContexts += 1;
@@ -185,7 +185,7 @@ export async function copyPluginTemplates(pluginName, templates) {
ctl: templates.includes("ctl"),
web: templates.includes("web"),
// Macro flags for context requirements
webpack: webpack,
web_build: webBuild,
config: config,
// String values for package json
clusterio_version: _package.version,
@@ -67,15 +67,13 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
//%endif
//%if webpack
"webpack": "^5.110.3",
"webpack-cli": "^7.2.3",
"webpack-merge": "^5.9.0",
//%if web_build
"vite": "^8.3.4",
//%endif
//%if controller
"@clusterio/controller": "^__clusterio_version__",
//%endif
//%if webpack
//%if web_build
"@clusterio/web_ui": "^__clusterio_version__",
//%endif
//%if host
@@ -87,7 +85,7 @@
"@clusterio/lib": "^__clusterio_version__"
},
"dependencies": {
"@sinclair/typebox": "^0.30.6"
"@sinclair/typebox": "^0.34.52"
},
"publishConfig": {
"access": "public"
@@ -1,3 +1,3 @@
webpack.config.js
vite.config.js
dist/browser
dist/web
@@ -0,0 +1,6 @@
import { defineConfig } from "vite";
import { clusterioPlugin } from "@clusterio/web_ui/vite.mjs";
export default defineConfig({
plugins: [clusterioPlugin()],
});
@@ -1,34 +0,0 @@
"use strict";
const path = require("path");
const webpack = require("webpack");
const { merge } = require("webpack-merge");
const common = require("@clusterio/web_ui/webpack.common");
module.exports = (env = {}, argv = {}) => merge(common(env, argv), {
context: __dirname,
entry: "./web/index.__ext__x",
output: {
path: path.resolve(__dirname, "dist", "web"),
},
plugins: [
new webpack.container.ModuleFederationPlugin({
name: "__plugin_name__",
library: { type: "window", name: "plugin___plugin_name__" },
exposes: {
"./": "./index.__ext__",
"./package.json": "./package.json",
"./web": "./web/index.__ext__x",
},
shared: {
"@clusterio/lib": { import: false },
"@clusterio/web_ui": { import: false },
//%if web
"antd": { import: false },
"react": { import: false },
"react-dom": { import: false },
//%endif
},
}),
],
});
+1 -1
View File
@@ -2,7 +2,7 @@ import * as lib from "@clusterio/lib";
export async function getEditor(argsEditor: string) {
// eslint-disable-next-line
return argsEditor || process.env.EDITOR || process.env.VISUAL || undefined
return argsEditor || process.env.EDITOR || process.env.VISUAL || undefined;
// needed for the process.env statements to not be flagged by eslint
// priority for editors is CLI argument > env.EDITOR > env.VISUAL
}
+1
View File
@@ -1,3 +1,4 @@
import "./src/browser_polyfills";
export * from "./src/api";
export * from "./src/AsyncHook";
export * from "./src/SyncHook";
+1 -1
View File
@@ -6,5 +6,5 @@ export interface BuildArgs {
build?: boolean;
outputDir: string;
sourceDir: string;
};
}
export function build(args: BuildArgs): Promise<void>;
+2
View File
@@ -264,7 +264,9 @@ export class LockFile {
// Event handler for synchronous process exit, readLock can't be used as it is async
private handleExitSync = (): void => {
try {
// eslint-disable-next-line node/no-sync
if (readFileSync(this.filePath, "utf8") === LockFile.content) {
// eslint-disable-next-line node/no-sync
unlinkSync(this.filePath);
}
} catch (err) {
+1 -1
View File
@@ -14,4 +14,4 @@ export type PluginWebApi = {
* package.
*/
npmPackage?: string;
}
};
+9
View File
@@ -0,0 +1,9 @@
// Node.js globals used by lib and its dependencies when running in a browser.
import process from "process";
import { Buffer } from "buffer";
// Vite's development server may already have created process.env
if (typeof globalThis.process?.nextTick !== "function") {
globalThis.process = Object.assign(process, globalThis.process);
}
globalThis.Buffer ??= Buffer as any;
+8 -8
View File
@@ -24,7 +24,7 @@ type OldConfigGroupSchema = Static<typeof OldConfigGroupSchema>;
const OldConfigSchema = Type.Object({
"groups": Type.Array(OldConfigGroupSchema),
});
export type OldConfigSchema = Static<typeof OldConfigSchema>
export type OldConfigSchema = Static<typeof OldConfigSchema>;
export const ConfigSchema = Type.Record(Type.String(), Type.Union([
Type.Null(),
@@ -38,11 +38,11 @@ export type ConfigSchema = Static<typeof ConfigSchema>;
type FieldValue = null | boolean | string | number | object;
type FieldType<T extends FieldValue> =
T extends boolean ? "boolean" :
T extends string ? "string" :
T extends number ? "number" :
T extends object ? "object" :
never;
T extends boolean ? "boolean"
: T extends string ? "string"
: T extends number ? "number"
: T extends object ? "object"
: never;
export interface FieldDefinition<
Type extends FieldValue = any,
@@ -134,7 +134,7 @@ export type ConfigDefs<
Fields extends Record<keyof Fields, FieldValue>
> = {
[Prop in keyof Fields]: FieldDefinition<Fields[Prop], Fields>;
}
};
function defaultValue(def: FieldDefinition) {
if (def.initialValue === undefined) {
@@ -148,7 +148,7 @@ function defaultValue(def: FieldDefinition) {
export type ConfigEvents = {
fieldChanged: [ name: string, curr: FieldValue, prev: FieldValue ],
}
};
export enum ConfigAccess {
read = 0x1,
+4 -4
View File
@@ -1,7 +1,7 @@
// Core definitions for the configuration system
import * as classes from "./classes.js";
import * as validators from "./validators.js";
import type { PluginNodeEnvInfo, PluginWebpackEnvInfo } from "../plugin.js";
import type { PluginNodeEnvInfo, PluginWebEnvInfo } from "../plugin.js";
import { Static } from "@sinclair/typebox";
import { isTargetVersion } from "../data/version.js";
import { SyncHook } from "../SyncHook.js";
@@ -151,7 +151,7 @@ export class ControllerConfig extends classes.Config<ControllerConfigFields> {
optional: true,
validator: (value) => {
if (value) {
// This is unfortunately a limitiation of how the webpack build is set up right now.
// This is unfortunately a limitiation of how the web build is set up right now.
const pathname = new URL(value, "http://localhost/").pathname;
if (pathname !== "/static/") {
throw new Error(`Path component of static_url must be /static/ not ${pathname}.`);
@@ -786,7 +786,7 @@ function validateFieldNames(
* Add the config fields defined by the by a specific plugin infos
*/
export function addPluginFieldDefinitions(
pluginInfo: PluginNodeEnvInfo | PluginWebpackEnvInfo,
pluginInfo: PluginNodeEnvInfo | PluginWebEnvInfo,
kind: "controllerConfigFields" | "hostConfigFields" | "instanceConfigFields",
Config: typeof classes.Config<any>
) {
@@ -858,7 +858,7 @@ export function addPluginFieldDefinitions(
*
* @param {Array<Object>} pluginInfos - Array of plugin info objects.
*/
export function addPluginConfigFields(pluginInfos: PluginNodeEnvInfo[] | PluginWebpackEnvInfo[]) {
export function addPluginConfigFields(pluginInfos: PluginNodeEnvInfo[] | PluginWebEnvInfo[]) {
for (let pluginInfo of pluginInfos) {
addPluginFieldDefinitions(pluginInfo, "controllerConfigFields", ControllerConfig);
if (pluginInfo.hostEntrypoint || pluginInfo.instanceEntrypoint) {
+2 -2
View File
@@ -15,8 +15,8 @@ export abstract class DatastoreProvider<
K extends DatastoreKey,
V extends DatastoreValue,
> {
abstract save(data: Map<K, V>): Promise<void>
abstract load(): Promise<Map<K, V>>
abstract save(data: Map<K, V>): Promise<void>;
abstract load(): Promise<Map<K, V>>;
}
// Store all data in memory, used as a default and should be avoided
+6 -6
View File
@@ -35,7 +35,7 @@ export type FactorioModSettingType = "startup" | "runtime-global" | "runtime-per
* Boolean mod setting prototype.
* @see [bool-setting](https://wiki.factorio.com/Tutorial:Mod_settings#bool-setting)
*/
export interface FactorioBoolSettingPrototype extends FactorioPrototypeBase{
export interface FactorioBoolSettingPrototype extends FactorioPrototypeBase {
type: "bool-setting",
setting_type?: FactorioModSettingType,
default_value: boolean,
@@ -46,7 +46,7 @@ export interface FactorioBoolSettingPrototype extends FactorioPrototypeBase{
* Integer mod setting prototype.
* @see [int-setting](https://wiki.factorio.com/Tutorial:Mod_settings#int-setting)
*/
export interface FactorioIntSettingPrototype extends FactorioPrototypeBase{
export interface FactorioIntSettingPrototype extends FactorioPrototypeBase {
type: "int-setting",
setting_type?: FactorioModSettingType,
default_value: number,
@@ -59,7 +59,7 @@ export interface FactorioIntSettingPrototype extends FactorioPrototypeBase{
* Real number mod setting prototype.
* @see [double-setting](https://wiki.factorio.com/Tutorial:Mod_settings#double-setting)
*/
export interface FactorioDoubleSettingPrototype extends FactorioPrototypeBase{
export interface FactorioDoubleSettingPrototype extends FactorioPrototypeBase {
type: "double-setting",
setting_type?: FactorioModSettingType,
default_value: number,
@@ -72,7 +72,7 @@ export interface FactorioDoubleSettingPrototype extends FactorioPrototypeBase{
* String mod setting prototype.
* @see [string-setting](https://wiki.factorio.com/Tutorial:Mod_settings#string-setting)
*/
export interface FactorioStringSettingPrototype extends FactorioPrototypeBase{
export interface FactorioStringSettingPrototype extends FactorioPrototypeBase {
type: "string-setting",
setting_type?: FactorioModSettingType,
default_value: string,
@@ -85,7 +85,7 @@ export interface FactorioStringSettingPrototype extends FactorioPrototypeBase{
* Color mod setting prototype.
* @see [color-setting](https://wiki.factorio.com/Tutorial:Mod_settings#color-setting)
*/
export interface FactorioColorSettingPrototype extends FactorioPrototypeBase{
export interface FactorioColorSettingPrototype extends FactorioPrototypeBase {
type: "color-setting",
setting_type?: FactorioModSettingType,
default_value: FactorioColor,
@@ -104,7 +104,7 @@ export type FactorioModSettingPrototype =
* 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 FactorioPrototypes<Prototype = FactorioPrototypeBase> = Record<string, Record<string, Prototype>>;
export type FactorioDefinesValue = number | string | { [index: string]: FactorioDefinesValue };
export type FactorioDefines = {
[index: string]: FactorioDefinesValue,
+2 -2
View File
@@ -12,7 +12,7 @@ export const ExternalFactorioVersionSchema = Type.Object({
headlessUrl: Type.String(),
});
export type ExternalFactorioVersion = Static<typeof ExternalFactorioVersionSchema>
export type ExternalFactorioVersion = Static<typeof ExternalFactorioVersionSchema>;
/**
* Fetch all factorio versions that support headless
@@ -47,7 +47,7 @@ function parseFactorioVersions(html: string): ExternalFactorioVersion[] {
const versions = new Map<string, ExternalFactorioVersion>();
// Match <a class=" slot-button-inline version-button-stable " href="/download/archive/2.0.72">2.0.72</a>
// eslint-disable-next-line max-len
// eslint-disable-next-line @stylistic/max-len
const anchorRegex = /<a[^>]*class="[^"]*version-button-(?<kind>stable|experimental)[^"]*"[^>]*href="[^"]*\/download\/archive\/(?<version>\d+\.\d+.\d+)"[^>]*>/g;
let match: RegExpExecArray | null;
+1 -1
View File
@@ -15,7 +15,7 @@ export const LatestReleaseBuildsSchema = Type.Record(Type.String(), FullVersionS
*/
export const LatestReleasesSchema = Type.Record(Type.String(), LatestReleaseBuildsSchema);
export type LatestReleases = Static<typeof LatestReleasesSchema>
export type LatestReleases = Static<typeof LatestReleasesSchema>;
/**
* Fetch the latest stable and experimental factorio releases
+2 -2
View File
@@ -6,7 +6,7 @@ import * as libSchema from "../schema.js";
import { dataClasses, errorClasses } from "./messages.js";
import { BaseConnector, WebSocketBaseConnector } from "./connectors.js";
import { strict as assert } from "assert";
import type { PluginNodeEnvInfo, PluginWebpackEnvInfo } from "../plugin.js";
import type { PluginNodeEnvInfo, PluginWebEnvInfo } from "../plugin.js";
import type {
AddressType, JSONDeserialisable, MessageRoutable, MessageRequest, MessageEvent, IUser,
} from "../data/index.js";
@@ -895,7 +895,7 @@ for (let Class of errorClasses) {
Link.registerError(Class);
}
export function registerPluginMessages(pluginInfos: PluginNodeEnvInfo[]|PluginWebpackEnvInfo[]) {
export function registerPluginMessages(pluginInfos: PluginNodeEnvInfo[]|PluginWebEnvInfo[]) {
for (let pluginInfo of pluginInfos) {
for (let Class of pluginInfo.messages || []) {
if (Class.plugin !== pluginInfo.name) {
+3 -3
View File
@@ -177,9 +177,9 @@ export const logger = winston.createLogger({
// Who in their right mind thought that log levels should be hard coded
// into the type when you can define custom levels you want to support?
export type Logger = Omit<winston.Logger,
"error" | "warn" | "help" | "data" | "info" | "debug" | "prompt" | "verbose" | "input" | "silly" |
"emerg" | "alert" | "crit" | "warning" | "notice" |
"child"
"error" | "warn" | "help" | "data" | "info" | "debug" | "prompt" | "verbose" | "input" | "silly"
| "emerg" | "alert" | "crit" | "warning" | "notice"
| "child"
> & {
fatal: winston.LeveledLogMethod,
error: winston.LeveledLogMethod,
+2 -2
View File
@@ -6,7 +6,7 @@
* @author Hornwitser
* @module lib/users
*/
import type { PluginNodeEnvInfo, PluginWebpackEnvInfo } from "./plugin.js";
import type { PluginNodeEnvInfo, PluginWebEnvInfo } from "./plugin.js";
import { Permission } from "./data/index.js";
export const permissions = new Map<string, Permission>();
@@ -88,7 +88,7 @@ export function definePermission({
*
* @param pluginInfos - Array of plugin info objects.
*/
export function registerPluginPermissions(pluginInfos: PluginNodeEnvInfo[] | PluginWebpackEnvInfo[]) {
export function registerPluginPermissions(pluginInfos: PluginNodeEnvInfo[] | PluginWebEnvInfo[]) {
for (const pluginInfo of pluginInfos) {
for (const permission of pluginInfo.permissions ?? []) {
if (typeof permission.name !== "string" || !permission.name.startsWith(`${pluginInfo.name}.`)) {
+2 -2
View File
@@ -46,7 +46,7 @@ export type PluginDeclaration = {
errors?: ErrorClass[];
permissions?: PermissionDefinition[];
routes?: string[];
}
};
/**
* Check if a plugin is expected to ship a web build.
@@ -91,7 +91,7 @@ export type PluginNodeEnvInfo = PluginDeclaration & {
skippedNpmPackage?: string;
};
export type PluginWebpackEnvInfo = PluginDeclaration & {
export type PluginWebEnvInfo = PluginDeclaration & {
container?: any;
package?: any;
enabled?: boolean;
+1 -1
View File
@@ -175,7 +175,7 @@ export async function handlePluginCommand(
try {
const pluginPackage = (await import(
pathToFileURL(path.posix.join(pluginPath, "package.json")).href,
{ with: { type: "json" }},
{ with: { type: "json" }}
)).default;
pluginInfo = (await import(
pathToFileURL(path.posix.join(pluginPath, pluginPackage.main ?? "index.js")).href
+2 -2
View File
@@ -7,7 +7,7 @@ import { SessionLost, ValidationError } from "./errors.js";
import { logger } from "./logging.js";
export type SubscriptionRequestHandler<T> = RequestHandler<SubscriptionRequest, Event<T> | null>;
export type EventSubscriberCallback<T> = (event: T | null, synced: boolean) => void
export type EventSubscriberCallback<T> = (event: T | null, synced: boolean) => void;
/**
* Represents a set of string subscription filters.
@@ -241,7 +241,7 @@ type Subscriber = {
link: Link,
address: Address,
filters: SubscriptionFilters,
}
};
type EventData = {
subscriptionUpdate?: SubscriptionRequestHandler<unknown>,
+1
View File
@@ -9,6 +9,7 @@
"./src/config/*.ts",
"./src/data/*.ts",
"./src/external/*.ts",
"./src/browser_polyfills.ts",
"./src/check_singleton_import.ts",
"./src/datastore.ts",
"./src/errors.ts",
-3
View File
@@ -22,6 +22,3 @@ declare module "*.md" {
declare const webRoot: string;
/** Path static assets are served under, ends with a slash. */
declare const staticRoot: string;
declare const __webpack_init_sharing__: (shareScope: string) => Promise<void>;
declare const __webpack_share_scopes__: { default: any };
+4 -12
View File
@@ -33,19 +33,15 @@
},
"dependencies": {
"@ant-design/icons": "^5.6.1",
"@ant-design/icons": "catalog:",
"@clusterio/lib": "workspace:^",
"@sinclair/typebox": "catalog:",
"@swc/core": "catalog:",
"@vitejs/plugin-react": "catalog:",
"antd": "catalog:",
"assert": "^2.1.0",
"browserify-zlib": "^0.2.0",
"buffer": "^6.0.3",
"clean-webpack-plugin": "^4.0.0",
"css-loader": "^7.1.5",
"events": "^3.3.0",
"file-loader": "^6.2.0",
"null-loader": "^4.0.1",
"os-browserify": "^0.3.0",
"path-browserify": "^1.0.1",
"process": "^0.11.10",
@@ -53,17 +49,13 @@
"react": "catalog:",
"react-dom": "catalog:",
"react-markdown": "catalog:",
"react-router": "catalog:",
"react-router-dom": "catalog:",
"semver": "catalog:",
"stream-browserify": "^3.0.0",
"style-loader": "^4.0.0",
"swc-loader": "^0.2.7",
"terser-webpack-plugin": "^5.6.1",
"typescript": "catalog:",
"util": "^0.12.5",
"webpack": "catalog:",
"webpack-cli": "catalog:",
"webpack-manifest-plugin": "^6.0.1"
"vite": "catalog:"
},
"devDependencies": {
"@types/node": "catalog:",
+2 -2
View File
@@ -144,7 +144,7 @@ export type WebPluginContext = lib.PluginLoadContext<{
control: Control;
container: any;
package: any;
}, lib.PluginWebpackEnvInfo>;
}, lib.PluginWebEnvInfo>;
/**
* Collection of web plugin hooks
@@ -258,7 +258,7 @@ export class BaseWebPlugin {
constructor(
public container: any,
packageData: any,
public info: lib.PluginWebpackEnvInfo,
public info: lib.PluginWebEnvInfo,
public control: Control,
public logger: lib.Logger,
) {
+9 -28
View File
@@ -16,22 +16,7 @@ import { pages } from "./pages";
const { ConsoleTransport, WebConsoleFormat, logger } = lib;
async function loadScript(url: string) {
let script = document.createElement("script");
script.src = url;
script.type = "text/javascript";
script.async = true;
let result = new Promise((resolve, reject) => {
script.onload = resolve;
script.onerror = reject;
});
document.head.appendChild(script);
return result;
}
async function loadPluginInfos(): Promise<[lib.PluginWebpackEnvInfo[], string]> {
async function loadPluginInfos(): Promise<[lib.PluginWebEnvInfo[], string]> {
let response = await fetch(`${webRoot}api/plugins`);
let pluginList: lib.PluginWebApi[];
if (response.ok) {
@@ -43,8 +28,7 @@ async function loadPluginInfos(): Promise<[lib.PluginWebpackEnvInfo[], string]>
}
let pluginSetKey = loadedPluginSetKey(pluginList);
let pluginInfos: lib.PluginWebpackEnvInfo[] = [];
await __webpack_init_sharing__("default");
let pluginInfos: lib.PluginWebEnvInfo[] = [];
for (let meta of pluginList) {
if (meta.web.error) {
logger.error(`Failed to load plugin ${meta.name}: ${meta.web.error}`);
@@ -54,15 +38,13 @@ async function loadPluginInfos(): Promise<[lib.PluginWebpackEnvInfo[], string]>
continue;
}
try {
await loadScript(`${staticRoot}${meta.web.main}`);
let container: any = (window as { [key: string]: any })[`plugin_${meta.name}`];
if (!container) {
throw new Error(`Plugin did not expose its container via plugin_${meta.name}`);
let container = await import(/* @vite-ignore */ `${staticRoot}${meta.web.main}`);
if (typeof container.get !== "function") {
throw new Error("Plugin web bundle does not export get");
}
await container.init(__webpack_share_scopes__.default);
let pluginInfo = (await container.get("./"))().plugin;
let pluginInfo = (await container.get("./")).plugin;
pluginInfo.container = container;
pluginInfo.package = (await container.get("./package.json"))();
pluginInfo.package = (await container.get("./package.json")).default;
pluginInfo.enabled = meta.enabled;
pluginInfos.push(pluginInfo);
@@ -83,7 +65,7 @@ async function loadPluginInfos(): Promise<[lib.PluginWebpackEnvInfo[], string]>
}
async function loadPlugins(
pluginInfos: lib.PluginWebpackEnvInfo[],
pluginInfos: lib.PluginWebEnvInfo[],
control: Control
) {
const plugins = new Map<string, WebPlugin.BaseWebPlugin>();
@@ -94,8 +76,7 @@ async function loadPlugins(
}
try {
const moduleFactory = await pluginInfo.container.get(pluginInfo.webEntrypoint);
const webModule = moduleFactory();
const webModule = await pluginInfo.container.get(pluginInfo.webEntrypoint);
const pluginContext: WebPlugin.WebPluginContext = {
control,
+1 -1
View File
@@ -157,7 +157,7 @@ npm init "@clusterio" -- --controller-token ${token} --mode "host" --download-he
</>;
}
function CopyButton({ text, message }: { text:string, message:string }) {
function CopyButton({ text, message }: { text: string, message: string }) {
let [clipboardPermision, setClipboardPermission] = useState<PermissionState>("granted");
useEffect(() => {
(async () => {
@@ -264,7 +264,7 @@ type DownloadDependenciesProps = {
mods: lib.ModRecord[];
onChange: (change: ModChange) => void;
builtInModNames: string[];
}
};
function DownloadDependenciesButton(props: DownloadDependenciesProps) {
const control = useContext(ControlContext);
const [modInfos] = useMods();
@@ -904,7 +904,7 @@ type SettingsTableFieldProps = {
allowedValues: any;
locale: Map<string, any>;
changed: boolean;
}
};
const SettingsTableField = memo((props: SettingsTableFieldProps) => {
const name = props.name;
const isBoolean = props.type === "bool-setting";
@@ -47,8 +47,8 @@ function PluginMachines(props: PluginMachinesProps) {
type ControllerPluginProps = {
pluginMeta: PluginWebApi,
pluginInfo?: lib.PluginWebpackEnvInfo,
loadedPlugin?: lib.PluginWebpackEnvInfo,
pluginInfo?: lib.PluginWebEnvInfo,
loadedPlugin?: lib.PluginWebEnvInfo,
title: string,
};
@@ -71,8 +71,8 @@ function ControllerPlugin(props: ControllerPluginProps) {
}}
message={pluginError}
description={
"The web interface was unable to load the webpack module for this plugin. This is "+
"usually due to an incorrect or missing webpack build for the plugin. Due to the "+
"The web interface was unable to load the web module for this plugin. This is "+
"usually due to an incorrect or missing web build for the plugin. Due to the "+
"module not being loaded, the configs and web controls defined by this plugin may "+
"not be available in the web interface."
}
@@ -3,7 +3,7 @@ import { Space, Table, Typography } from "antd";
import CloseCircleFilled from "@ant-design/icons/CloseCircleFilled";
import InfoCircleFilled from "@ant-design/icons/InfoCircleFilled";
import type { PluginWebApi, PluginWebpackEnvInfo } from "@clusterio/lib";
import type { PluginWebApi, PluginWebEnvInfo } from "@clusterio/lib";
import notify from "../util/notify";
import { useAccount } from "../model/account";
@@ -28,7 +28,7 @@ type PluginRow = {
title: string;
/** Not present for plugins the controller hasn't loaded */
meta?: PluginWebApi;
info?: PluginWebpackEnvInfo;
info?: PluginWebEnvInfo;
package?: any;
status?: PluginStatus;
};
+2 -2
View File
@@ -25,7 +25,7 @@ type ModalProps = {
disabled: boolean;
save: lib.SaveDetails;
instanceId: number;
}
};
function RenameModal(props: ModalProps) {
let control = useContext(ControlContext);
@@ -198,7 +198,7 @@ type File = {
name: string;
percent: number;
status: string;
}
};
export default function SavesList(props: { instance: lib.InstanceDetails }) {
let account = useAccount();
+1 -1
View File
@@ -65,7 +65,7 @@ type UserBulkActionProps = {
setApplyAction(func: () => Promise<void>): void,
form: FormInstance,
restore?: boolean,
}
};
function UserBulkActionImport({ setApplyAction, form, restore }: UserBulkActionProps) {
const control = useContext(ControlContext);
+2 -2
View File
@@ -54,7 +54,7 @@ export class Control extends lib.Link {
public hooks = new WebPlugin.WebHooks(logger);
/** @deprecated Only holds plugins using the class export, use loadedPlugins and hooks instead */
public plugins = new Map<string, WebPlugin.BaseWebPlugin>();
public loadedPlugins = new Map<string, lib.PluginWebpackEnvInfo>();
public loadedPlugins = new Map<string, lib.PluginWebEnvInfo>();
public inputComponents = new Map<string, WebPlugin.InputComponent>();
public extensionComponents: Partial<WebPlugin.ExtensionComponents> = {};
public loginForms: WebPlugin.PluginLoginForm[] = [];
@@ -85,7 +85,7 @@ export class Control extends lib.Link {
constructor(
connector: ControlConnector,
public pluginInfos = new Map<string, lib.PluginWebpackEnvInfo>(),
public pluginInfos = new Map<string, lib.PluginWebEnvInfo>(),
/** Key of the plugin set this interface was loaded against */
public pluginSetKey = "",
) {
+9
View File
@@ -0,0 +1,9 @@
import type { Plugin, PluginOption } from "vite";
export const sharedModules: string[];
export function devUrl(id: string): string;
export function devSharedUrl(name: string): string;
export function devPluginUrl(name: string): string;
export function clusterioApp(options: { entry: string }): PluginOption[];
export function clusterioDevPlugins(pluginDirs: Map<string, string>): Plugin;
export function clusterioPlugin(options?: { root?: string }): PluginOption[];
+387
View File
@@ -0,0 +1,387 @@
// Vite configuration shared by the controller web interface and plugins.
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import util from "node:util";
import { createRequire } from "node:module";
import { execFile } from "node:child_process";
import { fileURLToPath } from "node:url";
import react from "@vitejs/plugin-react";
import { normalizePath } from "vite";
/**
* Modules the web interface provides to plugins at runtime. Plugins must not
* bundle their own copy of these, they are imported through the import map.
*/
export const sharedModules = [
"@clusterio/lib",
"@clusterio/web_ui",
"@sinclair/typebox",
"antd",
"react",
"react/jsx-runtime",
"react-dom",
"react-dom/client",
"react-router",
"react-router-dom",
];
/** Our own packages, these are ESM and differ between Node.js and the browser. */
const esmSharedModules = new Set(["@clusterio/lib", "@clusterio/web_ui"]);
// Only the console transport of winston works in the browser, and the colour
// library used by it does not.
const stubbedModules = /node_modules\/(winston\/dist\/winston\/transports\/(http|file)\.js|@colors\/colors\/)/;
// Browser versions of Node.js modules, resolved from web_ui so lib and plugins
// don't need them as dependencies.
const polyfills = {
assert: "assert/",
buffer: "buffer/",
events: "events/",
os: "os-browserify/browser",
path: "path-browserify",
process: "process/browser.js",
stream: "stream-browserify",
util: "util/",
zlib: "browserify-zlib",
};
const webUiPackage = fileURLToPath(new URL("./package.json", import.meta.url));
const libPackage = createRequire(webUiPackage).resolve("@clusterio/lib/package.json");
const sharedPrefix = "\0clusterio:shared/";
const pluginPrefix = "\0clusterio:plugin/";
/** URL of a virtual module on the Vite development server relative to its base. */
export function devUrl(id) {
return `@id/${id.replace("\0", "__x00__")}`;
}
export function devSharedUrl(name) {
return devUrl(sharedPrefix + name);
}
export function devPluginUrl(name) {
return devUrl(pluginPrefix + name);
}
async function getCommitHash() {
try {
const { stdout } = await util.promisify(execFile)("git", ["rev-parse", "--short", "HEAD"]);
return stdout.trim();
} catch {
return "unknown";
}
}
async function findFile(dir, candidates) {
for (const file of candidates) {
try {
await fs.access(path.join(dir, file));
return file;
} catch {
// Try the next one
}
}
return undefined;
}
// node_modules directories Node.js would search for packages imported from dir.
function nodeModulesDirs(dir) {
const dirs = [];
for (let current = dir; ; current = path.dirname(current)) {
if (path.basename(current) !== "node_modules") {
dirs.push(path.join(current, "node_modules"));
}
if (current === path.dirname(current)) {
return dirs;
}
}
}
function chunkName(name) {
return name.replace(/^@/, "").replace(/[/]/g, "_");
}
// Lists the named exports of a CommonJS package so they can be re-exported
// statically, export * from a CommonJS module only forwards default.
function exportNames(name, root) {
if (esmSharedModules.has(name)) {
return null;
}
const mod = createRequire(path.join(root, "package.json"))(name);
const names = Object.keys(mod).filter(key => key !== "__esModule" && /^[A-Za-z_$][\w$]*$/.test(key));
const isModule = Object.prototype.toString.call(mod) === "[object Module]";
if (!isModule && !mod.__esModule && !names.includes("default")) {
names.push("default");
}
return names;
}
function sharedModuleCode(name, root) {
const names = exportNames(name, root);
if (!names) {
return `export * from ${JSON.stringify(name)};\n`;
}
return `export { ${names.join(", ")} } from ${JSON.stringify(name)};\n`;
}
async function pluginEntryCode(pluginDir) {
const resolve = file => JSON.stringify(normalizePath(path.join(pluginDir, file)));
const exposes = {
"./": await findFile(pluginDir, ["index.ts", "index.js"]),
"./package.json": "package.json",
"./web": await findFile(pluginDir, ["web/index.tsx", "web/index.ts", "web/index.jsx", "web/index.js"]),
};
if (!exposes["./"]) {
throw new Error(`No index.ts or index.js found in ${pluginDir}`);
}
const entries = Object.entries(exposes)
.filter(([, file]) => file)
.map(([key, file]) => `\t${JSON.stringify(key)}: () => import(${resolve(file)}),`);
return [
"const modules = {",
...entries,
"};",
"export async function get(key) {",
"\tif (!Object.hasOwn(modules, key)) {",
"\t\tthrow new Error(\"Module \" + key + \" is not exposed by this plugin\");",
"\t}",
"\treturn await modules[key]();",
"}",
"",
].join("\n");
}
// Replaces modules that do not work in the browser with an empty module.
// Also passed to the dependency optimizer as it does not run Vite plugins.
const stubs = {
name: "clusterio:stubs",
load(id) {
if (stubbedModules.test(normalizePath(id))) {
return "module.exports = {};\n";
}
return null;
},
};
const polyfillResolver = {
name: "clusterio:polyfills",
resolveId(id, _importer, options) {
const target = polyfills[id.replace(/^node:/, "")];
if (!target) {
return null;
}
return this.resolve(target, webUiPackage, { ...options, skipSelf: true });
},
};
/** Base config needed to build code that imports @clusterio/lib for the browser. */
function browserCompat() {
return {
name: "clusterio:browser-compat",
enforce: "pre",
async config(_config, { command }) {
const buildEnv = process.env.NODE_ENV ?? (command === "build" ? "production" : "dev");
return {
base: command === "build" ? "./" : undefined,
define: {
"global": "globalThis",
"process.env.APP_ENV": JSON.stringify("browser"),
"process.env.COMMIT_HASH": JSON.stringify(await getCommitHash()),
"process.env.BUILD_DATE": JSON.stringify(new Date().toISOString()),
"process.env.BUILD_ENV": JSON.stringify(buildEnv),
"process.env.BUILD_OS": JSON.stringify(os.platform()),
},
optimizeDeps: {
rolldownOptions: { plugins: [polyfillResolver, stubs] },
},
resolve: {
alias: [{ find: /^node:(.*)$/, replacement: "$1" }],
},
build: {
target: "es2022",
sourcemap: true,
assetsDir: "static",
chunkSizeWarningLimit: 2048,
rolldownOptions: {
// Message and permission registration relies on class names.
output: {
minify: {
compress: { keepNames: { class: true, function: false } },
mangle: { keepNames: { class: true, function: false } },
},
},
},
},
};
},
resolveId: polyfillResolver.resolveId,
load: stubs.load,
transform(code, id) {
if (id.endsWith(".md")) {
return { code: `export default ${JSON.stringify(code)};\n`, map: null };
}
return null;
},
};
}
function writeManifest(manifest) {
return {
type: "asset",
fileName: "manifest.json",
source: `${JSON.stringify(manifest, null, "\t")}\n`,
};
}
/**
* Vite plugins for building the web interface served by the controller.
*
* Builds `entry` together with one entry per shared module and writes
* manifest.json listing the main bundle and the import map for plugins.
*/
export function clusterioApp({ entry }) {
let root;
return [
browserCompat(),
react(),
{
name: "clusterio:app",
api: { entry },
config(config) {
root = normalizePath(path.resolve(config.root ?? process.cwd()));
return {
optimizeDeps: {
entries: [entry],
include: sharedModules.filter(name => !esmSharedModules.has(name)),
},
build: {
outDir: "dist/web",
emptyOutDir: true,
rolldownOptions: {
input: {
main: path.posix.join(root, entry),
...Object.fromEntries(sharedModules.map(name => [
`shared-${chunkName(name)}`, sharedPrefix + name,
])),
},
preserveEntrySignatures: "strict",
},
},
};
},
resolveId(id) {
if (id.startsWith(sharedPrefix)) {
return id;
}
return null;
},
load(id) {
if (id.startsWith(sharedPrefix)) {
return sharedModuleCode(id.slice(sharedPrefix.length), root);
}
return null;
},
generateBundle(_options, bundle) {
const entries = new Map();
for (const chunk of Object.values(bundle)) {
if (chunk.type === "chunk" && chunk.isEntry) {
entries.set(chunk.facadeModuleId, chunk.fileName);
}
}
this.emitFile(writeManifest({
main: entries.get(path.posix.join(root, entry)),
imports: Object.fromEntries(sharedModules.map(
name => [name, entries.get(sharedPrefix + name)]
)),
}));
},
},
];
}
/**
* Vite plugin serving plugins from source on the controller's development
* server, given as a map of plugin name to plugin directory.
*/
export function clusterioDevPlugins(pluginDirs) {
return {
name: "clusterio:dev-plugins",
config(config) {
const root = path.resolve(config.root ?? process.cwd());
const sources = [root, path.dirname(webUiPackage), path.dirname(libPackage), ...pluginDirs.values()];
return {
server: {
// Vite's default is the workspace root, which may be the data directory.
fs: { allow: [...sources, ...sources.flatMap(nodeModulesDirs)] },
},
optimizeDeps: {
entries: [...pluginDirs.values()].flatMap(
dir => [`${dir}/index.{ts,js}`, `${dir}/web/**/*.{ts,tsx,js,jsx}`]
),
},
};
},
resolveId(id) {
if (id.startsWith(pluginPrefix)) {
return id;
}
return null;
},
load(id) {
if (id.startsWith(pluginPrefix)) {
const dir = pluginDirs.get(id.slice(pluginPrefix.length));
return dir ? pluginEntryCode(dir) : null;
}
return null;
},
};
}
/**
* Vite plugins for building the web bundle of a Clusterio plugin.
*
* The bundle exposes index, package.json and web/index from the plugin
* directory. Modules in sharedModules are left as bare imports and resolved
* by the web interface's import map at runtime.
*/
export function clusterioPlugin({ root = process.cwd() } = {}) {
const entryId = "\0clusterio:plugin-entry";
return [
browserCompat(),
react(),
{
name: "clusterio:plugin",
async config() {
const pkg = JSON.parse(await fs.readFile(path.join(root, "package.json"), "utf8"));
const name = pkg.name.replace(/^@[^/]+\/(plugin-)?/, "");
return {
build: {
outDir: "dist/web",
emptyOutDir: true,
rolldownOptions: {
input: { [name]: entryId },
external: sharedModules,
preserveEntrySignatures: "strict",
},
},
};
},
resolveId(id) {
if (id === entryId) {
return id;
}
return null;
},
load(id) {
if (id === entryId) {
return pluginEntryCode(root);
}
return null;
},
generateBundle(_options, bundle) {
const chunk = Object.values(bundle).find(c => c.type === "chunk" && c.facadeModuleId === entryId);
this.emitFile(writeManifest({ main: chunk.fileName }));
},
},
];
}
-182
View File
@@ -1,182 +0,0 @@
"use strict";
const os = require("os");
const { execSync } = require("child_process");
const webpack = require("webpack");
const { CleanWebpackPlugin } = require("clean-webpack-plugin");
const { WebpackManifestPlugin } = require("webpack-manifest-plugin");
const TerserPlugin = require("terser-webpack-plugin");
function getCommitHash() {
try {
return execSync("git rev-parse --short HEAD").toString().trim();
} catch {
return "unknown";
}
}
module.exports = (env = {}, argv = {}) => ({
mode: argv.mode ?? "development",
devtool: argv.mode === "production" ? "source-map" : "eval-source-map",
cache: { type: "filesystem" },
performance: {
maxAssetSize: 2**21,
maxEntrypointSize: 2**21,
},
output: {
publicPath: "auto",
assetModuleFilename: "static/[hash][ext][query]",
filename: "static/[name].[contenthash].js",
},
plugins: [
new CleanWebpackPlugin(),
new WebpackManifestPlugin({ publicPath: "" }),
// required for winston
new webpack.ProvidePlugin({
process: require.resolve("process/browser.js"),
Buffer: [require.resolve("buffer/"), "Buffer"],
}),
new webpack.DefinePlugin({
"process.env.APP_ENV": JSON.stringify("browser"),
"process.env.COMMIT_HASH": JSON.stringify(getCommitHash()),
"process.env.BUILD_DATE": JSON.stringify(new Date().toISOString()),
"process.env.BUILD_ENV": JSON.stringify(process.env.NODE_ENV ?? "dev"),
"process.env.BUILD_OS": JSON.stringify(os.platform()),
}),
// Make sure ant-design icons use the ES variant
new webpack.NormalModuleReplacementPlugin(
/@ant-design\/icons\/[A-Z]/,
resource => {
resource.request = resource.request.replace("@ant-design\/icons", "$&/es/icons");
}
),
// Strip node: prefix in request paths.
new webpack.NormalModuleReplacementPlugin(/^node:/, resource => {
resource.request = resource.request.replace(/^node:/, "");
}),
],
module: {
rules: [
{
test: /node_modules.winston.dist.winston.transports.(http|file)/,
use: require.resolve("null-loader"),
},
// Colour library used by winston which doesn't work in browser.
{
test: /node_modules.colors/,
use: require.resolve("null-loader"),
},
{
test: /\.css$/,
use: [
require.resolve("style-loader"),
{
loader: require.resolve("css-loader"),
options: {
sourceMap: argv.mode !== "production",
},
},
],
},
{
test: /\.tsx?$/,
exclude: /node_modules/,
use: {
loader: require.resolve("swc-loader"),
options: {
sourceMaps: true,
jsc: {
parser: {
syntax: "typescript",
tsx: true,
},
target: "es2022",
transform: {
react: {
runtime: "automatic",
},
},
},
},
},
},
{
test: /\.jsx?$/,
exclude: /node_modules/,
use: {
loader: require.resolve("swc-loader"),
options: {
sourceMaps: true,
jsc: {
parser: {
syntax: "ecmascript",
jsx: true,
},
target: "es2022",
transform: {
react: {
runtime: "automatic",
},
},
},
},
},
},
{
test: /\.png$/,
type: "asset/resource",
},
{
test: /\.svg$/,
type: "asset/resource",
},
{
test: /\.md$/,
type: "asset/source",
},
],
},
resolve: {
extensions: [".js", ".ts", ".json", ".jsx", ".tsx"],
extensionAlias: {
".js": [".ts", ".js"],
},
fallback: {
"crypto": false,
"fs": false,
"fs/promises": false,
// Required for winston
"util": require.resolve("util/"),
"os": require.resolve("os-browserify/browser"),
"buffer": require.resolve("buffer/"),
// Required for zlib
"assert": require.resolve("assert/"),
"stream": require.resolve("stream-browserify"),
"events": require.resolve("events/"),
"path": require.resolve("path-browserify"),
"zlib": require.resolve("browserify-zlib"),
},
},
optimization: {
moduleIds: "deterministic",
minimizer: [
new TerserPlugin({
minify: TerserPlugin.swcMinify,
terserOptions: {
compress: {
keep_classnames: true,
},
mangle: {
keep_classnames: true,
},
},
}),
],
},
});
+1 -1
View File
@@ -1,2 +1,2 @@
webpack.config.js
vite.config.js
dist/browser
+2 -4
View File
@@ -26,7 +26,7 @@
"type": "module",
"main": "dist/node/index.js",
"scripts": {
"prepare": "tsc --build && webpack-cli --mode production",
"prepare": "tsc --build && vite build",
"test": "echo \"Error: run tests from root\" && exit 1"
},
"engines": {
@@ -57,8 +57,6 @@
"@types/node": "catalog:",
"@types/react": "catalog:",
"typescript": "catalog:",
"webpack": "catalog:",
"webpack-cli": "catalog:",
"webpack-merge": "catalog:"
"vite": "catalog:"
}
}
+6
View File
@@ -0,0 +1,6 @@
import { defineConfig } from "vite";
import { clusterioPlugin } from "@clusterio/web_ui/vite.mjs";
export default defineConfig({
plugins: [clusterioPlugin()],
});
-28
View File
@@ -1,28 +0,0 @@
"use strict";
const path = require("path");
const webpack = require("webpack");
const { merge } = require("webpack-merge");
const common = require("@clusterio/web_ui/webpack.common");
module.exports = (env = {}, argv = {}) => merge(common(env, argv), {
context: __dirname,
entry: "./web/index.tsx",
output: {
path: path.resolve(__dirname, "dist", "web"),
},
plugins: [
new webpack.container.ModuleFederationPlugin({
name: "global_chat",
library: { type: "var", name: "plugin_global_chat" },
exposes: {
"./": "./index.ts",
"./package.json": "./package.json",
},
shared: {
"@clusterio/lib": { import: false },
"@clusterio/web_ui": { import: false },
},
}),
],
});
+1 -1
View File
@@ -1,2 +1,2 @@
webpack.config.js
vite.config.js
dist/browser
+1 -1
View File
@@ -9,7 +9,7 @@ type IpcPlayerAuth = {
type: "set_verify_code",
player: string,
verify_code: string,
}
};
export default async function loadInstancePlugin(context: InstancePluginContext) {
const { instance, host, logger, plugin } = context;
+2 -4
View File
@@ -26,7 +26,7 @@
"type": "module",
"main": "dist/node/index.js",
"scripts": {
"prepare": "tsc --build && webpack-cli --mode production",
"prepare": "tsc --build && vite build",
"test": "echo \"Error: run tests from root\" && exit 1"
},
"engines": {
@@ -64,8 +64,6 @@
"react": "catalog:",
"react-dom": "catalog:",
"typescript": "catalog:",
"webpack": "catalog:",
"webpack-cli": "catalog:",
"webpack-merge": "catalog:"
"vite": "catalog:"
}
}
+6
View File
@@ -0,0 +1,6 @@
import { defineConfig } from "vite";
import { clusterioPlugin } from "@clusterio/web_ui/vite.mjs";
export default defineConfig({
plugins: [clusterioPlugin()],
});
-32
View File
@@ -1,32 +0,0 @@
"use strict";
const path = require("path");
const webpack = require("webpack");
const { merge } = require("webpack-merge");
const common = require("@clusterio/web_ui/webpack.common");
module.exports = (env = {}, argv = {}) => merge(common(env, argv), {
context: __dirname,
entry: "./web/index.tsx",
output: {
path: path.resolve(__dirname, "dist", "web"),
},
plugins: [
new webpack.container.ModuleFederationPlugin({
name: "player_auth",
library: { type: "var", name: "plugin_player_auth" },
exposes: {
"./": "./index.ts",
"./package.json": "./package.json",
"./web": "./web/index.tsx",
},
shared: {
"@clusterio/lib": { import: false },
"@clusterio/web_ui": { import: false },
"antd": { import: false },
"react": { import: false },
"react-dom": { import: false },
},
}),
],
});
+1 -1
View File
@@ -1,2 +1,2 @@
webpack.config.js
vite.config.js
dist/browser
+4 -4
View File
@@ -10,12 +10,12 @@ import { type Component, enabledComponents, stripPlayerData } from "./components
type IpcPlayerName = {
player_name: string
}
};
type IpcDownloadRequest = {
player_name: string,
recipe_notifications?: string,
}
};
/** Components as an escaped JSON object for the scenario. */
function componentsJson(enabled: Set<Component>) {
@@ -30,7 +30,7 @@ type IpcAcquireResponse = {
message?: string,
epoch?: number,
token?: string,
}
};
/**
* Splits string into array of strings with max of a certain length
@@ -64,7 +64,7 @@ export type RecipeNotificationDelta = {
remove?: string[],
/** add is the whole stored list, sent without the instance's: the save works out what to remove */
full?: true,
}
};
/**
* Compute the changes needed to turn the current cleared recipe list into the stored one
+1 -1
View File
@@ -30,7 +30,7 @@ export type IpcPlayerData = {
// Components the upload was serialized with, all when missing
components?: Record<string, boolean> | unknown[],
destination_instance_id?: number, // set_destination's instance, on the leave upload only; not player data
}
};
// Marks tombstones in player_sync.json
const jsonReserved = { deleted: Type.Optional(Type.Never()) };
+3 -5
View File
@@ -26,7 +26,7 @@
"type": "module",
"main": "dist/node/index.js",
"scripts": {
"prepare": "tsc --build && webpack-cli --mode production",
"prepare": "tsc --build && vite build",
"test": "echo \"Error: run tests from root\" && exit 1"
},
"engines": {
@@ -51,7 +51,7 @@
"@sinclair/typebox": "catalog:"
},
"devDependencies": {
"@ant-design/icons": "^5.6.1",
"@ant-design/icons": "catalog:",
"@clusterio/controller": "workspace:^",
"@clusterio/ctl": "workspace:^",
"@clusterio/host": "workspace:^",
@@ -63,8 +63,6 @@
"react": "catalog:",
"react-dom": "catalog:",
"typescript": "catalog:",
"webpack": "catalog:",
"webpack-cli": "catalog:",
"webpack-merge": "catalog:"
"vite": "catalog:"
}
}
+6
View File
@@ -0,0 +1,6 @@
import { defineConfig } from "vite";
import { clusterioPlugin } from "@clusterio/web_ui/vite.mjs";
export default defineConfig({
plugins: [clusterioPlugin()],
});
-32
View File
@@ -1,32 +0,0 @@
"use strict";
const path = require("path");
const webpack = require("webpack");
const { merge } = require("webpack-merge");
const common = require("@clusterio/web_ui/webpack.common");
module.exports = (env = {}, argv = {}) => merge(common(env, argv), {
context: __dirname,
entry: "./web/index.tsx",
output: {
path: path.resolve(__dirname, "dist", "web"),
},
plugins: [
new webpack.container.ModuleFederationPlugin({
name: "player_sync",
library: { type: "var", name: "plugin_player_sync" },
exposes: {
"./": "./index.ts",
"./package.json": "./package.json",
"./web": "./web/index.tsx",
},
shared: {
"@clusterio/lib": { import: false },
"@clusterio/web_ui": { import: false },
"antd": { import: false },
"react": { import: false },
"react-dom": { import: false },
},
}),
],
});
+1 -1
View File
@@ -1,2 +1,2 @@
webpack.config.js
vite.config.js
dist/browser
+1 -1
View File
@@ -22,7 +22,7 @@ type Technology = {
level: number,
progress: number | null,
researched: boolean,
}
};
async function loadTechnologies(
+2 -4
View File
@@ -26,7 +26,7 @@
"type": "module",
"main": "dist/node/index.js",
"scripts": {
"prepare": "tsc --build && webpack-cli --mode production",
"prepare": "tsc --build && vite build",
"test": "echo \"Error: run tests from root\" && exit 1"
},
"engines": {
@@ -57,8 +57,6 @@
"@types/node": "catalog:",
"@types/react": "catalog:",
"typescript": "catalog:",
"webpack": "catalog:",
"webpack-cli": "catalog:",
"webpack-merge": "catalog:"
"vite": "catalog:"
}
}
+4 -4
View File
@@ -145,21 +145,21 @@ describe("research_sync controller", function() {
});
describe("technology sync unlocking several levels", function() {
const techSync = (name, level, researched) => mock.getHandler(controller, SyncTechnologiesRequest)(
const syncTech = (name, level, researched) => mock.getHandler(controller, SyncTechnologiesRequest)(
new SyncTechnologiesRequest([new TechnologySync(name, level, null, researched)]), src
);
it("should drop one entry per level unlocked on a known technology", async function() {
await techSync("m", 4, true);
await syncTech("m", 4, true);
await update(["m", "m", "m", "x"]);
sent.length = 0;
await techSync("m", 7, true);
await syncTech("m", 7, true);
assert.deepEqual(await sync(), ["x"]);
assert.equal(queueEvents().length, 1);
assert.deepEqual(queueEvents()[0][1], new QueueEvent(["x"], null, null));
});
it("should drop one entry per level unlocked on a new technology", async function() {
await update(["m", "m", "x"]);
await techSync("m", 2, true);
await syncTech("m", 2, true);
assert.deepEqual(await sync(), ["x"]);
});
});
+6
View File
@@ -0,0 +1,6 @@
import { defineConfig } from "vite";
import { clusterioPlugin } from "@clusterio/web_ui/vite.mjs";
export default defineConfig({
plugins: [clusterioPlugin()],
});
-28
View File
@@ -1,28 +0,0 @@
"use strict";
const path = require("path");
const webpack = require("webpack");
const { merge } = require("webpack-merge");
const common = require("@clusterio/web_ui/webpack.common");
module.exports = (env = {}, argv = {}) => merge(common(env, argv), {
context: __dirname,
entry: "./web/index.tsx",
output: {
path: path.resolve(__dirname, "dist", "web"),
},
plugins: [
new webpack.container.ModuleFederationPlugin({
name: "research_sync",
library: { type: "var", name: "plugin_research_sync" },
exposes: {
"./": "./index.ts",
"./package.json": "./package.json",
},
shared: {
"@clusterio/lib": { import: false },
"@clusterio/web_ui": { import: false },
},
}),
],
});
+1 -1
View File
@@ -1,2 +1,2 @@
webpack.config.js
vite.config.js
dist/browser
+2 -2
View File
@@ -26,12 +26,12 @@ type IpcStats = {
weight: number,
}
}
}
};
type IpcFlowType = {
"input": Record<string, number>,
"output": Record<string, number>,
}
};
const instancePlayerCount = new Gauge(
"clusterio_statistics_exporter_instance_player_count",
+2 -4
View File
@@ -26,7 +26,7 @@
"type": "module",
"main": "dist/node/index.js",
"scripts": {
"prepare": "tsc --build && webpack-cli --mode production",
"prepare": "tsc --build && vite build",
"test": "echo \"Error: run tests from root\" && exit 1"
},
"engines": {
@@ -52,8 +52,6 @@
"@types/node": "catalog:",
"@types/react": "catalog:",
"typescript": "catalog:",
"webpack": "catalog:",
"webpack-cli": "catalog:",
"webpack-merge": "catalog:"
"vite": "catalog:"
}
}
@@ -0,0 +1,6 @@
import { defineConfig } from "vite";
import { clusterioPlugin } from "@clusterio/web_ui/vite.mjs";
export default defineConfig({
plugins: [clusterioPlugin()],
});
@@ -1,28 +0,0 @@
"use strict";
const path = require("path");
const webpack = require("webpack");
const { merge } = require("webpack-merge");
const common = require("@clusterio/web_ui/webpack.common");
module.exports = (env = {}, argv = {}) => merge(common(env, argv), {
context: __dirname,
entry: "./web/index.tsx",
output: {
path: path.resolve(__dirname, "dist", "web"),
},
plugins: [
new webpack.container.ModuleFederationPlugin({
name: "statistics_exporter",
library: { type: "var", name: "plugin_statistics_exporter" },
exposes: {
"./": "./index.ts",
"./package.json": "./package.json",
},
shared: {
"@clusterio/lib": { import: false },
"@clusterio/web_ui": { import: false },
},
}),
],
});
+1 -1
View File
@@ -1,2 +1,2 @@
webpack.config.js
vite.config.js
dist/browser
+4 -2
View File
@@ -78,7 +78,9 @@ export class NeuralDole {
}
}
divider(object: { name:string, quality:string, count:number, instanceId:number, instanceName:string }): number {
divider(
object: { name: string, quality: string, count: number, instanceId: number, instanceName: string }
): number {
let magicData = doleNN.dose(
object.count, // numReq
this.items.getItemCount(object.name, object.quality) || 0,
@@ -118,7 +120,7 @@ export function doleDivider(
logItemTransfers,
logger,
} :{
object: { name:string, quality:string, count:number, instanceId:number, instanceName:string },
object: { name: string, quality: string, count: number, instanceId: number, instanceName: string },
items: lib.ItemDatabase,
logItemTransfers: boolean,
logger: lib.Logger,
+2 -4
View File
@@ -26,7 +26,7 @@
"type": "module",
"main": "dist/node/index.js",
"scripts": {
"prepare": "tsc --build && webpack-cli --mode production",
"prepare": "tsc --build && vite build",
"test": "echo \"Error: run tests from root\" && exit 1"
},
"engines": {
@@ -61,8 +61,6 @@
"react": "catalog:",
"react-dom": "catalog:",
"typescript": "catalog:",
"webpack": "catalog:",
"webpack-cli": "catalog:",
"webpack-merge": "catalog:"
"vite": "catalog:"
}
}
+6
View File
@@ -0,0 +1,6 @@
import { defineConfig } from "vite";
import { clusterioPlugin } from "@clusterio/web_ui/vite.mjs";
export default defineConfig({
plugins: [clusterioPlugin()],
});
@@ -1,32 +0,0 @@
"use strict";
const path = require("path");
const webpack = require("webpack");
const { merge } = require("webpack-merge");
const common = require("@clusterio/web_ui/webpack.common");
module.exports = (env = {}, argv = {}) => merge(common(env, argv), {
context: __dirname,
entry: "./web/index.tsx",
output: {
path: path.resolve(__dirname, "dist", "web"),
},
plugins: [
new webpack.container.ModuleFederationPlugin({
name: "subspace_storage",
library: { type: "var", name: "plugin_subspace_storage" },
exposes: {
"./": "./index.ts",
"./package.json": "./package.json",
"./web": "./web/index.tsx",
},
shared: {
"@clusterio/lib": { import: false },
"@clusterio/web_ui": { import: false },
"antd": { import: false },
"react": { import: false },
"react-dom": { import: false },
},
}),
],
});
+4 -7
View File
@@ -6,8 +6,8 @@ packages:
- test/
catalog:
'@ant-design/icons': ^5.6.1
'@sinclair/typebox': ^0.34.52
'@swc/core': ^1.16.18
'@types/express': ^5.0.6
'@types/finalhandler': ^1.2.4
'@types/jsonwebtoken': ^9.0.10
@@ -19,6 +19,7 @@ catalog:
'@types/yargs': ^17.0.35
'@types/yauzl': ^3.4.0
'@types/yazl': ^3.3.1
'@vitejs/plugin-react': ^6.1.2
antd: ^5.29.3
chalk: ^6.0.1
express: ^5.3.0
@@ -26,13 +27,12 @@ catalog:
react: ^18.3.1
react-dom: ^18.3.1
react-markdown: ^10.1.0
react-router: ^7.18.4
react-router-dom: ^7.18.4
semver: ^7.8.5
set-blocking: ^2.0.0
typescript: ^5.9.3
webpack: ^5.112.0
webpack-cli: ^7.2.3
webpack-merge: 5.9.0
vite: ^8.3.4
winston: ^3.19.0
winston-daily-rotate-file: ^5.0.0
winston-transport: ^4.9.0
@@ -47,7 +47,4 @@ autoInstallPeers: false
linkWorkspacePackages: true
allowBuilds:
'@swc/core': true
shellEmulator: true
+6 -1
View File
@@ -181,7 +181,10 @@ describe("controller/src/Controller", function() {
before(async function() {
const app = express();
app.locals.controller = { config: { get: () => "Test Cluster" } };
app.locals.mainBundle = "static/main.abc.js";
app.locals.webBundle = {
main: "static/main.abc.js",
imports: { "react": "static/shared-react.abc.js" },
};
for (let route of ["/", "/instances/:id/view"]) {
app.get(route, Controller.serveWeb(route));
}
@@ -199,12 +202,14 @@ describe("controller/src/Controller", function() {
assert(html.includes("src=\"./static/main.abc.js\""));
assert(html.includes("new URL(\"./\", document.location)"));
assert(html.includes("new URL(\"./static/\", document.location)"));
assert(html.includes('{"imports":{"react":"./static/shared-react.abc.js"}}'));
});
it("should use relative roots for nested routes", async function() {
let html = await (await fetch(`http://localhost:${port}/instances/1/view`)).text();
assert(html.includes("src=\"../../static/main.abc.js\""));
assert(html.includes("new URL(\"../../\", document.location)"));
assert(html.includes("new URL(\"../../static/\", document.location)"));
assert(html.includes('{"imports":{"react":"../../static/shared-react.abc.js"}}'));
html = await (await fetch(`http://localhost:${port}/instances/1/view/`)).text();
assert(html.includes("src=\"../../../static/main.abc.js\""));
assert(html.includes("new URL(\"../../../static/\", document.location)"));
+5 -4
View File
@@ -157,7 +157,7 @@ describe("controller/src/routes", function() {
it("should strip static/ from the plugin bundle path", async function() {
controller.pluginInfos = [{
name: "foo", version: "1.0.0", npmPackage: "foo",
manifest: { "foo.js": "static/foo.abc.js" },
manifest: { main: "static/foo.abc.js" },
}];
controller.loadedPlugins = new Set(controller.pluginInfos);
let response = await fetch(endpoint);
@@ -171,15 +171,16 @@ describe("controller/src/routes", function() {
controller.pluginInfos = [
{ name: "foo", version: "1.0.0", npmPackage: "foo", webEntrypoint: "dist/web/index.js" },
{ name: "bar", version: "1.0.0", npmPackage: "bar", manifest: {} },
{ name: "baz", version: "1.0.0", npmPackage: "baz", manifest: { "baz.js": "remoteEntry.js" } },
{ name: "baz", version: "1.0.0", npmPackage: "baz", manifest: { "baz.js": "static/baz.abc.js" } },
];
controller.loadedPlugins = new Set([controller.pluginInfos[0]]);
let response = await fetch(endpoint);
assert.equal(response.status, 200);
let data = await response.json();
assert.equal(data[0].web.error, "Missing dist/web/manifest.json");
assert.equal(data[1].web.error, "Missing bar.js entry in manifest.json");
assert.equal(data[2].web.error, "Incompatible old remoteEntry.js entrypoint.");
const incompatible = "Incompatible dist/web/manifest.json, the plugin must be rebuilt with Vite";
assert.equal(data[1].web.error, incompatible);
assert.equal(data[2].web.error, incompatible);
assert.equal(data[1].loaded, false);
assert.equal(data[1].enabled, false);
});
+1 -1
View File
@@ -181,7 +181,7 @@ describe("host/server", function() {
let port = hostServer._randomDynamicPort();
assert.equal(typeof port, "number");
assert(Number.isInteger(port));
assert(0 <= port && port < 2**16);
assert(port >= 0 && port < 2**16);
});
it("should return a port number in the dynamic range", function() {
+1 -1
View File
@@ -1313,7 +1313,7 @@ describe("Integration of Clusterio", function() {
} else if (isV2) {
exchangeString = testStrings.modified_v2;
}
// eslint-disable-next-line max-len
// eslint-disable-next-line @stylistic/max-len
const args = `base/freeplay --seed 1234 --map-exchange-string "${exchangeString.replace(/[\n\r]+/g, "")}"`;
await execCtl("instance stop 44");