Archived
fix: preserve plugin manifest compatibility
This commit is contained in:
+6
-4
@@ -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>./<plugin-id>/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
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user