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([