diff --git a/docs/plugins.html b/docs/plugins.html index 25e05e1..bb3a304 100644 --- a/docs/plugins.html +++ b/docs/plugins.html @@ -395,10 +395,12 @@ After editing, check the manifest endpoint and browser-console failure cases.

- Current PI WEB manifests publish module references relative to the fetched manifest, so local and - federated plugin modules follow root or nested reverse-proxy deployments without a prefix-specific - build. The browser and federated gateway also accept leading-root module references emitted by existing - PI WEB releases and keep them inside the current application base. + Current PI WEB manifests publish leading application-root module references. The browser keeps them + inside the current application base, so local and federated plugins follow root or nested reverse-proxy + deployments without a prefix-specific build while remaining compatible with existing gateways. + Federated gateways also accept manifest-relative references such as + ./<plugin-id>/plugin.js and legacy plugin-root-relative references such as + nested/plugin.js from remote machines.

For portable plugin assets, prefer URLs relative to the plugin module, such as diff --git a/docs/plugins.md b/docs/plugins.md index bfea440..cbcfcd2 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -325,14 +325,14 @@ Rules: ### Manifest and assets -The manifest contains each discovered plugin module. Current PI WEB releases emit `module` relative to the fetched manifest so the same manifest works at the origin root or under a reverse-proxy path prefix: +The manifest contains each discovered plugin module. Current PI WEB releases emit `module` as a leading application-root reference: ```json { "plugins": [ { "id": "my-plugin", - "module": "./my-plugin/pi-web-plugin.js?v=1234567890", + "module": "/pi-web-plugins/my-plugin/pi-web-plugin.js?v=1234567890", "source": "local", "scope": "local", "machineSpecific": false @@ -341,7 +341,7 @@ The manifest contains each discovered plugin module. Current PI WEB releases emi } ``` -The browser resolves manifest-relative module references against the manifest URL. For backward compatibility, it also treats leading-root references such as `/pi-web-plugins/my-plugin/pi-web-plugin.js` from existing PI WEB releases as application-root input, not origin-root input. Federated gateways accept both forms from remote machines and rewrite them to deployment-portable, gateway-relative references. +The browser maps leading application-root references into the current application base, so the same manifest works at the origin root or under a reverse-proxy path prefix. Keeping this output format also lets gateways from existing PI WEB releases consume plugins from an upgraded remote machine. For compatibility, federated gateways additionally accept explicit manifest-relative references such as `./my-plugin/pi-web-plugin.js` and legacy plugin-root-relative references such as `nested/pi-web-plugin.js`; all accepted forms are rewritten to deployment-portable, gateway-relative references. `source` describes where the plugin came from (`bundled`, `local`, or the Pi package source). `scope` is `bundled`, `local`, `user`, or `project`. `machineSpecific` controls whether the gateway copy is valid for remote machines or only each selected machine's own copy can appear. diff --git a/src/server/app.plugins.test.ts b/src/server/app.plugins.test.ts index 5a1ff1e..92b0c16 100644 --- a/src/server/app.plugins.test.ts +++ b/src/server/app.plugins.test.ts @@ -6,18 +6,18 @@ import { appTestContext, fakeRemoteClient, registerAppTestHooks } from "./app.te registerAppTestHooks(); describe("buildApp PI WEB plugin routes", () => { - it("serves the PI WEB plugin manifest and plugin assets", async () => { + it("serves application-root plugin modules through the manifest and plugin-list APIs", async () => { const manifestResponse = await appTestContext.app.inject({ method: "GET", url: "/pi-web-plugins/manifest.json" }); expect(manifestResponse.statusCode).toBe(200); - expect(manifestResponse.json()).toEqual({ plugins: [{ id: "fake", module: "./fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false }] }); + expect(manifestResponse.json()).toEqual({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false }] }); const pluginsResponse = await appTestContext.app.inject({ method: "GET", url: "/api/plugins" }); expect(pluginsResponse.statusCode).toBe(200); - expect(pluginsResponse.json()).toEqual({ plugins: [{ id: "fake", module: "./fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false, enabled: true }] }); + expect(pluginsResponse.json()).toEqual({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false, enabled: true }] }); const localMachinePluginsResponse = await appTestContext.app.inject({ method: "GET", url: "/api/machines/local/plugins" }); expect(localMachinePluginsResponse.statusCode).toBe(200); - expect(localMachinePluginsResponse.json()).toEqual({ plugins: [{ id: "fake", module: "./fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false, enabled: true }] }); + expect(localMachinePluginsResponse.json()).toEqual({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false, enabled: true }] }); const assetResponse = await appTestContext.app.inject({ method: "GET", url: "/pi-web-plugins/fake/plugin.js?v=1" }); expect(assetResponse.statusCode).toBe(200); @@ -87,7 +87,7 @@ describe("buildApp PI WEB plugin routes", () => { expect(request).toHaveBeenCalledWith("GET", "/pi-web-plugins/remote-tools/pi-web-plugin.js?v=123"); }); - it("accepts safe manifest-relative modules and drops unsafe remote modules", async () => { + it("accepts manifest-relative and legacy plugin-root-relative modules while dropping unsafe remote modules", async () => { const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } }); const remote = addResponse.json<{ id: string }>(); appTestContext.remoteClient = fakeRemoteClient({ @@ -97,8 +97,11 @@ describe("buildApp PI WEB plugin routes", () => { body: { plugins: [ { id: "safe-tools", module: "./safe-tools/nested/pi-web-plugin.js?v=1", source: "local", scope: "local" }, + { id: "legacy-tools", module: "nested/pi-web-plugin.js?v=2", source: "local", scope: "local" }, { id: "traversal-tools", module: "./traversal-tools/..%2F..%2Fapi%2Fconfig", source: "local", scope: "local" }, { id: "wrong-root", module: "/pi-web-plugins/other/pi-web-plugin.js", source: "local", scope: "local" }, + { id: "cross-origin", module: "https://plugins.example.test/pi-web-plugin.js", source: "local", scope: "local" }, + { id: "malformed", module: "nested/%E0%A4%A.js", source: "local", scope: "local" }, ], }, })), @@ -108,7 +111,10 @@ describe("buildApp PI WEB plugin routes", () => { expect(manifestResponse.statusCode).toBe(200); expect(manifestResponse.json()).toEqual({ - plugins: [{ id: "safe-tools", module: `../../../../pi-web-plugins/${machineScopedPluginId(remote.id, "safe-tools")}/nested/pi-web-plugin.js?v=1`, source: "local", scope: "local" }], + plugins: [ + { id: "safe-tools", module: `../../../../pi-web-plugins/${machineScopedPluginId(remote.id, "safe-tools")}/nested/pi-web-plugin.js?v=1`, source: "local", scope: "local" }, + { id: "legacy-tools", module: `../../../../pi-web-plugins/${machineScopedPluginId(remote.id, "legacy-tools")}/nested/pi-web-plugin.js?v=2`, source: "local", scope: "local" }, + ], }); }); diff --git a/src/server/app.testSupport.ts b/src/server/app.testSupport.ts index 5844301..e863fb4 100644 --- a/src/server/app.testSupport.ts +++ b/src/server/app.testSupport.ts @@ -98,8 +98,8 @@ export function registerAppTestHooks(): void { config: fakeConfigService(), piPackages: fakePiPackageService(), piWebPlugins: { - manifest: () => Promise.resolve({ plugins: [{ id: "fake", module: "./fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false }] }), - plugins: () => Promise.resolve({ plugins: [{ id: "fake", module: "./fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false, enabled: true }] }), + manifest: () => Promise.resolve({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false }] }), + plugins: () => Promise.resolve({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false, enabled: true }] }), readAsset: fakePiWebPluginAsset, }, clientDist: false, diff --git a/src/server/machines/machinePluginProxyRoutes.ts b/src/server/machines/machinePluginProxyRoutes.ts index ba4a6ab..bc3b34d 100644 --- a/src/server/machines/machinePluginProxyRoutes.ts +++ b/src/server/machines/machinePluginProxyRoutes.ts @@ -95,10 +95,13 @@ function rewriteRemotePluginManifest(machineId: string, manifest: RemotePluginMa function remotePluginModulePath(pluginId: string, module: string): { path: string; query: string } | undefined { if (!isPiWebPluginId(pluginId)) return undefined; const prefix = `/pi-web-plugins/${encodeURIComponent(pluginId)}/`; - const manifestUrl = new URL("/pi-web-plugins/manifest.json", "http://pi-web.local"); + const pluginRootUrl = new URL(prefix, "http://pi-web.local"); + const manifestUrl = new URL("/pi-web-plugins/manifest.json", pluginRootUrl); try { - const url = new URL(module, manifestUrl); - if (url.origin !== manifestUrl.origin || !url.pathname.startsWith(prefix)) return undefined; + // An explicit .// prefix is manifest-relative; bare paths retain the legacy plugin-root-relative contract. + const baseUrl = module.startsWith("./") ? manifestUrl : pluginRootUrl; + const url = new URL(module, baseUrl); + if (url.origin !== pluginRootUrl.origin || !url.pathname.startsWith(prefix)) return undefined; const path = safeRemotePluginAssetPath(url.pathname.slice(prefix.length)); return path === undefined ? undefined : { path, query: url.search }; } catch { diff --git a/src/server/piWebPluginService.test.ts b/src/server/piWebPluginService.test.ts index 1f650fe..71829ed 100644 --- a/src/server/piWebPluginService.test.ts +++ b/src/server/piWebPluginService.test.ts @@ -37,7 +37,10 @@ describe("PiWebPluginService", () => { plugins: [expect.objectContaining({ id: "info", source: "test", scope: "local", machineSpecific: false })], }); const manifest = await service.manifest(); - expect(manifest.plugins[0]?.module).toMatch(/^\.\/info\/pi-web-plugin\.js\?v=\d+$/u); + const module = manifest.plugins[0]?.module; + expect(module).toMatch(/^\/pi-web-plugins\/info\/pi-web-plugin\.js\?v=\d+$/u); + expect(new URL(module ?? "", "http://old-gateway.test/pi-web-plugins/info/").pathname).toBe("/pi-web-plugins/info/pi-web-plugin.js"); + await expect(service.plugins()).resolves.toMatchObject({ plugins: [{ module }] }); const asset = await service.readAsset("info", "pi-web-plugin.js"); expect(asset?.contentType).toBe("application/javascript; charset=utf-8"); @@ -127,7 +130,7 @@ describe("PiWebPluginService", () => { const manifest = await service.manifest(); expect(manifest.plugins).toHaveLength(1); expect(manifest.plugins[0]).toMatchObject({ id: "review", source: "npm:@acme/review", scope: "user" }); - expect(manifest.plugins[0]?.module).toMatch(/^\.\/review\/dist\/review\.js\?v=\d+$/u); + expect(manifest.plugins[0]?.module).toMatch(/^\/pi-web-plugins\/review\/dist\/review\.js\?v=\d+$/u); }); it("refreshes Pi package plugin discovery after Pi package settings change", async () => { @@ -236,7 +239,7 @@ describe("PiWebPluginService", () => { expect(manifest.plugins).toEqual([ expect.objectContaining({ id: "duplicate", source: "first", machineSpecific: false }), ]); - expect(manifest.plugins[0]?.module).toMatch(/^\.\/duplicate\/first\.js\?v=\d+$/u); + expect(manifest.plugins[0]?.module).toMatch(/^\/pi-web-plugins\/duplicate\/first\.js\?v=\d+$/u); }); it("skips legacy metadata shortcuts and unsafe module paths", async () => { diff --git a/src/server/piWebPluginService.ts b/src/server/piWebPluginService.ts index 7769b91..45938ea 100644 --- a/src/server/piWebPluginService.ts +++ b/src/server/piWebPluginService.ts @@ -135,7 +135,7 @@ export class PiWebPluginService { private pluginInfo(plugin: PluginRecord, config: PiWebConfig): PiWebPluginInfo { return { id: plugin.id, - module: `./${encodeURIComponent(plugin.id)}/${plugin.entryFile}?${pluginModuleQuery(plugin)}`, + module: `/pi-web-plugins/${encodeURIComponent(plugin.id)}/${plugin.entryFile}?${pluginModuleQuery(plugin)}`, source: plugin.source, scope: plugin.scope, machineSpecific: plugin.machineSpecific,