Archived
fix(plugins): make manifests deployment relative
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { loadExternalPlugins } from "./external";
|
||||
import { loadExternalPlugins, resolvePluginModuleUrl } from "./external";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("document", { baseURI: "https://pi.example.test/" });
|
||||
@@ -18,4 +18,35 @@ describe("external plugin manifests", () => {
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith("https://pi.example.test/pi-web-plugins/manifest.json", { cache: "no-store" });
|
||||
});
|
||||
|
||||
it("loads manifest-relative modules from a nested deployment", async () => {
|
||||
const manifestUrl = "https://pi.example.test/test/ai/pi-web-plugins/manifest.json";
|
||||
const fetchMock = vi.fn(() => Promise.resolve(new Response(JSON.stringify({
|
||||
plugins: [{ id: "info", module: "./info/pi-web-plugin.js?v=1", machineSpecific: false }],
|
||||
}))));
|
||||
const moduleLoader = vi.fn(() => Promise.resolve({
|
||||
default: { apiVersion: 1, name: "Info", activate: () => ({ contributions: {} }) },
|
||||
}));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const registrations = await loadExternalPlugins(manifestUrl, { moduleLoader });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(manifestUrl, { cache: "no-store" });
|
||||
expect(moduleLoader).toHaveBeenCalledWith("https://pi.example.test/test/ai/pi-web-plugins/info/pi-web-plugin.js?v=1");
|
||||
expect(registrations).toMatchObject([{ id: "info", machineSpecific: false, plugin: { apiVersion: 1, name: "Info" } }]);
|
||||
});
|
||||
|
||||
it("treats root-style modules from existing manifests as application-root paths", () => {
|
||||
const rootManifestUrl = "https://pi.example.test/pi-web-plugins/manifest.json";
|
||||
const nestedManifestUrl = "https://pi.example.test/test/ai/pi-web-plugins/manifest.json";
|
||||
|
||||
expect(resolvePluginModuleUrl("/pi-web-plugins/info/pi-web-plugin.js?v=1", rootManifestUrl, {
|
||||
viteBaseUrl: "/",
|
||||
documentBaseUrl: "https://pi.example.test/",
|
||||
})).toBe("https://pi.example.test/pi-web-plugins/info/pi-web-plugin.js?v=1");
|
||||
expect(resolvePluginModuleUrl("/pi-web-plugins/info/pi-web-plugin.js?v=1", nestedManifestUrl, {
|
||||
viteBaseUrl: "./",
|
||||
documentBaseUrl: "https://pi.example.test/test/ai/",
|
||||
})).toBe("https://pi.example.test/test/ai/pi-web-plugins/info/pi-web-plugin.js?v=1");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { machineScopedPluginId } from "../../../shared/machinePluginIds";
|
||||
import { resolveAppUrl } from "../appUrl";
|
||||
import { resolveAppUrl, type AppUrlContext } from "../appUrl";
|
||||
import type { PiWebPlugin, PiWebPluginRegistration } from "./types";
|
||||
|
||||
export interface PluginManifestEntry {
|
||||
@@ -15,6 +15,7 @@ interface PluginManifest {
|
||||
export interface LoadExternalPluginsOptions {
|
||||
machineId?: string;
|
||||
shouldLoadPlugin?: (entry: PluginManifestEntry) => boolean;
|
||||
moduleLoader?: (moduleUrl: string) => Promise<unknown>;
|
||||
}
|
||||
|
||||
export async function loadExternalPlugins(manifestUrl = "pi-web-plugins/manifest.json", options: LoadExternalPluginsOptions = {}): Promise<PiWebPluginRegistration[]> {
|
||||
@@ -26,8 +27,8 @@ export async function loadExternalPlugins(manifestUrl = "pi-web-plugins/manifest
|
||||
for (const entry of manifest.plugins) {
|
||||
if (options.shouldLoadPlugin?.(entry) === false) continue;
|
||||
try {
|
||||
const moduleUrl = new URL(entry.module, resolvedManifestUrl).toString();
|
||||
const module: unknown = await import(/* @vite-ignore */ moduleUrl);
|
||||
const moduleUrl = resolvePluginModuleUrl(entry.module, resolvedManifestUrl);
|
||||
const module = await (options.moduleLoader ?? importPluginModule)(moduleUrl);
|
||||
const plugin = parsePluginModule(module, moduleUrl);
|
||||
registrations.push({
|
||||
id: options.machineId === undefined ? entry.id : machineScopedPluginId(options.machineId, entry.id),
|
||||
@@ -42,6 +43,15 @@ export async function loadExternalPlugins(manifestUrl = "pi-web-plugins/manifest
|
||||
return registrations;
|
||||
}
|
||||
|
||||
export function resolvePluginModuleUrl(moduleReference: string, manifestUrl: string, appUrlContext?: AppUrlContext): string {
|
||||
if (!moduleReference.startsWith("/")) return new URL(moduleReference, manifestUrl).toString();
|
||||
return appUrlContext === undefined ? resolveAppUrl(moduleReference) : resolveAppUrl(moduleReference, appUrlContext);
|
||||
}
|
||||
|
||||
async function importPluginModule(moduleUrl: string): Promise<unknown> {
|
||||
return import(/* @vite-ignore */ moduleUrl);
|
||||
}
|
||||
|
||||
async function fetchPluginManifest(manifestUrl: string): Promise<PluginManifest | undefined> {
|
||||
const response = await fetch(manifestUrl, { cache: "no-store" });
|
||||
if (response.status === 404) return undefined;
|
||||
|
||||
@@ -9,15 +9,15 @@ describe("buildApp PI WEB plugin routes", () => {
|
||||
it("serves the PI WEB plugin manifest and plugin assets", 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: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false }] });
|
||||
expect(manifestResponse.json()).toEqual({ plugins: [{ id: "fake", module: "./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: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false, enabled: true }] });
|
||||
expect(pluginsResponse.json()).toEqual({ plugins: [{ id: "fake", module: "./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: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", machineSpecific: false, enabled: true }] });
|
||||
expect(localMachinePluginsResponse.json()).toEqual({ plugins: [{ id: "fake", module: "./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);
|
||||
@@ -51,7 +51,7 @@ describe("buildApp PI WEB plugin routes", () => {
|
||||
expect(request).toHaveBeenCalledWith("GET", "/api/plugins", undefined);
|
||||
});
|
||||
|
||||
it("rewrites and proxies remote machine plugin manifests and assets", async () => {
|
||||
it("rewrites existing root-style remote plugin manifests and proxies their assets", 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 }>();
|
||||
const requestJson = vi.fn(() => Promise.resolve({
|
||||
@@ -68,10 +68,15 @@ describe("buildApp PI WEB plugin routes", () => {
|
||||
|
||||
const manifestResponse = await appTestContext.app.inject({ method: "GET", url: `/api/machines/${remote.id}/pi-web-plugins/manifest.json` });
|
||||
const scopedPluginId = machineScopedPluginId(remote.id, "remote-tools");
|
||||
const rewrittenModule = `../../../../pi-web-plugins/${scopedPluginId}/pi-web-plugin.js?v=123`;
|
||||
expect(manifestResponse.statusCode).toBe(200);
|
||||
expect(manifestResponse.json()).toEqual({
|
||||
plugins: [{ id: "remote-tools", module: `/pi-web-plugins/${scopedPluginId}/pi-web-plugin.js?v=123`, source: "local", scope: "local", machineSpecific: true }],
|
||||
plugins: [{ id: "remote-tools", module: rewrittenModule, source: "local", scope: "local", machineSpecific: true }],
|
||||
});
|
||||
expect(new URL(rewrittenModule, `https://gateway.example.test/api/machines/${remote.id}/pi-web-plugins/manifest.json`).toString())
|
||||
.toBe(`https://gateway.example.test/pi-web-plugins/${scopedPluginId}/pi-web-plugin.js?v=123`);
|
||||
expect(new URL(rewrittenModule, `https://gateway.example.test/test/ai/api/machines/${remote.id}/pi-web-plugins/manifest.json`).toString())
|
||||
.toBe(`https://gateway.example.test/test/ai/pi-web-plugins/${scopedPluginId}/pi-web-plugin.js?v=123`);
|
||||
expect(requestJson).toHaveBeenCalledWith("GET", "/pi-web-plugins/manifest.json", undefined, { timeoutMs: 10000 });
|
||||
|
||||
const assetResponse = await appTestContext.app.inject({ method: "GET", url: `/pi-web-plugins/${scopedPluginId}/pi-web-plugin.js?v=123` });
|
||||
@@ -82,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("drops unsafe remote machine plugin manifest modules", async () => {
|
||||
it("accepts safe manifest-relative modules and drops 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({
|
||||
@@ -91,8 +96,8 @@ describe("buildApp PI WEB plugin routes", () => {
|
||||
headers: { "content-type": "application/json" },
|
||||
body: {
|
||||
plugins: [
|
||||
{ id: "safe-tools", module: "nested/pi-web-plugin.js?v=1", source: "local", scope: "local" },
|
||||
{ id: "traversal-tools", module: "..%2F..%2Fapi%2Fconfig", source: "local", scope: "local" },
|
||||
{ id: "safe-tools", module: "./safe-tools/nested/pi-web-plugin.js?v=1", 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" },
|
||||
],
|
||||
},
|
||||
@@ -103,7 +108,7 @@ 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" }],
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -98,8 +98,8 @@ export function registerAppTestHooks(): void {
|
||||
config: fakeConfigService(),
|
||||
piPackages: fakePiPackageService(),
|
||||
piWebPlugins: {
|
||||
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 }] }),
|
||||
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 }] }),
|
||||
readAsset: fakePiWebPluginAsset,
|
||||
},
|
||||
clientDist: false,
|
||||
|
||||
@@ -86,7 +86,7 @@ function rewriteRemotePluginManifest(machineId: string, manifest: RemotePluginMa
|
||||
if (modulePath === undefined) return [];
|
||||
return [{
|
||||
...plugin,
|
||||
module: `${piWebBasePath()}/pi-web-plugins/${encodeURIComponent(machineScopedPluginId(machineId, plugin.id))}/${modulePath.path}${modulePath.query}`,
|
||||
module: `../../../../pi-web-plugins/${encodeURIComponent(machineScopedPluginId(machineId, plugin.id))}/${modulePath.path}${modulePath.query}`,
|
||||
}];
|
||||
}),
|
||||
};
|
||||
@@ -95,10 +95,10 @@ 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 base = new URL(prefix, "http://pi-web.local");
|
||||
const manifestUrl = new URL("/pi-web-plugins/manifest.json", "http://pi-web.local");
|
||||
try {
|
||||
const url = new URL(module, base);
|
||||
if (url.origin !== base.origin || !url.pathname.startsWith(prefix)) return undefined;
|
||||
const url = new URL(module, manifestUrl);
|
||||
if (url.origin !== manifestUrl.origin || !url.pathname.startsWith(prefix)) return undefined;
|
||||
const path = safeRemotePluginAssetPath(url.pathname.slice(prefix.length));
|
||||
return path === undefined ? undefined : { path, query: url.search };
|
||||
} catch {
|
||||
@@ -190,11 +190,6 @@ function sendGatewayError(reply: FastifyReply, machineId: string, error: unknown
|
||||
});
|
||||
}
|
||||
|
||||
function piWebBasePath(): string {
|
||||
const basePath = process.env["PI_WEB_BASE_PATH"] ?? "";
|
||||
return basePath.replace(/\/$/u, "");
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ describe("PiWebPluginService", () => {
|
||||
plugins: [expect.objectContaining({ id: "info", source: "test", scope: "local", machineSpecific: false })],
|
||||
});
|
||||
const manifest = await service.manifest();
|
||||
expect(manifest.plugins[0]?.module).toMatch(/^\/pi-web-plugins\/info\/pi-web-plugin\.js\?v=\d+$/u);
|
||||
expect(manifest.plugins[0]?.module).toMatch(/^\.\/info\/pi-web-plugin\.js\?v=\d+$/u);
|
||||
|
||||
const asset = await service.readAsset("info", "pi-web-plugin.js");
|
||||
expect(asset?.contentType).toBe("application/javascript; charset=utf-8");
|
||||
@@ -105,7 +105,7 @@ describe("PiWebPluginService", () => {
|
||||
const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false });
|
||||
|
||||
const manifest = await service.manifest();
|
||||
const moduleUrl = new URL(manifest.plugins[0]?.module ?? "", "http://pi-web.test");
|
||||
const moduleUrl = new URL(manifest.plugins[0]?.module ?? "", "http://pi-web.test/pi-web-plugins/manifest.json");
|
||||
expect(moduleUrl.pathname).toBe("/pi-web-plugins/updates/pi-web-plugin.js");
|
||||
expect(moduleUrl.searchParams.get("v")).toMatch(/^\d+$/u);
|
||||
expect(moduleUrl.searchParams.get("piWebDockerMode")).toBe("dev");
|
||||
@@ -127,7 +127,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(/^\/pi-web-plugins\/review\/dist\/review\.js\?v=\d+$/u);
|
||||
expect(manifest.plugins[0]?.module).toMatch(/^\.\/review\/dist\/review\.js\?v=\d+$/u);
|
||||
});
|
||||
|
||||
it("refreshes Pi package plugin discovery after Pi package settings change", async () => {
|
||||
@@ -236,7 +236,7 @@ describe("PiWebPluginService", () => {
|
||||
expect(manifest.plugins).toEqual([
|
||||
expect.objectContaining({ id: "duplicate", source: "first", machineSpecific: false }),
|
||||
]);
|
||||
expect(manifest.plugins[0]?.module).toMatch(/^\/pi-web-plugins\/duplicate\/first\.js\?v=\d+$/u);
|
||||
expect(manifest.plugins[0]?.module).toMatch(/^\.\/duplicate\/first\.js\?v=\d+$/u);
|
||||
});
|
||||
|
||||
it("skips legacy metadata shortcuts and unsafe module paths", async () => {
|
||||
|
||||
@@ -50,11 +50,6 @@ interface PiWebPluginServiceOptions {
|
||||
configProvider?: () => PiWebConfig;
|
||||
}
|
||||
|
||||
function piWebBasePath(): string {
|
||||
const basePath = process.env["PI_WEB_BASE_PATH"] ?? "";
|
||||
return basePath.replace(/\/$/u, "");
|
||||
}
|
||||
|
||||
interface LocalPluginRoot {
|
||||
path: string;
|
||||
source: string;
|
||||
@@ -140,7 +135,7 @@ export class PiWebPluginService {
|
||||
private pluginInfo(plugin: PluginRecord, config: PiWebConfig): PiWebPluginInfo {
|
||||
return {
|
||||
id: plugin.id,
|
||||
module: `${piWebBasePath()}/pi-web-plugins/${encodeURIComponent(plugin.id)}/${plugin.entryFile}?${pluginModuleQuery(plugin)}`,
|
||||
module: `./${encodeURIComponent(plugin.id)}/${plugin.entryFile}?${pluginModuleQuery(plugin)}`,
|
||||
source: plugin.source,
|
||||
scope: plugin.scope,
|
||||
machineSpecific: plugin.machineSpecific,
|
||||
|
||||
Reference in New Issue
Block a user