From 125dedf30050d71c33ad036b334721865db2e0c4 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sun, 10 May 2026 09:33:01 +0200 Subject: [PATCH] Load Pi Web plugins from server manifest --- package.json | 1 + pi-web-plugins/info/package.json | 8 ++ pi-web-plugins/info/pi-web-plugin.js | 35 ++++++++ src/client/src/components/PiWebApp.ts | 13 ++- src/client/src/plugins/external.ts | 71 +++++++++++++++ src/server/app.ts | 10 +++ src/server/piWebPluginService.ts | 123 ++++++++++++++++++++++++++ vite.config.ts | 1 + 8 files changed, 260 insertions(+), 2 deletions(-) create mode 100644 pi-web-plugins/info/package.json create mode 100644 pi-web-plugins/info/pi-web-plugin.js create mode 100644 src/client/src/plugins/external.ts create mode 100644 src/server/piWebPluginService.ts diff --git a/package.json b/package.json index acd3359..111ee09 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "README.md", "LICENSE", "extensions", + "pi-web-plugins", "docs/assets" ], "scripts": { diff --git a/pi-web-plugins/info/package.json b/pi-web-plugins/info/package.json new file mode 100644 index 0000000..c0bd321 --- /dev/null +++ b/pi-web-plugins/info/package.json @@ -0,0 +1,8 @@ +{ + "name": "@pi-web/info-plugin", + "private": true, + "piWeb": { + "id": "info", + "plugin": "pi-web-plugin.js" + } +} diff --git a/pi-web-plugins/info/pi-web-plugin.js b/pi-web-plugins/info/pi-web-plugin.js new file mode 100644 index 0000000..2766340 --- /dev/null +++ b/pi-web-plugins/info/pi-web-plugin.js @@ -0,0 +1,35 @@ +const { html } = globalThis.piWebPluginApi; + +export default { + id: "info", + name: "Info Plugin", + activate: () => ({ + actions: [ + { + id: "workspace.show-path", + title: "Show Current Workspace Path", + group: "Info", + enabled: (context) => context.state.selectedWorkspace !== undefined, + run: (context) => { + const path = context.state.selectedWorkspace?.path ?? "No workspace selected"; + window.alert(path); + }, + }, + ], + workspacePanels: [ + { + id: "workspace.info", + title: "Info", + order: 100, + render: (context) => html` +
Info
+
+

Workspace

+

${context.workspace.label}

+

${context.workspace.path}

+
+ `, + }, + ], + }), +}; diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index 02014ad..205363e 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -11,7 +11,7 @@ import { WorkspaceController } from "../controllers/workspaceController"; import { KeyboardShortcutDispatcher } from "../keyboardShortcuts"; import type { QualifiedContributionId, QualifiedWorkspacePanelContribution, PluginRuntimeContext } from "../plugins/types"; import { corePlugin } from "../plugins/core"; -import { examplePlugin } from "../plugins/example"; +import { loadExternalPlugins } from "../plugins/external"; import { PluginRegistry } from "../plugins/registry"; import { queryNamespace, readNamespacedString } from "../namespacedQueryArgs"; import { readRoute, writeRoute } from "../route"; @@ -76,6 +76,7 @@ export class PiWebApp extends LitElement { window.addEventListener("popstate", this.onPopState); window.addEventListener("keydown", this.onKeyDown); this.sessions.connectStatusUpdates(); + void this.loadExternalPlugins(); void this.loadProjectsAndRestoreRoute(); } @@ -215,6 +216,15 @@ export class PiWebApp extends LitElement { return this.plugins.getActions(this.createPluginRuntimeContext()); } + private async loadExternalPlugins(): Promise { + try { + for (const plugin of await loadExternalPlugins()) this.plugins.register(plugin); + this.requestUpdate(); + } catch (error) { + console.warn("Failed to load external Pi Web plugins", error); + } + } + private createPluginRuntimeContext(): PluginRuntimeContext { return { state: this.state, @@ -272,7 +282,6 @@ export class PiWebApp extends LitElement { function createPluginRegistry(): PluginRegistry { const registry = new PluginRegistry(); registry.register(corePlugin); - registry.register(examplePlugin); return registry; } diff --git a/src/client/src/plugins/external.ts b/src/client/src/plugins/external.ts new file mode 100644 index 0000000..cc0ab41 --- /dev/null +++ b/src/client/src/plugins/external.ts @@ -0,0 +1,71 @@ +import { html } from "lit"; +import type { PiWebPlugin } from "./types"; + +interface PluginManifestEntry { + module: string; +} + +interface PluginManifest { + plugins: PluginManifestEntry[]; +} + +declare global { + interface Window { + piWebPluginApi?: { + apiVersion: 1; + html: typeof html; + }; + } +} + +export async function loadExternalPlugins(manifestUrl = "/pi-web-plugins/manifest.json"): Promise { + window.piWebPluginApi = { apiVersion: 1, html }; + + const manifest = await fetchPluginManifest(manifestUrl); + if (manifest === undefined) return []; + + const plugins: PiWebPlugin[] = []; + for (const entry of manifest.plugins) { + try { + 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); + if (plugin !== undefined) plugins.push(plugin); + } catch (error) { + console.warn(`Failed to load Pi Web plugin ${entry.module}`, error); + } + } + return plugins; +} + +async function fetchPluginManifest(manifestUrl: string): Promise { + const response = await fetch(manifestUrl, { cache: "no-store" }); + if (response.status === 404) return undefined; + if (!response.ok) throw new Error(`Failed to load plugin manifest: ${response.statusText}`); + return parseManifest(await response.json()); +} + +function parseManifest(value: unknown): PluginManifest { + if (!isRecord(value) || !Array.isArray(value["plugins"])) throw new Error("Invalid plugin manifest"); + return { + plugins: value["plugins"].map((entry) => { + if (!isRecord(entry) || typeof entry["module"] !== "string" || entry["module"] === "") throw new Error("Invalid plugin manifest entry"); + return { module: entry["module"] }; + }), + }; +} + +function parsePluginModule(module: unknown, moduleUrl: string): PiWebPlugin | undefined { + if (!isRecord(module)) throw new Error(`Plugin module ${moduleUrl} did not export an object`); + const plugin = module["default"]; + if (!isPiWebPlugin(plugin)) throw new Error(`Plugin module ${moduleUrl} default export is not a PiWebPlugin`); + return plugin; +} + +function isPiWebPlugin(value: unknown): value is PiWebPlugin { + return isRecord(value) && typeof value["id"] === "string" && typeof value["name"] === "string" && typeof value["activate"] === "function"; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} diff --git a/src/server/app.ts b/src/server/app.ts index 555d3b8..f60429e 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -13,6 +13,7 @@ import { registerSessionProxyRoutes } from "./sessiond/sessionProxyRoutes.js"; import { registerWorkspaceExplorerRoutes } from "./workspaceExplorerRoutes.js"; import { registerGitRoutes } from "./gitRoutes.js"; import { registerTerminalProxyRoutes } from "./terminalProxyRoutes.js"; +import { PiWebPluginService } from "./piWebPluginService.js"; export interface AppDependencies { projects?: ProjectService; @@ -27,6 +28,15 @@ export async function buildApp(deps: AppDependencies = {}): Promise piWebPlugins.manifest()); + + app.get<{ Params: { pluginId: string; "*": string } }>("/pi-web-plugins/:pluginId/*", async (request, reply) => { + 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); + }); app.get("/api/projects", async () => projects.list()); diff --git a/src/server/piWebPluginService.ts b/src/server/piWebPluginService.ts new file mode 100644 index 0000000..f976565 --- /dev/null +++ b/src/server/piWebPluginService.ts @@ -0,0 +1,123 @@ +import { existsSync } from "node:fs"; +import { readdir, readFile, realpath, stat } from "node:fs/promises"; +import { dirname, join, relative, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; +import { piWebDataDir } from "../config.js"; + +const pluginIdPattern = /^[a-z][a-z0-9.-]*$/u; +const defaultEntryFile = "pi-web-plugin.js"; + +export interface PiWebPluginManifest { + plugins: { id: string; module: string }[]; +} + +interface PluginRecord { + id: string; + root: string; + entryFile: string; +} + +export class PiWebPluginService { + constructor(private readonly roots = defaultPluginRoots()) {} + + async manifest(): Promise { + const plugins = await this.discoverPlugins(); + return { + plugins: plugins.map((plugin) => ({ + id: plugin.id, + module: `/pi-web-plugins/${encodeURIComponent(plugin.id)}/${plugin.entryFile}`, + })), + }; + } + + async readAsset(pluginId: string, assetPath: string): Promise<{ content: Buffer; contentType: string } | undefined> { + if (!pluginIdPattern.test(pluginId)) return undefined; + const plugin = (await this.discoverPlugins()).find((candidate) => candidate.id === pluginId); + if (plugin === undefined) return undefined; + + const resolved = resolve(plugin.root, assetPath); + const [realRoot, realAsset] = await Promise.all([ + realpath(plugin.root), + realpath(resolved).catch(() => undefined), + ]); + if (realAsset === undefined || !isWithin(realRoot, realAsset)) return undefined; + + const assetStat = await stat(realAsset).catch(() => undefined); + if (assetStat?.isFile() !== true) return undefined; + + return { content: await readFile(realAsset), contentType: contentTypeFor(realAsset) }; + } + + private async discoverPlugins(): Promise { + const records = new Map(); + for (const root of this.roots) { + for (const plugin of await discoverRoot(root)) { + if (!records.has(plugin.id)) records.set(plugin.id, plugin); + } + } + return [...records.values()].sort((left, right) => left.id.localeCompare(right.id)); + } +} + +function defaultPluginRoots(): string[] { + const moduleDir = dirname(fileURLToPath(import.meta.url)); + return [ + join(moduleDir, "..", "..", "pi-web-plugins"), + join(piWebDataDir(), "plugins"), + ]; +} + +async function discoverRoot(root: string): Promise { + if (!existsSync(root)) return []; + const entries = await readdir(root, { withFileTypes: true }).catch(() => []); + const plugins: PluginRecord[] = []; + for (const entry of entries) { + if (!entry.isDirectory() || !pluginIdPattern.test(entry.name)) continue; + const plugin = await discoverPlugin(join(root, entry.name), entry.name); + if (plugin !== undefined) plugins.push(plugin); + } + return plugins; +} + +async function discoverPlugin(root: string, fallbackId: string): Promise { + const metadata = await readPluginPackage(root); + const id = metadata?.id ?? fallbackId; + const entryFile = metadata?.entryFile ?? defaultEntryFile; + if (!pluginIdPattern.test(id) || entryFile.includes("..") || entryFile.startsWith("/")) return undefined; + const entryPath = join(root, entryFile); + const entryStat = await stat(entryPath).catch(() => undefined); + if (entryStat?.isFile() !== true) return undefined; + return { id, root, entryFile }; +} + +async function readPluginPackage(root: string): Promise<{ id?: string; entryFile?: string } | undefined> { + const packagePath = join(root, "package.json"); + const content = await readFile(packagePath, "utf8").catch(() => undefined); + if (content === undefined) return undefined; + const parsed: unknown = JSON.parse(content); + if (!isRecord(parsed)) return undefined; + const pi = parsed["pi"]; + const piWeb = isRecord(parsed["piWeb"]) ? parsed["piWeb"] : isRecord(pi) && isRecord(pi["piWeb"]) ? pi["piWeb"] : undefined; + if (!isRecord(piWeb)) return undefined; + return { + ...(typeof piWeb["id"] === "string" ? { id: piWeb["id"] } : {}), + ...(typeof piWeb["plugin"] === "string" ? { entryFile: piWeb["plugin"] } : {}), + }; +} + +function isWithin(root: string, candidate: string): boolean { + const rel = relative(root, candidate); + return rel === "" || (!rel.startsWith("..") && !rel.startsWith(sep)); +} + +function contentTypeFor(path: string): string { + if (path.endsWith(".js")) return "application/javascript; charset=utf-8"; + if (path.endsWith(".json")) return "application/json; charset=utf-8"; + if (path.endsWith(".css")) return "text/css; charset=utf-8"; + if (path.endsWith(".html")) return "text/html; charset=utf-8"; + return "application/octet-stream"; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/vite.config.ts b/vite.config.ts index 1326a8f..18ef412 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -29,6 +29,7 @@ export default defineConfig({ ...(config.allowedHosts === undefined ? {} : { allowedHosts: config.allowedHosts }), proxy: { "/api": { target: `http://localhost:${String(apiPort)}`, ws: true }, + "/pi-web-plugins": { target: `http://localhost:${String(apiPort)}` }, }, }, });