Archived
feat: load machine-scoped remote plugins
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user