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 <noreply@anthropic.com>
This commit is contained in:
BastiaanandClaude Fable 5.1 authored and GitHub committed 2026-09-12 01:59:16 +01:00
1 parent 9c97eeb597
commit 1a10b38743
9 files changed
+76 -6

No files matched your search

+3 -1
View File
@@ -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`)) {
+1 -1
View File
@@ -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") {
+2 -1
View File
@@ -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;
};
/**
+17
View File
@@ -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
+3
View File
@@ -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}`];
@@ -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 <PageLayout nav={nav}>
<Descriptions bordered size="small" title={pluginTitle}>
<Descriptions.Item label="Version">{pluginMeta.version}</Descriptions.Item>
<Descriptions.Item label="Loaded in Web UI" span={2}>No</Descriptions.Item>
<Descriptions.Item label="Loaded in Web UI" span={2}>
{hasWebModule ? "No" : "No web module"}
</Descriptions.Item>
<Descriptions.Item label="Loaded on controller">{pluginMeta.loaded ? "Yes" : "No"}</Descriptions.Item>
<Descriptions.Item label="Enabled on controller" span={2}>
{pluginMeta.enabled ? "Yes" : "No"}
+12 -1
View File
@@ -88,6 +88,9 @@ export default function PluginsPage() {
if (!plugin.meta.enabled) {
return <><InfoCircleFilled style={{ color: "#1668dc" }} /> 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 <><InfoCircleFilled style={{ color: "#1668dc" }} /> 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"],
+14 -1
View File
@@ -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;
+20
View File
@@ -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([