Archived
feat: support machine-specific plugins
This commit is contained in:
@@ -60,8 +60,8 @@ beforeEach(async () => {
|
||||
}),
|
||||
sessionDaemon: fakeSessionDaemon(),
|
||||
piWebPlugins: {
|
||||
manifest: () => Promise.resolve({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local" }] }),
|
||||
plugins: () => Promise.resolve({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local", 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: (pluginId, assetPath) => Promise.resolve(pluginId === "fake" && assetPath === "plugin.js" ? { content: Buffer.from("export default {};"), contentType: "application/javascript; charset=utf-8" } : undefined),
|
||||
},
|
||||
clientDist: false,
|
||||
@@ -332,11 +332,11 @@ describe("buildApp", () => {
|
||||
it("serves the PI WEB plugin manifest and plugin assets", async () => {
|
||||
const manifestResponse = await 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" }] });
|
||||
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 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", 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 assetResponse = await app.inject({ method: "GET", url: "/pi-web-plugins/fake/plugin.js?v=1" });
|
||||
expect(assetResponse.statusCode).toBe(200);
|
||||
@@ -353,7 +353,7 @@ describe("buildApp", () => {
|
||||
const requestJson = vi.fn(() => Promise.resolve({
|
||||
statusCode: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: { plugins: [{ id: "remote-tools", module: "/pi-web-plugins/remote-tools/pi-web-plugin.js?v=123", source: "local", scope: "local" }] },
|
||||
body: { plugins: [{ id: "remote-tools", module: "/pi-web-plugins/remote-tools/pi-web-plugin.js?v=123", source: "local", scope: "local", machineSpecific: true }] },
|
||||
}));
|
||||
const request = vi.fn(() => Promise.resolve({
|
||||
statusCode: 200,
|
||||
@@ -366,7 +366,7 @@ describe("buildApp", () => {
|
||||
const scopedPluginId = machineScopedPluginId(remote.id, "remote-tools");
|
||||
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" }],
|
||||
plugins: [{ id: "remote-tools", module: `/pi-web-plugins/${scopedPluginId}/pi-web-plugin.js?v=123`, source: "local", scope: "local", machineSpecific: true }],
|
||||
});
|
||||
expect(requestJson).toHaveBeenCalledWith("GET", "/pi-web-plugins/manifest.json", undefined, { timeoutMs: 10000 });
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ interface RemotePluginManifestEntry {
|
||||
module: string;
|
||||
source?: string;
|
||||
scope?: string;
|
||||
machineSpecific?: boolean;
|
||||
}
|
||||
|
||||
interface RemotePluginManifest {
|
||||
@@ -158,11 +159,18 @@ function parseRemoteManifest(value: unknown): RemotePluginManifest {
|
||||
module: entry["module"],
|
||||
...(typeof entry["source"] === "string" ? { source: entry["source"] } : {}),
|
||||
...(typeof entry["scope"] === "string" ? { scope: entry["scope"] } : {}),
|
||||
...(parseRemoteMachineSpecific(entry["machineSpecific"])),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function parseRemoteMachineSpecific(value: unknown): { machineSpecific?: boolean } {
|
||||
if (value === undefined) return {};
|
||||
if (typeof value !== "boolean") throw new Error("Invalid remote PI WEB plugin manifest entry");
|
||||
return { machineSpecific: value };
|
||||
}
|
||||
|
||||
function applySafeHeaders(reply: FastifyReply, headers: Record<string, string | string[] | undefined>): void {
|
||||
for (const [name, value] of Object.entries(headers)) {
|
||||
if (value === undefined) continue;
|
||||
|
||||
@@ -25,7 +25,7 @@ describe("PiWebPluginService", () => {
|
||||
const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false });
|
||||
|
||||
await expect(service.manifest()).resolves.toEqual({
|
||||
plugins: [expect.objectContaining({ id: "info", source: "test", scope: "local" })],
|
||||
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);
|
||||
@@ -35,6 +35,18 @@ describe("PiWebPluginService", () => {
|
||||
expect(asset?.content.toString("utf8")).toContain("export default");
|
||||
});
|
||||
|
||||
it("includes machine-specific preferences in plugin manifests", async () => {
|
||||
await writePlugin(join(tempDir, "plugins", "updates"), {
|
||||
packageJson: { piWeb: { plugins: [{ id: "updates", module: "pi-web-plugin.js", machineSpecific: true }] } },
|
||||
files: { "pi-web-plugin.js": "export default {};" },
|
||||
});
|
||||
|
||||
const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false });
|
||||
|
||||
await expect(service.manifest()).resolves.toMatchObject({ plugins: [{ id: "updates", machineSpecific: true }] });
|
||||
await expect(service.plugins()).resolves.toMatchObject({ plugins: [{ id: "updates", machineSpecific: true, enabled: true }] });
|
||||
});
|
||||
|
||||
it("discovers Pi package plugins through an injected package provider", async () => {
|
||||
const packageDir = join(tempDir, "pkg");
|
||||
await writePlugin(packageDir, {
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface PiWebPluginManifestEntry {
|
||||
module: string;
|
||||
source: string;
|
||||
scope: PiWebPluginScope;
|
||||
machineSpecific: boolean;
|
||||
}
|
||||
|
||||
export interface ConfiguredPiPackage {
|
||||
@@ -38,6 +39,7 @@ interface PluginRecord {
|
||||
version: string;
|
||||
source: string;
|
||||
scope: PiWebPluginScope;
|
||||
machineSpecific: boolean;
|
||||
}
|
||||
|
||||
interface PiWebPluginServiceOptions {
|
||||
@@ -61,6 +63,7 @@ interface PiWebPackageConfig {
|
||||
interface PiWebPluginEntry {
|
||||
id: string;
|
||||
module: string;
|
||||
machineSpecific: boolean;
|
||||
}
|
||||
|
||||
type ArraylessPluginRecord = Omit<PluginRecord, "source" | "scope">;
|
||||
@@ -102,7 +105,7 @@ export class PiWebPluginService {
|
||||
return {
|
||||
plugins: (await this.plugins()).plugins
|
||||
.filter((plugin) => plugin.enabled)
|
||||
.map((plugin) => ({ id: plugin.id, module: plugin.module, source: plugin.source, scope: plugin.scope })),
|
||||
.map((plugin) => ({ id: plugin.id, module: plugin.module, source: plugin.source, scope: plugin.scope, machineSpecific: plugin.machineSpecific })),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -135,6 +138,7 @@ export class PiWebPluginService {
|
||||
module: `/pi-web-plugins/${encodeURIComponent(plugin.id)}/${plugin.entryFile}?v=${encodeURIComponent(plugin.version)}`,
|
||||
source: plugin.source,
|
||||
scope: plugin.scope,
|
||||
machineSpecific: plugin.machineSpecific,
|
||||
enabled: config.plugins?.[plugin.id]?.enabled !== false,
|
||||
};
|
||||
}
|
||||
@@ -228,7 +232,7 @@ async function discoverPluginEntries(root: string, config: PiWebPackageConfig):
|
||||
const entryPath = join(root, entry.module);
|
||||
const entryStat = await stat(entryPath).catch(() => undefined);
|
||||
if (entryStat?.isFile() !== true) throw new Error(`PI WEB plugin module not found for ${entry.id}: ${entry.module}`);
|
||||
plugins.push({ id: entry.id, root, entryFile: entry.module, version: String(Math.floor(entryStat.mtimeMs)) });
|
||||
plugins.push({ id: entry.id, root, entryFile: entry.module, version: String(Math.floor(entryStat.mtimeMs)), machineSpecific: entry.machineSpecific });
|
||||
}
|
||||
return plugins;
|
||||
}
|
||||
@@ -248,7 +252,7 @@ async function readPiWebPackageConfig(root: string): Promise<PiWebPackageConfig
|
||||
}
|
||||
|
||||
function parsePluginEntries(piWeb: Record<string, unknown>, packagePath: string): PiWebPluginEntry[] {
|
||||
if (piWeb["plugin"] !== undefined) throw new Error(`Unsupported PI WEB plugin metadata in ${packagePath}: use piWeb.plugins with { id, module } entries`);
|
||||
if (piWeb["plugin"] !== undefined) throw new Error(`Unsupported PI WEB plugin metadata in ${packagePath}: use piWeb.plugins with { id, module, machineSpecific? } entries`);
|
||||
const plugins = piWeb["plugins"];
|
||||
if (plugins === undefined) return [];
|
||||
if (!Array.isArray(plugins)) throw new Error(`PI WEB plugins must be an array in ${packagePath}`);
|
||||
@@ -259,10 +263,26 @@ function parsePluginEntries(piWeb: Record<string, unknown>, packagePath: string)
|
||||
const module = entry["module"];
|
||||
if (typeof id !== "string" || !isPiWebPluginId(id)) throw new Error(`Invalid PI WEB plugin id in ${packagePath}: ${String(id)}`);
|
||||
if (typeof module !== "string" || module === "") throw new Error(`Invalid PI WEB plugin module for ${id} in ${packagePath}`);
|
||||
return { id, module };
|
||||
return { id, module, machineSpecific: parseMachineSpecific(entry["machineSpecific"], packagePath, id) };
|
||||
});
|
||||
}
|
||||
|
||||
function parseMachineSpecific(value: unknown, packagePath: string, pluginId: string): boolean {
|
||||
if (value === undefined) return false;
|
||||
if (typeof value !== "boolean") throw new Error(`Invalid PI WEB plugin machineSpecific value for ${pluginId} in ${packagePath}: ${formatUnknownValue(value)}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function formatUnknownValue(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint" || typeof value === "symbol" || typeof value === "function" || value === null || value === undefined) return String(value);
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return Object.prototype.toString.call(value);
|
||||
}
|
||||
}
|
||||
|
||||
function addUnique(records: Map<string, PluginRecord>, plugin: PluginRecord): void {
|
||||
if (records.has(plugin.id)) {
|
||||
warnInvalidPlugin(plugin.source, `Duplicate PI WEB plugin id: ${plugin.id}`);
|
||||
|
||||
Reference in New Issue
Block a user