From 1a10b38743e38d8fdb225e253da4da776491f989 Mon Sep 17 00:00:00 2001 From: Bastiaan <17990055+bbassie@users.noreply.github.com> Date: Sat, 12 Sep 2026 02:59:16 +0200 Subject: [PATCH] Only report a missing plugin web build when one is expected (#1013) Plugins with only host, instance or ctl code have no reason to ship a webpack build, yet the plugin list showed "Missing dist/web/manifest.json" as an error for them and the controller logged a warning on every start. Decide whether a build is expected from the plugin declaration, using the same rule the create tool uses to decide when to generate one: a web or controller entrypoint, or any config fields. Plugins outside that rule get no bundle path and no error, the web UI skips loading them, and the plugin pages show them as having no web module instead of an error or a prompt to reload. Fixes #1004 Co-authored-by: Claude Fable 5.1 --- packages/controller/src/Controller.ts | 4 +++- packages/controller/src/routes.ts | 2 +- packages/lib/src/api.ts | 3 ++- packages/lib/src/plugin.ts | 17 ++++++++++++++++ packages/web_ui/src/bootstrap.tsx | 3 +++ .../web_ui/src/components/PluginViewPage.tsx | 5 ++++- .../web_ui/src/components/PluginsPage.tsx | 13 +++++++++++- test/controller/routes.js | 15 +++++++++++++- test/lib/plugin.js | 20 +++++++++++++++++++ 9 files changed, 76 insertions(+), 6 deletions(-) diff --git a/packages/controller/src/Controller.ts b/packages/controller/src/Controller.ts index 7c7aa18b..48388665 100644 --- a/packages/controller/src/Controller.ts +++ b/packages/controller/src/Controller.ts @@ -1133,7 +1133,9 @@ export default class Controller { let manifestPath = path.posix.join(pluginInfo.requirePath, "dist", "web", "manifest.json"); pluginInfo.manifest = await Controller.loadJsonObject(import.meta.resolve(manifestPath), true); } catch (err) { - logger.warn(`Unable to load dist/web/manifest.json for plugin ${pluginInfo.name}`); + if (lib.pluginNeedsWebBuild(pluginInfo)) { + logger.warn(`Unable to load dist/web/manifest.json for plugin ${pluginInfo.name}`); + } } if (!this.config.get(`${pluginInfo.name}.load_plugin`)) { diff --git a/packages/controller/src/routes.ts b/packages/controller/src/routes.ts index 5e522583..bb8704fe 100644 --- a/packages/controller/src/routes.ts +++ b/packages/controller/src/routes.ts @@ -132,7 +132,7 @@ function getPlugins(req: Request, res: Response) { if (!web.main) { web.error = `Missing ${pluginInfo.name}.js entry in manifest.json`; } - } else { + } else if (lib.pluginNeedsWebBuild(pluginInfo)) { web.error = "Missing dist/web/manifest.json"; } if (web.main === "remoteEntry.js") { diff --git a/packages/lib/src/api.ts b/packages/lib/src/api.ts index 43289178..86a25393 100644 --- a/packages/lib/src/api.ts +++ b/packages/lib/src/api.ts @@ -4,7 +4,8 @@ export type PluginWebApi = { enabled: boolean; loaded: boolean; web: { - main: string; + /** Path to the web bundle, not present if the plugin has no web build. */ + main?: string; error?: string; }; /** diff --git a/packages/lib/src/plugin.ts b/packages/lib/src/plugin.ts index 0f88f43a..ae8e75c4 100644 --- a/packages/lib/src/plugin.ts +++ b/packages/lib/src/plugin.ts @@ -41,6 +41,23 @@ export type PluginDeclaration = { routes?: string[]; } +/** + * Check if a plugin is expected to ship a web build. + * + * Mirrors the rule used by the create tool: a web build is generated when the + * plugin has a web or controller entrypoint or defines config fields. + */ +export function pluginNeedsWebBuild(info: PluginDeclaration) { + return Boolean( + info.webEntrypoint + || info.controllerEntrypoint + || info.controllerConfigFields + || info.hostConfigFields + || info.instanceConfigFields + || info.controlConfigFields + ); +} + export type PluginNodeEnvInfo = PluginDeclaration & { /** * Path to the folder with the static files that should be hosted on the web diff --git a/packages/web_ui/src/bootstrap.tsx b/packages/web_ui/src/bootstrap.tsx index 74359367..1e4848b4 100644 --- a/packages/web_ui/src/bootstrap.tsx +++ b/packages/web_ui/src/bootstrap.tsx @@ -49,6 +49,9 @@ async function loadPluginInfos(): Promise<[lib.PluginWebpackEnvInfo[], string]> logger.error(`Failed to load plugin ${meta.name}: ${meta.web.error}`); continue; } + if (!meta.web.main) { + continue; + } try { await loadScript(`${staticRoot}${meta.web.main}`); let container: any = (window as { [key: string]: any })[`plugin_${meta.name}`]; diff --git a/packages/web_ui/src/components/PluginViewPage.tsx b/packages/web_ui/src/components/PluginViewPage.tsx index 58eba31a..5134b09c 100644 --- a/packages/web_ui/src/components/PluginViewPage.tsx +++ b/packages/web_ui/src/components/PluginViewPage.tsx @@ -46,11 +46,14 @@ export default function PluginViewPage() { } const pluginError = pluginMeta.web.error ?? pluginInfo?.error; + const hasWebModule = Boolean(pluginMeta.web.main || pluginMeta.web.error); if (!plugin) { return {pluginMeta.version} - No + + {hasWebModule ? "No" : "No web module"} + {pluginMeta.loaded ? "Yes" : "No"} {pluginMeta.enabled ? "Yes" : "No"} diff --git a/packages/web_ui/src/components/PluginsPage.tsx b/packages/web_ui/src/components/PluginsPage.tsx index 16a0dc7d..20d5ef84 100644 --- a/packages/web_ui/src/components/PluginsPage.tsx +++ b/packages/web_ui/src/components/PluginsPage.tsx @@ -88,6 +88,9 @@ export default function PluginsPage() { if (!plugin.meta.enabled) { return <> Disabled on controller; } + if (!plugin.meta.web.main && !plugin.meta.web.error) { + return plugin.meta.version; + } if (!plugin.meta.web.error && !control.pluginInfos.has(plugin.meta.name)) { return <> Reload page to load; } @@ -105,7 +108,15 @@ export default function PluginsPage() { { title: "Loaded", key: "loaded", - render: (_, plugin) => (plugin.package ? "Yes" : null), + render: (_, plugin) => { + if (plugin.package) { + return "Yes"; + } + if (!plugin.meta.web.main && !plugin.meta.web.error) { + return "No web module"; + } + return null; + }, sorter: (a, b) => Number(Boolean(a.package)) - Number(Boolean(b.package)), sortOrder: tableState.sortOrder("loaded"), responsive: ["sm"], diff --git a/test/controller/routes.js b/test/controller/routes.js index 5e764214..726b6aa6 100644 --- a/test/controller/routes.js +++ b/test/controller/routes.js @@ -102,7 +102,7 @@ describe("controller/src/routes", function() { }); it("should report missing manifest and entrypoint", async function() { controller.pluginInfos = [ - { name: "foo", version: "1.0.0", npmPackage: "foo" }, + { 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" } }, ]; @@ -115,6 +115,19 @@ describe("controller/src/routes", function() { assert.equal(data[1].loaded, false); assert.equal(data[1].enabled, false); }); + it("should not report a missing manifest for plugins without a web build", async function() { + controller.pluginInfos = [ + { name: "foo", version: "1.0.0", npmPackage: "foo", hostEntrypoint: "dist/node/host.js" }, + { name: "bar", version: "1.0.0", npmPackage: "bar", controllerEntrypoint: "dist/node/controller.js" }, + { name: "baz", version: "1.0.0", npmPackage: "baz", instanceConfigFields: {} }, + ]; + let response = await fetch(endpoint); + assert.equal(response.status, 200); + let data = await response.json(); + assert.deepEqual(data[0].web, {}); + assert.equal(data[1].web.error, "Missing dist/web/manifest.json"); + assert.equal(data[2].web.error, "Missing dist/web/manifest.json"); + }); }); describe("/api/cluster-name", function() { let endpoint; diff --git a/test/lib/plugin.js b/test/lib/plugin.js index b9fd11ac..bfbe58f9 100644 --- a/test/lib/plugin.js +++ b/test/lib/plugin.js @@ -5,6 +5,26 @@ import * as lib from "@clusterio/lib"; describe("lib/plugin", function() { + describe("pluginNeedsWebBuild()", function() { + it("should be false for plugins with only host, instance or ctl code", function() { + assert.equal(lib.pluginNeedsWebBuild({ name: "foo", title: "Foo" }), false); + assert.equal(lib.pluginNeedsWebBuild({ + name: "foo", title: "Foo", hostEntrypoint: "host.js", instanceEntrypoint: "instance.js", + ctlEntrypoint: "ctl.js", messages: [], + }), false); + }); + it("should be true for plugins with web or controller code or config fields", function() { + assert.equal(lib.pluginNeedsWebBuild({ name: "foo", title: "Foo", webEntrypoint: "web.js" }), true); + assert.equal(lib.pluginNeedsWebBuild({ + name: "foo", title: "Foo", controllerEntrypoint: "controller.js", + }), true); + for (const field of [ + "controllerConfigFields", "hostConfigFields", "instanceConfigFields", "controlConfigFields", + ]) { + assert.equal(lib.pluginNeedsWebBuild({ name: "foo", title: "Foo", [field]: {} }), true, field); + } + }); + }); describe("invokeHook()", function() { let betaTestCalled = false; let plugins = new Map([