Load Pi Web plugins from server manifest

This commit is contained in:
Federico Jaramillo Martinez
2026-05-10 09:33:01 +02:00
parent 614db739db
commit 125dedf300
8 changed files with 260 additions and 2 deletions
+11 -2
View File
@@ -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<void> {
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;
}
+71
View File
@@ -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<PiWebPlugin[]> {
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<PluginManifest | undefined> {
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<string, unknown> {
return typeof value === "object" && value !== null;
}