Build the web interface and plugins with Vite

Replace webpack and Module Federation with Vite 8. The controller build
emits one entry per shared module (lib, web_ui, typebox, antd, react,
react-dom, react-router) and lists them in manifest.json. The controller
turns that into an import map in index.html, and plugins are built as
ES modules that import the shared modules by name and are loaded with
import().

The shared Vite setup lives in @clusterio/web_ui/vite.mjs. Plugins use
clusterioPlugin(), which exposes index, package.json and web/index under
the same keys the webpack template used. The --dev and --dev-plugin
flags now run the Vite dev server in middleware mode with hot module
replacement.

Browser polyfills move from web_ui to lib, which is the code that needs
them, and lib sets the process and Buffer globals itself.

Refs #1127

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bbassieandClaude Opus 5.5 committed 2026-10-27 22:52:10 +00:00
1 parent 35125a28ab
commit 3227179613
57 files changed
+616 -620

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 }}-
+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"))
);
}
}
+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`. |
+7 -6
View File
@@ -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)
@@ -347,14 +347,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
+34
View File
@@ -24,3 +24,37 @@ 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.
Vite does not provide Node.js modules to plugin code.
If your web code imports `events`, `path` or similar, add the browser package for it (`events`, `path-browserify`) to your dependencies.
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.
+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.
-1
View File
@@ -46,7 +46,6 @@
"@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",
+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:"
}
}
+63 -46
View File
@@ -106,7 +106,7 @@ export default class Controller {
_registeredEvents = new Map<lib.EventClass<unknown>, lib.EventHandler<unknown>>();
_snoopedEvents = new Map();
devMiddleware: any | null = null;
viteServer: 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.viteServer) {
this.app.use(this.viteServer.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,45 @@ 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.viteServer = await vite.createServer({
configFile: path.join(controllerDir, "vite.config.js"),
root: controllerDir,
base: "/static/",
appType: "custom",
server: {
middlewareMode: true,
preTransformRequests: false,
fs: { allow: [vite.searchForWorkspaceRoot(controllerDir), ...pluginDirs.values()] },
},
plugins: [webUiVite.clusterioDevPlugins(pluginDirs)],
});
if (args.dev) {
this.app.locals.webBundle = {
main: this.viteServer.config.plugins.find(p => p.name === "clusterio:app")!.api.entry,
imports: Object.fromEntries(webUiVite.sharedModules.map(
name => [name, webUiVite.devSharedUrl(name)]
)),
};
}
this.app.locals.viteServer = this.viteServer;
}
/**
@@ -573,8 +587,8 @@ export default class Controller {
await this.clusterLogIndex.save();
}
if (this.devMiddleware) {
await new Promise((resolve, reject) => { this.devMiddleware.close(resolve); });
if (this.viteServer) {
await this.viteServer.close();
}
await this.hooks.shutdown.invoke();
@@ -1357,26 +1371,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.viteServer) {
content = await res.app.locals.viteServer.transformIndexHtml(req.originalUrl, content);
}
res.type("text/html");
res.send(content);
}).catch(err => {
next(err);
});
+7 -10
View File
@@ -20,9 +20,10 @@ declare global {
namespace Express {
export interface Locals {
controller: Controller,
mainBundle: string,
webBundle: { main: string, imports: Record<string, string> },
viteServer?: 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) {
@@ -143,19 +144,15 @@ function getPlugins(req: Request, res: Response) {
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);
}
+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>
-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
@@ -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
View File
@@ -1,3 +1,4 @@
import "./src/browser_polyfills";
export * from "./src/api";
export * from "./src/AsyncHook";
export * from "./src/SyncHook";
+9
View File
@@ -54,10 +54,19 @@
"@types/yauzl": "catalog:",
"@types/yazl": "catalog:",
"ajv": "^8.20.0",
"assert": "^2.1.0",
"browserify-zlib": "^0.2.0",
"buffer": "^6.0.3",
"chalk": "catalog:",
"events": "^3.3.0",
"fast-deep-equal": "^3.1.3",
"os-browserify": "^0.3.0",
"path-browserify": "^1.0.1",
"process": "^0.11.10",
"set-blocking": "catalog:",
"stream-browserify": "^3.0.0",
"triple-beam": "^1.4.1",
"util": "^0.12.5",
"winston": "catalog:",
"winston-transport": "catalog:",
"ws": "catalog:",
+10
View File
@@ -0,0 +1,10 @@
// Node.js globals used by lib and its dependencies when running in a browser.
// @ts-expect-error process has no type declarations
import process from "process/browser.js";
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;
+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 -20
View File
@@ -33,37 +33,21 @@
},
"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",
"rc-field-form": "^1.44.0",
"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:",
+6 -25
View File
@@ -16,21 +16,6 @@ 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]> {
let response = await fetch(`${webRoot}api/plugins`);
let pluginList: lib.PluginWebApi[];
@@ -44,7 +29,6 @@ async function loadPluginInfos(): Promise<[lib.PluginWebpackEnvInfo[], string]>
let pluginSetKey = loadedPluginSetKey(pluginList);
let pluginInfos: lib.PluginWebpackEnvInfo[] = [];
await __webpack_init_sharing__("default");
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);
@@ -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,
@@ -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."
}
+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[];
+351
View File
@@ -0,0 +1,351 @@
// 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 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\/)/;
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;
}
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;
},
};
/** Base config needed to build code that imports @clusterio/lib for the browser. */
function browserCompat() {
const alias = (find, replacement) => ({ find: new RegExp(`^(node:)?${find}$`), replacement });
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: [stubs] },
},
resolve: {
alias: [
alias("assert", "assert/"),
alias("buffer", "buffer/"),
alias("events", "events/"),
alias("os", "os-browserify/browser"),
alias("path", "path-browserify"),
alias("stream", "stream-browserify"),
alias("util", "util/"),
alias("zlib", "browserify-zlib"),
{ 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 } },
},
},
},
},
};
},
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() {
return {
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
+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
+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
+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: "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 -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
+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);
});