fix: preserve plugin manifest compatibility

This commit is contained in:
Federico Jaramillo Martinez
2026-07-13 12:29:21 +02:00
parent 0d3096973e
commit a9bcfe25ff
7 changed files with 36 additions and 22 deletions
+6 -4
View File
@@ -395,10 +395,12 @@ After editing, check the manifest endpoint and browser-console failure cases.</c
the remote machine exposes its own copy. the remote machine exposes its own copy.
</p> </p>
<p> <p>
Current PI WEB manifests publish module references relative to the fetched manifest, so local and Current PI WEB manifests publish leading application-root module references. The browser keeps them
federated plugin modules follow root or nested reverse-proxy deployments without a prefix-specific inside the current application base, so local and federated plugins follow root or nested reverse-proxy
build. The browser and federated gateway also accept leading-root module references emitted by existing deployments without a prefix-specific build while remaining compatible with existing gateways.
PI WEB releases and keep them inside the current application base. Federated gateways also accept manifest-relative references such as
<code>./&lt;plugin-id&gt;/plugin.js</code> and legacy plugin-root-relative references such as
<code>nested/plugin.js</code> from remote machines.
</p> </p>
<p> <p>
For portable plugin assets, prefer URLs relative to the plugin module, such as For portable plugin assets, prefer URLs relative to the plugin module, such as
+3 -3
View File
@@ -325,14 +325,14 @@ Rules:
### Manifest and assets ### 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 ```json
{ {
"plugins": [ "plugins": [
{ {
"id": "my-plugin", "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", "source": "local",
"scope": "local", "scope": "local",
"machineSpecific": false "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. `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.
+12 -6
View File
@@ -6,18 +6,18 @@ import { appTestContext, fakeRemoteClient, registerAppTestHooks } from "./app.te
registerAppTestHooks(); registerAppTestHooks();
describe("buildApp PI WEB plugin routes", () => { 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" }); const manifestResponse = await appTestContext.app.inject({ method: "GET", url: "/pi-web-plugins/manifest.json" });
expect(manifestResponse.statusCode).toBe(200); 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" }); const pluginsResponse = await appTestContext.app.inject({ method: "GET", url: "/api/plugins" });
expect(pluginsResponse.statusCode).toBe(200); 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" }); const localMachinePluginsResponse = await appTestContext.app.inject({ method: "GET", url: "/api/machines/local/plugins" });
expect(localMachinePluginsResponse.statusCode).toBe(200); 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" }); const assetResponse = await appTestContext.app.inject({ method: "GET", url: "/pi-web-plugins/fake/plugin.js?v=1" });
expect(assetResponse.statusCode).toBe(200); 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"); 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 addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
const remote = addResponse.json<{ id: string }>(); const remote = addResponse.json<{ id: string }>();
appTestContext.remoteClient = fakeRemoteClient({ appTestContext.remoteClient = fakeRemoteClient({
@@ -97,8 +97,11 @@ describe("buildApp PI WEB plugin routes", () => {
body: { body: {
plugins: [ plugins: [
{ id: "safe-tools", module: "./safe-tools/nested/pi-web-plugin.js?v=1", source: "local", scope: "local" }, { 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: "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: "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.statusCode).toBe(200);
expect(manifestResponse.json()).toEqual({ 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" },
],
}); });
}); });
+2 -2
View File
@@ -98,8 +98,8 @@ export function registerAppTestHooks(): void {
config: fakeConfigService(), config: fakeConfigService(),
piPackages: fakePiPackageService(), piPackages: fakePiPackageService(),
piWebPlugins: { piWebPlugins: {
manifest: () => Promise.resolve({ plugins: [{ id: "fake", module: "./fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false }] }), 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: "./fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false, enabled: true }] }), 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, readAsset: fakePiWebPluginAsset,
}, },
clientDist: false, clientDist: false,
@@ -95,10 +95,13 @@ function rewriteRemotePluginManifest(machineId: string, manifest: RemotePluginMa
function remotePluginModulePath(pluginId: string, module: string): { path: string; query: string } | undefined { function remotePluginModulePath(pluginId: string, module: string): { path: string; query: string } | undefined {
if (!isPiWebPluginId(pluginId)) return undefined; if (!isPiWebPluginId(pluginId)) return undefined;
const prefix = `/pi-web-plugins/${encodeURIComponent(pluginId)}/`; 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 { try {
const url = new URL(module, manifestUrl); // An explicit ./<plugin-id>/ prefix is manifest-relative; bare paths retain the legacy plugin-root-relative contract.
if (url.origin !== manifestUrl.origin || !url.pathname.startsWith(prefix)) return undefined; 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)); const path = safeRemotePluginAssetPath(url.pathname.slice(prefix.length));
return path === undefined ? undefined : { path, query: url.search }; return path === undefined ? undefined : { path, query: url.search };
} catch { } catch {
+6 -3
View File
@@ -37,7 +37,10 @@ describe("PiWebPluginService", () => {
plugins: [expect.objectContaining({ id: "info", source: "test", scope: "local", machineSpecific: false })], plugins: [expect.objectContaining({ id: "info", source: "test", scope: "local", machineSpecific: false })],
}); });
const manifest = await service.manifest(); 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"); const asset = await service.readAsset("info", "pi-web-plugin.js");
expect(asset?.contentType).toBe("application/javascript; charset=utf-8"); expect(asset?.contentType).toBe("application/javascript; charset=utf-8");
@@ -127,7 +130,7 @@ describe("PiWebPluginService", () => {
const manifest = await service.manifest(); const manifest = await service.manifest();
expect(manifest.plugins).toHaveLength(1); expect(manifest.plugins).toHaveLength(1);
expect(manifest.plugins[0]).toMatchObject({ id: "review", source: "npm:@acme/review", scope: "user" }); 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 () => { it("refreshes Pi package plugin discovery after Pi package settings change", async () => {
@@ -236,7 +239,7 @@ describe("PiWebPluginService", () => {
expect(manifest.plugins).toEqual([ expect(manifest.plugins).toEqual([
expect.objectContaining({ id: "duplicate", source: "first", machineSpecific: false }), 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 () => { it("skips legacy metadata shortcuts and unsafe module paths", async () => {
+1 -1
View File
@@ -135,7 +135,7 @@ export class PiWebPluginService {
private pluginInfo(plugin: PluginRecord, config: PiWebConfig): PiWebPluginInfo { private pluginInfo(plugin: PluginRecord, config: PiWebConfig): PiWebPluginInfo {
return { return {
id: plugin.id, 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, source: plugin.source,
scope: plugin.scope, scope: plugin.scope,
machineSpecific: plugin.machineSpecific, machineSpecific: plugin.machineSpecific,