From 2f4e0c441852b9597ba80f4acf353e4154aae4c4 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Fri, 28 Aug 2026 09:50:08 +0000 Subject: [PATCH 1/4] Make staticRoot include the static/ prefix and use it for plugin bundles staticRoot was set to the same value as webRoot, so the static/ prefix ended up scattered across consumers: index.html relied on the manifest path containing it, the export stores hardcoded `${staticRoot}static/`, and bootstrap loaded plugin bundles from webRoot instead. Now staticRoot is `${webRoot}static/` and the controller strips the static/ prefix from bundle paths before handing them to the page and to /api/plugins. Plugins that fetch assets with `${staticRoot}static/...` need to drop the static/ part. Fixes #980 Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 10 ++++++++++ packages/controller/src/Controller.ts | 5 +++-- packages/controller/src/routes.ts | 11 +++++++++++ packages/web_ui/global.d.ts | 2 ++ packages/web_ui/src/bootstrap.tsx | 2 +- packages/web_ui/src/components/ModPackViewPage.tsx | 2 +- packages/web_ui/src/store/export_locale_stores.ts | 2 +- .../src/store/export_prototype_metadata_store.ts | 4 ++-- 8 files changed, 31 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 52c13d77..edfbb13b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,16 @@ Many thanks to the following for contributing to this release: [@username](https://github.com/username) --> +## Version 2.0.0-alpha.28 + +### Fixes + +- Plugin web bundles are now loaded relative to `staticRoot` instead of `webRoot`. [#980]() + +### Breaking Changes + +- `staticRoot` in the web interface now includes the `static/` prefix. Plugins that fetch assets with `${staticRoot}static/...` should drop the `static/` part. [#980]() + ## Version 2.0.0-alpha.27 ### Features diff --git a/packages/controller/src/Controller.ts b/packages/controller/src/Controller.ts index bbf571d8..cd63d9cf 100644 --- a/packages/controller/src/Controller.ts +++ b/packages/controller/src/Controller.ts @@ -1217,7 +1217,7 @@ export default class Controller { } /** - * Servers the web interface with the root path set apropriately + * Serves the web interface with the root path set appropriately * * @param route - route the interface is served under. * @returns Experess.js route handler. @@ -1231,7 +1231,7 @@ export default class Controller { return function(req: Request, res: Response, next: NextFunction) { let depth = routeDepth + Number(req.path.slice(-1) === "/"); let webRoot = "../".repeat(depth) || "./"; - let staticRoot = webRoot; + let staticRoot = `${webRoot}static/`; let mainBundle: string = ""; if (res.app.locals.mainBundle) { mainBundle = res.app.locals.mainBundle; @@ -1239,6 +1239,7 @@ export default class Controller { let stats = res.locals.webpack.devMiddleware.stats.stats[0]; mainBundle = stats.toJson().assetsByChunkName["main"]; } + mainBundle = routes.stripStaticPrefix(mainBundle); fs.readFile(path.join(__dirname, "..", "..", "..", "web", "index.html"), "utf8").then((content) => { res.type("text/html"); diff --git a/packages/controller/src/routes.ts b/packages/controller/src/routes.ts index 4622502b..bcd3eaaa 100644 --- a/packages/controller/src/routes.ts +++ b/packages/controller/src/routes.ts @@ -104,6 +104,14 @@ async function getMetrics(req: Request, res: Response, next: any) { } +/** + * Removes the static/ prefix webpack adds to bundle paths so they can be + * resolved relative to staticRoot. + */ +export function stripStaticPrefix(bundlePath: string) { + return bundlePath.replace(/^static\//, ""); +} + function getPlugins(req: Request, res: Response) { let plugins: lib.PluginWebApi[] = []; for (let pluginInfo of req.app.locals.controller.pluginInfos) { @@ -129,6 +137,9 @@ function getPlugins(req: Request, res: Response) { if (web.main === "remoteEntry.js") { web.error = "Incompatible old remoteEntry.js entrypoint."; } + if (web.main) { + web.main = stripStaticPrefix(web.main); + } plugins.push({ name, version: pluginInfo.version, enabled, loaded, web, npmPackage: pluginInfo.npmPackage }); } res.send(plugins); diff --git a/packages/web_ui/global.d.ts b/packages/web_ui/global.d.ts index 475b5037..90fd88c1 100644 --- a/packages/web_ui/global.d.ts +++ b/packages/web_ui/global.d.ts @@ -13,7 +13,9 @@ declare module "*.md" { export default value; } +/** Path the web interface is served under, ends with a slash. */ 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; diff --git a/packages/web_ui/src/bootstrap.tsx b/packages/web_ui/src/bootstrap.tsx index 8ef588bd..2695d154 100644 --- a/packages/web_ui/src/bootstrap.tsx +++ b/packages/web_ui/src/bootstrap.tsx @@ -50,7 +50,7 @@ async function loadPluginInfos(): Promise<[lib.PluginWebpackEnvInfo[], string]> continue; } try { - await loadScript(`${webRoot}${meta.web.main}`); + 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}`); diff --git a/packages/web_ui/src/components/ModPackViewPage.tsx b/packages/web_ui/src/components/ModPackViewPage.tsx index c00a4315..19c8ae53 100644 --- a/packages/web_ui/src/components/ModPackViewPage.tsx +++ b/packages/web_ui/src/components/ModPackViewPage.tsx @@ -1102,7 +1102,7 @@ function useExportedAsset(modPack: lib.ModPack | undefined, asset: "settings"|"l return; } - let response = await fetch(`${staticRoot}static/${assetFilename}`); + let response = await fetch(`${staticRoot}${assetFilename}`); if (response.ok) { let data = await response.json(); setAssetData(asset === "locale" ? new Map(data) : data); diff --git a/packages/web_ui/src/store/export_locale_stores.ts b/packages/web_ui/src/store/export_locale_stores.ts index 8d3c6d17..e15c6aa8 100644 --- a/packages/web_ui/src/store/export_locale_stores.ts +++ b/packages/web_ui/src/store/export_locale_stores.ts @@ -21,7 +21,7 @@ class ExportLocaleStore { return; } - let response = await fetch(`${staticRoot}static/${cacheEntry.localePath}`); + let response = await fetch(`${staticRoot}${cacheEntry.localePath}`); if (response.ok) { cacheEntry.data = new Map(await response.json() as ExportLocale); for (const callback of cacheEntry.callbacks) { diff --git a/packages/web_ui/src/store/export_prototype_metadata_store.ts b/packages/web_ui/src/store/export_prototype_metadata_store.ts index 59169fd3..cd919658 100644 --- a/packages/web_ui/src/store/export_prototype_metadata_store.ts +++ b/packages/web_ui/src/store/export_prototype_metadata_store.ts @@ -26,7 +26,7 @@ class ExportPrototypeMetadataStore { return; } - const response = await fetch(`${staticRoot}static/${cacheEntry.metadataPath}`); + const response = await fetch(`${staticRoot}${cacheEntry.metadataPath}`); if (!response.ok) { notify( `Failed to fetch prototype metadata for mod pack ${modPackId}, server returned: ` + @@ -43,7 +43,7 @@ class ExportPrototypeMetadataStore { const metadata = await response.json() as ExportMetadata; - const spriteUrl = `${staticRoot}static/${cacheEntry.spritesheetPath}`; + const spriteUrl = `${staticRoot}${cacheEntry.spritesheetPath}`; const styleContent: string[] = []; styleContent.push(`\ .factorio-icon\ From bd6bf480da96b2e19e2920f503257f33de7a6692 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:35:35 +0000 Subject: [PATCH 2/4] Drop changelog edit, entries go in the PR description Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index edfbb13b..52c13d77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,16 +31,6 @@ Many thanks to the following for contributing to this release: [@username](https://github.com/username) --> -## Version 2.0.0-alpha.28 - -### Fixes - -- Plugin web bundles are now loaded relative to `staticRoot` instead of `webRoot`. [#980]() - -### Breaking Changes - -- `staticRoot` in the web interface now includes the `static/` prefix. Plugins that fetch assets with `${staticRoot}static/...` should drop the `static/` part. [#980]() - ## Version 2.0.0-alpha.27 ### Features From 4c252a07633cf3f41cfe50490a2ed4ccc0ba1394 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:48:54 +0000 Subject: [PATCH 3/4] Add tests for stripStaticPrefix and /api/plugins The codecov/patch check flagged the new routes code as uncovered. The unit tests never hit /api/plugins, so cover the static/ stripping and the manifest error paths directly. Co-Authored-By: Claude Fable 5 --- test/controller/routes.js | 44 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/test/controller/routes.js b/test/controller/routes.js index 9cbbf4f8..5087e97b 100644 --- a/test/controller/routes.js +++ b/test/controller/routes.js @@ -73,6 +73,50 @@ describe("controller/src/routes", function() { assert.equal(await responses[1].text(), "test content"); }); }); + describe("stripStaticPrefix()", function() { + it("should remove a leading static/", function() { + assert.equal(routes.stripStaticPrefix("static/main.abc.js"), "main.abc.js"); + }); + it("should leave other paths alone", function() { + assert.equal(routes.stripStaticPrefix("main.abc.js"), "main.abc.js"); + assert.equal(routes.stripStaticPrefix("other/static/main.js"), "other/static/main.js"); + }); + }); + describe("/api/plugins", function() { + let endpoint; + beforeEach(function() { + endpoint = `http://localhost:${port}/api/plugins`; + controller.plugins = new Map([["foo", {}]]); + controller.mockConfigEntries.set("foo.load_plugin", true); + }); + 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" }, + }]; + let response = await fetch(endpoint); + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), [{ + name: "foo", version: "1.0.0", enabled: true, loaded: true, npmPackage: "foo", + web: { main: "foo.abc.js" }, + }]); + }); + it("should report missing manifest and entrypoint", async function() { + controller.pluginInfos = [ + { name: "foo", version: "1.0.0", npmPackage: "foo" }, + { name: "bar", version: "1.0.0", npmPackage: "bar", manifest: {} }, + { name: "baz", version: "1.0.0", npmPackage: "baz", manifest: { "baz.js": "remoteEntry.js" } }, + ]; + 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."); + assert.equal(data[1].loaded, false); + assert.equal(data[1].enabled, false); + }); + }); describe("/api/cluster-name", function() { let endpoint; beforeEach(function() { From 930223019e256a6bd0a72068952975ac405743c8 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:54:25 +0000 Subject: [PATCH 4/4] Add serveWeb test for webRoot and staticRoot substitution Co-Authored-By: Claude Fable 5 --- test/controller/Controller.js | 37 +++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/test/controller/Controller.js b/test/controller/Controller.js index 62c1c264..35e6db9c 100644 --- a/test/controller/Controller.js +++ b/test/controller/Controller.js @@ -3,6 +3,9 @@ const path = require("node:path"); const assert = require("assert").strict; const { Controller, HostRecord, InstanceRecord, UserRecord } = require("@clusterio/controller"); const { EventEmitter } = require("stream"); +const events = require("node:events"); +const http = require("node:http"); +const express = require("express"); const { ControllerConfig, Address, RequestError, @@ -48,6 +51,40 @@ describe("controller/src/Controller", function() { assert(list.check(ip), `${ip} missing from list`); } } + describe(".serveWeb()", function() { + let server, port; + before(async function() { + const app = express(); + app.locals.controller = { config: { get: () => "Test Cluster" } }; + app.locals.mainBundle = "static/main.abc.js"; + for (let route of ["/", "/instances/:id/view"]) { + app.get(route, Controller.serveWeb(route)); + } + server = http.createServer(app); + server.listen(0, "localhost"); + await events.once(server, "listening"); + port = server.address().port; + }); + after(function() { + server.close(); + }); + it("should serve the root with static/ in staticRoot", async function() { + const html = await (await fetch(`http://localhost:${port}/`)).text(); + assert(html.includes("Test Cluster")); + assert(html.includes("src=\"./static/main.abc.js\"")); + assert(html.includes("new URL(\"./\", document.location)")); + assert(html.includes("new URL(\"./static/\", document.location)")); + }); + 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)")); + 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)")); + }); + }); describe(".parseTrustedProxies()", function() { it("should parse addresses", function() { controller.config.set("controller.trusted_proxies", "10.0.0.1");