feat: load machine-scoped remote plugins

This commit is contained in:
Federico Jaramillo Martinez
2026-06-05 09:29:48 +02:00
parent 9c3dafc4d4
commit b9be7de206
13 changed files with 466 additions and 24 deletions
+32 -3
View File
@@ -19,7 +19,7 @@ import { SessionStorageWorkspaceSelectionMemory } from "../controllers/workspace
import { KeyboardShortcutDispatcher } from "../keyboardShortcuts";
import { selectedMachineId } from "../controllers/types";
import { RealtimeSocket } from "../sessionSocket";
import type { PluginMachine, QualifiedContributionId, QualifiedThemeContribution, QualifiedThemePairContribution, QualifiedWorkspacePanelContribution, PluginRuntimeContext, TerminalCommandRunsInternalRuntime, WorkspacePanelContext } from "../plugins/types";
import type { PiWebPluginRegistration, PluginMachine, QualifiedContributionId, QualifiedThemeContribution, QualifiedThemePairContribution, QualifiedWorkspacePanelContribution, PluginRuntimeContext, TerminalCommandRunsInternalRuntime, WorkspacePanelContext } from "../plugins/types";
import { CLASSIC_THEME_ID, DEFAULT_THEME_PREFERENCE, applyPiWebTheme, findThemePairForTheme, readStoredThemePreference, resolveThemePreference, writeStoredThemePreference, type ThemePreference, type ThemePreferenceResolution } from "../theme";
import { corePlugin } from "../plugins/core";
import { themePackPlugin } from "../plugins/themes";
@@ -145,6 +145,8 @@ export class PiWebApp extends LitElement {
private routeRestoreDepth = 0;
private restoringRouteTerminalId: string | undefined;
private readonly plugins = createPluginRegistry();
private readonly loadedMachinePluginIds = new Set<string>();
private readonly machinePluginLoadPromises = new Map<string, Promise<void>>();
private themePreference: ThemePreference = readStoredThemePreference() ?? DEFAULT_THEME_PREFERENCE;
@state() private activeThemeId: QualifiedContributionId = CLASSIC_THEME_ID;
@state() private isRefreshingApp = false;
@@ -331,6 +333,8 @@ export class PiWebApp extends LitElement {
this.restoringRouteTerminalId = routeSurface.selectedTerminalId;
try {
await this.restoreRouteMachine(route, false);
const selectedMachinePluginLoad = this.loadPluginsForSelectedMachine();
if (route.tool?.startsWith("machine.") === true) await selectedMachinePluginLoad;
if (!this.isCurrentRouteRestore(restoreSeq)) return;
this.setState({
workspaceTool: route.tool ?? this.state.workspaceTool,
@@ -710,6 +714,7 @@ export class PiWebApp extends LitElement {
this.connectRealtime();
this.activeTerminalIds.clear();
this.git.updatePolling();
void this.loadPluginsForSelectedMachine();
}
private refreshSelectedWorkspaceTool(tool: QualifiedContributionId): void {
@@ -945,8 +950,30 @@ export class PiWebApp extends LitElement {
}
private async loadExternalPlugins(): Promise<void> {
await this.registerExternalPlugins("PI WEB plugins", () => loadExternalPlugins());
}
private async loadPluginsForSelectedMachine(): Promise<void> {
const machine = this.state.selectedMachine;
if (machine?.kind !== "remote") return;
await this.loadPluginsForMachine(machine);
}
private async loadPluginsForMachine(machine: Machine): Promise<void> {
if (machine.kind !== "remote" || this.loadedMachinePluginIds.has(machine.id)) return;
const existing = this.machinePluginLoadPromises.get(machine.id);
if (existing !== undefined) return existing;
const load = this.registerExternalPlugins(`PI WEB plugins from ${machine.name}`, () => loadExternalPlugins(`/api/machines/${encodeURIComponent(machine.id)}/pi-web-plugins/manifest.json`, { machineId: machine.id }))
.then((loaded) => { if (loaded) this.loadedMachinePluginIds.add(machine.id); })
.finally(() => { this.machinePluginLoadPromises.delete(machine.id); });
this.machinePluginLoadPromises.set(machine.id, load);
await load;
}
private async registerExternalPlugins(label: string, load: () => Promise<PiWebPluginRegistration[]>): Promise<boolean> {
try {
const registrations = await loadExternalPlugins();
const registrations = await load();
for (const registration of registrations) {
try {
this.plugins.register(registration);
@@ -956,8 +983,10 @@ export class PiWebApp extends LitElement {
}
this.applyPreferredTheme(false);
this.requestUpdate();
return true;
} catch (error) {
console.warn("Failed to load external PI WEB plugins", error);
console.warn(`Failed to load ${label}`, error);
return false;
}
}
+11 -2
View File
@@ -1,3 +1,4 @@
import { machineScopedPluginId } from "../../../shared/machinePluginIds";
import type { PiWebPlugin, PiWebPluginRegistration } from "./types";
interface PluginManifestEntry {
@@ -9,7 +10,11 @@ interface PluginManifest {
plugins: PluginManifestEntry[];
}
export async function loadExternalPlugins(manifestUrl = "/pi-web-plugins/manifest.json"): Promise<PiWebPluginRegistration[]> {
export interface LoadExternalPluginsOptions {
machineId?: string;
}
export async function loadExternalPlugins(manifestUrl = "/pi-web-plugins/manifest.json", options: LoadExternalPluginsOptions = {}): Promise<PiWebPluginRegistration[]> {
const manifest = await fetchPluginManifest(manifestUrl);
if (manifest === undefined) return [];
@@ -19,7 +24,11 @@ export async function loadExternalPlugins(manifestUrl = "/pi-web-plugins/manifes
const moduleUrl = new URL(entry.module, new URL(manifestUrl, window.location.href)).toString();
const module: unknown = await import(/* @vite-ignore */ moduleUrl);
const plugin = parsePluginModule(module, moduleUrl);
registrations.push({ id: entry.id, plugin });
registrations.push({
id: options.machineId === undefined ? entry.id : machineScopedPluginId(options.machineId, entry.id),
plugin,
...(options.machineId === undefined ? {} : { machineId: options.machineId, sourcePluginId: entry.id }),
});
} catch (error) {
console.warn(`Failed to load PI WEB plugin ${entry.module}`, error);
}
+72 -1
View File
@@ -1,11 +1,13 @@
import { html } from "lit";
import { describe, expect, it, vi } from "vitest";
import type { SessionInfo, Workspace } from "../api";
import { initialAppState, type AppState } from "../appState";
import { markCachedNewSessionInfo } from "../cachedNewSessions";
import { machineScopedPluginId } from "../../../shared/machinePluginIds";
import { corePlugin } from "./core";
import { PluginRegistry } from "./registry";
import { themePackPlugin } from "./themes";
import type { PluginRuntimeContext, ThemeTokens } from "./types";
import type { PluginRuntimeContext, ThemeTokens, WorkspacePanelContext } from "./types";
function createContext(statePatch: Partial<AppState> = {}) {
const calls: string[] = [];
@@ -289,12 +291,81 @@ describe("PluginRegistry", () => {
{ type: "text", text: "last" },
]);
});
it("only exposes machine-scoped plugin contributions for their machine", () => {
const registry = new PluginRegistry();
const pluginId = machineScopedPluginId("remote-1", "project-tools");
const workspace = testWorkspace();
registry.register({
id: pluginId,
machineId: "remote-1",
sourcePluginId: "project-tools",
plugin: {
apiVersion: 1,
name: "Project Tools",
activate: () => ({
contributions: {
actions: [{ id: "do-thing", title: "Do Thing", run: () => undefined }],
workspacePanels: [{ id: "workspace.tools", title: "Tools", render: () => html`<p>Tools</p>` }],
workspaceLabels: [{ id: "badge", items: () => [{ type: "text", text: "remote" }] }],
themes: [{ id: "remote-theme", name: "Remote Theme", colorScheme: "dark", tokens: testThemeTokens() }],
},
}),
},
});
expect(registry.getActions(createContext().context).map((action) => action.id)).not.toContain(`${pluginId}:do-thing`);
expect(registry.getActions(createContext({ selectedMachine: testMachine("remote-1") }).context).map((action) => action.id)).toContain(`${pluginId}:do-thing`);
const panel = registry.getWorkspacePanels().find((candidate) => candidate.id === `${pluginId}:workspace.tools`);
expect(panel?.visible?.(createWorkspacePanelContext("local"))).toBe(false);
expect(panel?.visible?.(createWorkspacePanelContext("remote-1"))).toBe(true);
expect(registry.getWorkspaceLabelItems(initialAppState(), workspace)).toEqual([]);
expect(registry.getWorkspaceLabelItems({ ...initialAppState(), selectedMachine: testMachine("remote-1") }, workspace)).toEqual([{ type: "text", text: "remote" }]);
expect(registry.getThemes()).toEqual([]);
});
});
function testWorkspace(patch: Partial<Workspace> = {}): Workspace {
return { id: "w1", projectId: "p1", path: "/tmp/project", label: "main", isMain: true, isGitRepo: true, isGitWorktree: false, ...patch };
}
function createWorkspacePanelContext(machineId: string): WorkspacePanelContext {
const workspace = testWorkspace();
return {
machine: { id: machineId, name: machineId, kind: machineId === "local" ? "local" : "remote" },
workspace,
state: { ...initialAppState(), selectedMachine: testMachine(machineId) },
files: { readFile: vi.fn() },
terminal: { open: vi.fn(), runCommand: vi.fn() },
host: { requestRender: vi.fn() },
fileTree: [],
expandedDirs: {},
selectedFilePath: undefined,
selectedFileContent: undefined,
fileTreeStale: false,
gitStatus: undefined,
selectedDiffPath: undefined,
selectedDiff: undefined,
selectedStagedDiff: undefined,
gitStale: false,
activeTerminalCount: 0,
selectedTerminalId: undefined,
terminalAutoStart: false,
onRefreshFiles: vi.fn(),
onExpandDir: vi.fn(),
onSelectFile: vi.fn(),
onRefreshGit: vi.fn(),
onSelectDiff: vi.fn(),
onSelectTerminal: vi.fn(),
};
}
function testMachine(id: string) {
return { id, name: id, kind: id === "local" ? "local" as const : "remote" as const, createdAt: "2026-05-20T00:00:00.000Z", updatedAt: "2026-05-20T00:00:00.000Z" };
}
function testSession(patch: Partial<SessionInfo> = {}): SessionInfo {
return {
id: "s1",
+36 -13
View File
@@ -12,6 +12,7 @@ type RegisteredPluginAction = Omit<PluginAction, "id"> & {
id: QualifiedContributionId;
pluginId: string;
localId: string;
machineId?: string;
};
export class PluginRegistry {
@@ -33,21 +34,24 @@ export class PluginRegistry {
if (apiVersion !== 1) throw new Error(`Unsupported plugin API version for ${id}: ${String(apiVersion)}`);
const result = plugin.activate({ apiVersion: 1, pluginId: id, html, svg });
const contributions = result.contributions;
for (const action of contributions.actions ?? []) this.actions.push(this.qualifyAction(id, action));
for (const panel of contributions.workspacePanels ?? []) this.workspacePanels.push(this.qualifyWorkspacePanel(id, panel));
for (const contribution of contributions.workspaceLabels ?? []) this.workspaceLabels.push(this.qualifyWorkspaceLabelContribution(id, contribution));
for (const theme of contributions.themes ?? []) this.themes.push(this.qualifyTheme(id, theme));
for (const pair of contributions.themePairs ?? []) this.themePairs.push(this.qualifyThemePair(id, pair));
for (const action of contributions.actions ?? []) this.actions.push(this.qualifyAction(id, action, registration.machineId));
for (const panel of contributions.workspacePanels ?? []) this.workspacePanels.push(this.qualifyWorkspacePanel(id, panel, registration.machineId));
for (const contribution of contributions.workspaceLabels ?? []) this.workspaceLabels.push(this.qualifyWorkspaceLabelContribution(id, contribution, registration.machineId));
if (registration.machineId === undefined) {
for (const theme of contributions.themes ?? []) this.themes.push(this.qualifyTheme(id, theme));
for (const pair of contributions.themePairs ?? []) this.themePairs.push(this.qualifyThemePair(id, pair));
}
}
getActions(context: PluginRuntimeContext): QualifiedPluginAction[] {
return this.actions.map((action) => {
return this.actions.filter((action) => isActiveForMachine(action.machineId, runtimeContextMachineId(context))).map((action) => {
const scopedContext = pluginRuntimeContextFor(context, action.pluginId);
const enabled = action.enabled?.(scopedContext);
const qualified: QualifiedPluginAction = {
id: action.id,
pluginId: action.pluginId,
localId: action.localId,
...(action.machineId === undefined ? {} : { machineId: action.machineId }),
title: action.title,
run: () => action.run(scopedContext),
};
@@ -81,12 +85,12 @@ export class PluginRegistry {
});
}
private qualifyAction(pluginId: string, action: PluginAction): RegisteredPluginAction {
private qualifyAction(pluginId: string, action: PluginAction, machineId: string | undefined): RegisteredPluginAction {
const id = this.qualify(pluginId, action.id);
return { ...action, id, pluginId, localId: action.id };
return { ...action, id, pluginId, localId: action.id, ...(machineId === undefined ? {} : { machineId }) };
}
private qualifyWorkspacePanel(pluginId: string, panel: WorkspacePanelContribution): QualifiedWorkspacePanelContribution {
private qualifyWorkspacePanel(pluginId: string, panel: WorkspacePanelContribution, machineId: string | undefined): QualifiedWorkspacePanelContribution {
const id = this.qualify(pluginId, panel.id);
const badge = panel.badge;
const visible = panel.visible;
@@ -95,15 +99,26 @@ export class PluginRegistry {
id,
pluginId,
localId: panel.id,
...(visible === undefined ? {} : { visible: (context: WorkspacePanelContext) => visible(workspacePanelContextFor(context, pluginId)) }),
...(badge === undefined ? {} : { badge: (context: WorkspacePanelContext) => badge(workspacePanelContextFor(context, pluginId)) }),
...(machineId === undefined ? {} : { machineId }),
visible: (context: WorkspacePanelContext) => isActiveForMachine(machineId, context.machine.id) && (visible?.(workspacePanelContextFor(context, pluginId)) ?? true),
...(badge === undefined ? {} : { badge: (context: WorkspacePanelContext) => isActiveForMachine(machineId, context.machine.id) ? badge(workspacePanelContextFor(context, pluginId)) : undefined }),
render: (context: WorkspacePanelContext) => panel.render(workspacePanelContextFor(context, pluginId)),
};
}
private qualifyWorkspaceLabelContribution(pluginId: string, contribution: WorkspaceLabelContribution): QualifiedWorkspaceLabelContribution {
private qualifyWorkspaceLabelContribution(pluginId: string, contribution: WorkspaceLabelContribution, machineId: string | undefined): QualifiedWorkspaceLabelContribution {
const id = this.qualify(pluginId, contribution.id);
return { ...contribution, id, pluginId, localId: contribution.id };
const visible = contribution.visible;
const items = contribution.items;
return {
...contribution,
id,
pluginId,
localId: contribution.id,
...(machineId === undefined ? {} : { machineId }),
visible: (context) => isActiveForMachine(machineId, context.machine.id) && (visible?.(context) ?? true),
items: (context) => isActiveForMachine(machineId, context.machine.id) ? items(context) : [],
};
}
private qualifyTheme(pluginId: string, theme: ThemeContribution): QualifiedThemeContribution {
@@ -163,6 +178,14 @@ export function installWorkspacePanelScope(context: WorkspacePanelContext, scope
return context;
}
function isActiveForMachine(machineId: string | undefined, selectedMachineId: string): boolean {
return machineId === undefined || machineId === selectedMachineId;
}
function runtimeContextMachineId(context: PluginRuntimeContext): string {
return context.state.selectedMachine?.id ?? "local";
}
function pluginMachineFromState(state: Pick<AppState, "selectedMachine">): PluginMachine {
const machine = state.selectedMachine;
if (machine !== undefined) return { id: machine.id, name: machine.name, kind: machine.kind };
+5
View File
@@ -12,6 +12,8 @@ export type SvgTemplateTag = (strings: TemplateStringsArray, ...values: unknown[
export interface PiWebPluginRegistration {
id: PluginId;
plugin: PiWebPlugin;
machineId?: string;
sourcePluginId?: PluginId;
}
export interface PiWebPlugin {
@@ -112,6 +114,7 @@ export interface PluginAction {
export interface QualifiedPluginAction extends AppAction {
pluginId: PluginId;
localId: LocalContributionId;
machineId?: string;
}
export interface WorkspacePanelContext {
@@ -159,6 +162,7 @@ export interface QualifiedWorkspacePanelContribution extends WorkspacePanelContr
id: QualifiedContributionId;
pluginId: PluginId;
localId: LocalContributionId;
machineId?: string;
}
export interface WorkspaceLabelContext {
@@ -272,4 +276,5 @@ export interface QualifiedWorkspaceLabelContribution extends WorkspaceLabelContr
id: QualifiedContributionId;
pluginId: PluginId;
localId: LocalContributionId;
machineId?: string;
}
+32
View File
@@ -12,6 +12,7 @@ import { MachineService } from "./machines/machineService.js";
import { MachineStore } from "./machines/machineStore.js";
import { WorkspaceService } from "./workspaces/workspaceService.js";
import type { SessionProxyDaemon } from "./sessiond/sessionProxyRoutes.js";
import { machineScopedPluginId } from "../shared/machinePluginIds.js";
import { MAX_IMAGE_PREVIEW_BYTES } from "../shared/workspaceFiles.js";
import type { Project, Workspace } from "./types.js";
@@ -311,6 +312,37 @@ describe("buildApp", () => {
expect(missingResponse.statusCode).toBe(404);
});
it("rewrites and proxies remote machine plugin manifests and assets", async () => {
const addResponse = await 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({
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" }] },
}));
const request = vi.fn(() => Promise.resolve({
statusCode: 200,
headers: { "content-type": "application/javascript", "set-cookie": "secret=1" },
body: Readable.from(["export default {};"]),
}));
remoteClient = fakeRemoteClient({ requestJson, request });
const manifestResponse = await app.inject({ method: "GET", url: `/api/machines/${remote.id}/pi-web-plugins/manifest.json` });
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" }],
});
expect(requestJson).toHaveBeenCalledWith("GET", "/pi-web-plugins/manifest.json", undefined, { timeoutMs: 10000 });
const assetResponse = await app.inject({ method: "GET", url: `/pi-web-plugins/${scopedPluginId}/pi-web-plugin.js?v=123` });
expect(assetResponse.statusCode).toBe(200);
expect(assetResponse.headers["content-type"]).toContain("application/javascript");
expect(assetResponse.headers["set-cookie"]).toBeUndefined();
expect(assetResponse.body).toBe("export default {};");
expect(request).toHaveBeenCalledWith("GET", "/pi-web-plugins/remote-tools/pi-web-plugin.js?v=123");
});
it("returns stable errors for invalid project requests", async () => {
const addResponse = await app.inject({
method: "POST",
+4
View File
@@ -21,6 +21,7 @@ import { getPiWebStatus, getPiWebVersionStatus } from "./piWebStatus.js";
import { MachineService } from "./machines/machineService.js";
import { registerMachineRoutes } from "./machines/machineRoutes.js";
import { registerMachineProxyRoutes } from "./machines/machineProxyRoutes.js";
import { proxyMachinePluginAsset, registerMachinePluginProxyRoutes } from "./machines/machinePluginProxyRoutes.js";
export interface AppDependencies {
projects?: ProjectService;
@@ -96,6 +97,8 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
app.get("/pi-web-plugins/manifest.json", async () => piWebPlugins.manifest());
app.get<{ Params: { pluginId: string; "*": string } }>("/pi-web-plugins/:pluginId/*", async (request, reply) => {
if (await proxyMachinePluginAsset(machines, request.params.pluginId, request.params["*"], request.url, reply)) return;
const asset = await piWebPlugins.readAsset(request.params.pluginId, request.params["*"]);
if (asset === undefined) return reply.code(404).send({ error: "Plugin asset not found" });
return reply.type(asset.contentType).send(asset.content);
@@ -107,6 +110,7 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
registerConfigRoutes(app, deps.config);
registerMachineRoutes(app, machines);
registerMachinePluginProxyRoutes(app, machines);
registerLocalProjectRoutes(app, projects, workspaces, "/api");
registerLocalProjectRoutes(app, projects, workspaces, "/api/machines/local");
@@ -0,0 +1,153 @@
import type { FastifyInstance, FastifyReply } from "fastify";
import { machineScopedPluginId, parseMachineScopedPluginId, type MachineScopedPluginIdParts } from "../../shared/machinePluginIds.js";
import { isPiWebPluginId } from "../../shared/pluginIds.js";
import { RemoteMachineRequestError, type MachineClient } from "./machineClient.js";
import { MachineService } from "./machineService.js";
interface RemotePluginManifestEntry {
id: string;
module: string;
source?: string;
scope?: string;
}
interface RemotePluginManifest {
plugins: RemotePluginManifestEntry[];
}
interface MachinePluginProxyMachines {
remoteClient(id: string): Promise<MachineClient | undefined>;
}
const MACHINE_PLUGIN_MANIFEST_TIMEOUT_MS = 10_000;
const SAFE_RESPONSE_HEADERS = new Set([
"content-type",
"content-length",
"cache-control",
"last-modified",
"etag",
"content-security-policy",
"x-content-type-options",
]);
export function registerMachinePluginProxyRoutes(app: FastifyInstance, machines: MachinePluginProxyMachines = new MachineService()): void {
app.get<{ Params: { machineId: string } }>("/api/machines/:machineId/pi-web-plugins/manifest.json", async (request, reply) => {
if (request.params.machineId === "local") return { plugins: [] };
const client = await machines.remoteClient(request.params.machineId);
if (client === undefined) return reply.code(404).send({ error: "Machine not found" });
try {
const response = await client.requestJson("GET", "/pi-web-plugins/manifest.json", undefined, { timeoutMs: MACHINE_PLUGIN_MANIFEST_TIMEOUT_MS });
if (response.statusCode === 404) return { plugins: [] };
if (response.statusCode < 200 || response.statusCode >= 300) return await reply.code(response.statusCode).send(response.body);
return rewriteRemotePluginManifest(request.params.machineId, parseRemoteManifest(response.body));
} catch (error) {
return sendGatewayError(reply, request.params.machineId, error);
}
});
}
export async function proxyMachinePluginAsset(machines: MachinePluginProxyMachines, scopedPluginId: string, assetPath: string, requestUrl: string, reply: FastifyReply): Promise<boolean> {
const remotePlugin = parseMachineScopedPluginId(scopedPluginId);
if (remotePlugin === undefined) return false;
const client = await machines.remoteClient(remotePlugin.machineId);
if (client === undefined) {
await reply.code(404).send({ error: "Machine not found" });
return true;
}
try {
const upstream = await client.request("GET", remotePluginAssetRequestPath(remotePlugin, assetPath, requestUrl));
reply.code(upstream.statusCode);
applySafeHeaders(reply, upstream.headers);
if (upstream.body === undefined) await reply.send();
else await reply.send(upstream.body);
return true;
} catch (error) {
sendGatewayError(reply, remotePlugin.machineId, error);
return true;
}
}
function rewriteRemotePluginManifest(machineId: string, manifest: RemotePluginManifest): RemotePluginManifest {
return {
plugins: manifest.plugins.flatMap((plugin) => {
const modulePath = remotePluginModulePath(plugin.id, plugin.module);
if (modulePath === undefined) return [];
return [{
...plugin,
module: `/pi-web-plugins/${encodeURIComponent(machineScopedPluginId(machineId, plugin.id))}/${modulePath.path}${modulePath.query}`,
}];
}),
};
}
function remotePluginModulePath(pluginId: string, module: string): { path: string; query: string } | undefined {
if (!isPiWebPluginId(pluginId)) return undefined;
try {
const url = new URL(module, "http://pi-web.local");
const prefix = `/pi-web-plugins/${encodeURIComponent(pluginId)}/`;
if (url.pathname.startsWith(prefix)) {
return { path: url.pathname.slice(prefix.length), query: url.search };
}
if (!module.startsWith("/") && !/^https?:\/\//iu.test(module)) {
const [path, query = ""] = module.split("?", 2);
if (path !== undefined && path !== "") return { path, query: query === "" ? "" : `?${query}` };
}
} catch {
return undefined;
}
return undefined;
}
function remotePluginAssetRequestPath(remotePlugin: MachineScopedPluginIdParts, assetPath: string, requestUrl: string): string {
const query = requestUrl.includes("?") ? requestUrl.slice(requestUrl.indexOf("?")) : "";
return `/pi-web-plugins/${encodeURIComponent(remotePlugin.pluginId)}/${encodePathSegments(assetPath)}${query}`;
}
function encodePathSegments(path: string): string {
return path.split("/").map((segment) => encodeURIComponent(segment)).join("/");
}
function parseRemoteManifest(value: unknown): RemotePluginManifest {
if (!isRecord(value) || !Array.isArray(value["plugins"])) throw new Error("Invalid remote PI WEB plugin manifest");
return {
plugins: value["plugins"].map((entry) => {
if (!isRecord(entry) || typeof entry["id"] !== "string" || !isPiWebPluginId(entry["id"]) || typeof entry["module"] !== "string" || entry["module"] === "") {
throw new Error("Invalid remote PI WEB plugin manifest entry");
}
return {
id: entry["id"],
module: entry["module"],
...(typeof entry["source"] === "string" ? { source: entry["source"] } : {}),
...(typeof entry["scope"] === "string" ? { scope: entry["scope"] } : {}),
};
}),
};
}
function applySafeHeaders(reply: FastifyReply, headers: Record<string, string | string[] | undefined>): void {
for (const [name, value] of Object.entries(headers)) {
if (value === undefined) continue;
if (!SAFE_RESPONSE_HEADERS.has(name.toLowerCase())) continue;
reply.header(name, value);
}
}
function sendGatewayError(reply: FastifyReply, machineId: string, error: unknown): FastifyReply {
const statusCode = error instanceof RemoteMachineRequestError ? error.statusCode : 502;
const label = statusCode === 504 ? "Remote machine timeout" : "Remote machine unavailable";
return reply.code(statusCode).send({
error: label,
machineId,
statusCode,
detail: error instanceof Error ? error.message : String(error),
});
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
+15
View File
@@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import { machineScopedPluginId, parseMachineScopedPluginId } from "./machinePluginIds";
describe("machine-scoped plugin ids", () => {
it("encodes machine ids into valid plugin ids and decodes them", () => {
const scoped = machineScopedPluginId("550e8400-e29b-41d4-a716-446655440000", "project-tools");
expect(scoped).toMatch(/^machine\.[0-9a-f]+\.project-tools$/u);
expect(parseMachineScopedPluginId(scoped)).toEqual({ machineId: "550e8400-e29b-41d4-a716-446655440000", pluginId: "project-tools" });
});
it("leaves normal plugin ids unparsed", () => {
expect(parseMachineScopedPluginId("project-tools")).toBeUndefined();
});
});
+47
View File
@@ -0,0 +1,47 @@
import { isPiWebPluginId } from "./pluginIds.js";
const MACHINE_PLUGIN_ID_PREFIX = "machine.";
export interface MachineScopedPluginIdParts {
machineId: string;
pluginId: string;
}
export function machineScopedPluginId(machineId: string, pluginId: string): string {
if (machineId === "") throw new Error("Machine id is required");
if (!isPiWebPluginId(pluginId)) throw new Error(`Invalid PI WEB plugin id: ${pluginId}`);
return `${MACHINE_PLUGIN_ID_PREFIX}${stringToHex(machineId)}.${pluginId}`;
}
export function parseMachineScopedPluginId(pluginId: string): MachineScopedPluginIdParts | undefined {
if (!pluginId.startsWith(MACHINE_PLUGIN_ID_PREFIX)) return undefined;
const rest = pluginId.slice(MACHINE_PLUGIN_ID_PREFIX.length);
const separator = rest.indexOf(".");
if (separator < 1) return undefined;
const encodedMachineId = rest.slice(0, separator);
const sourcePluginId = rest.slice(separator + 1);
if (!isHexString(encodedMachineId) || !isPiWebPluginId(sourcePluginId)) return undefined;
const machineId = hexToString(encodedMachineId);
if (machineId === "") return undefined;
return { machineId, pluginId: sourcePluginId };
}
function stringToHex(value: string): string {
return [...new TextEncoder().encode(value)]
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("");
}
function hexToString(value: string): string {
const bytes = new Uint8Array(value.length / 2);
for (let index = 0; index < value.length; index += 2) {
bytes[index / 2] = Number.parseInt(value.slice(index, index + 2), 16);
}
return new TextDecoder().decode(bytes);
}
function isHexString(value: string): boolean {
return value.length > 0 && value.length % 2 === 0 && /^[0-9a-f]+$/u.test(value);
}