diff --git a/.github/actions/pnpm-install/action.yml b/.github/actions/pnpm-install/action.yml index b4225457..7a5037e5 100644 --- a/.github/actions/pnpm-install/action.yml +++ b/.github/actions/pnpm-install/action.yml @@ -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 }}- diff --git a/clean.js b/clean.js index 48c8c5b1..a45e3ac1 100644 --- a/clean.js +++ b/clean.js @@ -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")) ); } } diff --git a/docs/contributing.md b/docs/contributing.md index 41f59aaa..f099db99 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -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`. | diff --git a/docs/decisions.md b/docs/decisions.md index 8c2a8181..088c0ed1 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -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 diff --git a/docs/devs/plugin-migration-to-2.1.md b/docs/devs/plugin-migration-to-2.1.md index aac0a789..697fafbb 100644 --- a/docs/devs/plugin-migration-to-2.1.md +++ b/docs/devs/plugin-migration-to-2.1.md @@ -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. diff --git a/docs/writing-plugins.md b/docs/writing-plugins.md index d31d14e5..d351f31d 100644 --- a/docs/writing-plugins.md +++ b/docs/writing-plugins.md @@ -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. diff --git a/package.json b/package.json index ed405e3a..503e2c4c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/controller/.npmignore b/packages/controller/.npmignore index 55622581..75717778 100644 --- a/packages/controller/.npmignore +++ b/packages/controller/.npmignore @@ -1,2 +1,2 @@ -webpack.config.js +vite.config.js dist/browser diff --git a/packages/controller/package.json b/packages/controller/package.json index 1efe96de..e968acbd 100644 --- a/packages/controller/package.json +++ b/packages/controller/package.json @@ -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:" } } diff --git a/packages/controller/src/Controller.ts b/packages/controller/src/Controller.ts index 4f319f67..3cce5b8f 100644 --- a/packages/controller/src/Controller.ts +++ b/packages/controller/src/Controller.ts @@ -106,7 +106,7 @@ export default class Controller { _registeredEvents = new Map, lib.EventHandler>(); _snoopedEvents = new Map(); - devMiddleware: any | null = null; + viteServer: import("vite").ViteDevServer | null = null; startedAtMs = Date.now(); autosaveInterval?: ReturnType; @@ -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(); + const pluginDirs = new Map(); + 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(/ { - 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); }); diff --git a/packages/controller/src/routes.ts b/packages/controller/src/routes.ts index 668e2a48..505eea63 100644 --- a/packages/controller/src/routes.ts +++ b/packages/controller/src/routes.ts @@ -20,9 +20,10 @@ declare global { namespace Express { export interface Locals { controller: Controller, - mainBundle: string, + webBundle: { main: string, imports: Record }, + viteServer?: import("vite").ViteDevServer, staticRoot: string | null, - devPlugins: Map, + devPlugins?: Map, streams: Map, pendingMetrics?: Promise, } @@ -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); } diff --git a/packages/controller/vite.config.js b/packages/controller/vite.config.js new file mode 100644 index 00000000..bca02bc4 --- /dev/null +++ b/packages/controller/vite.config.js @@ -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" })], +}); diff --git a/packages/controller/web/index.html b/packages/controller/web/index.html index 4bf444cd..a153d69e 100644 --- a/packages/controller/web/index.html +++ b/packages/controller/web/index.html @@ -5,6 +5,7 @@ __CLUSTER_NAME__ +