Archived
Load Pi Web plugins from server manifest
This commit is contained in:
@@ -16,6 +16,7 @@
|
|||||||
"README.md",
|
"README.md",
|
||||||
"LICENSE",
|
"LICENSE",
|
||||||
"extensions",
|
"extensions",
|
||||||
|
"pi-web-plugins",
|
||||||
"docs/assets"
|
"docs/assets"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "@pi-web/info-plugin",
|
||||||
|
"private": true,
|
||||||
|
"piWeb": {
|
||||||
|
"id": "info",
|
||||||
|
"plugin": "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`
|
||||||
|
<section class="toolbar"><strong>Info</strong></section>
|
||||||
|
<section class="viewer">
|
||||||
|
<p><strong>Workspace</strong></p>
|
||||||
|
<p class="muted">${context.workspace.label}</p>
|
||||||
|
<p class="muted">${context.workspace.path}</p>
|
||||||
|
</section>
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
};
|
||||||
@@ -11,7 +11,7 @@ import { WorkspaceController } from "../controllers/workspaceController";
|
|||||||
import { KeyboardShortcutDispatcher } from "../keyboardShortcuts";
|
import { KeyboardShortcutDispatcher } from "../keyboardShortcuts";
|
||||||
import type { QualifiedContributionId, QualifiedWorkspacePanelContribution, PluginRuntimeContext } from "../plugins/types";
|
import type { QualifiedContributionId, QualifiedWorkspacePanelContribution, PluginRuntimeContext } from "../plugins/types";
|
||||||
import { corePlugin } from "../plugins/core";
|
import { corePlugin } from "../plugins/core";
|
||||||
import { examplePlugin } from "../plugins/example";
|
import { loadExternalPlugins } from "../plugins/external";
|
||||||
import { PluginRegistry } from "../plugins/registry";
|
import { PluginRegistry } from "../plugins/registry";
|
||||||
import { queryNamespace, readNamespacedString } from "../namespacedQueryArgs";
|
import { queryNamespace, readNamespacedString } from "../namespacedQueryArgs";
|
||||||
import { readRoute, writeRoute } from "../route";
|
import { readRoute, writeRoute } from "../route";
|
||||||
@@ -76,6 +76,7 @@ export class PiWebApp extends LitElement {
|
|||||||
window.addEventListener("popstate", this.onPopState);
|
window.addEventListener("popstate", this.onPopState);
|
||||||
window.addEventListener("keydown", this.onKeyDown);
|
window.addEventListener("keydown", this.onKeyDown);
|
||||||
this.sessions.connectStatusUpdates();
|
this.sessions.connectStatusUpdates();
|
||||||
|
void this.loadExternalPlugins();
|
||||||
void this.loadProjectsAndRestoreRoute();
|
void this.loadProjectsAndRestoreRoute();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,6 +216,15 @@ export class PiWebApp extends LitElement {
|
|||||||
return this.plugins.getActions(this.createPluginRuntimeContext());
|
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 {
|
private createPluginRuntimeContext(): PluginRuntimeContext {
|
||||||
return {
|
return {
|
||||||
state: this.state,
|
state: this.state,
|
||||||
@@ -272,7 +282,6 @@ export class PiWebApp extends LitElement {
|
|||||||
function createPluginRegistry(): PluginRegistry {
|
function createPluginRegistry(): PluginRegistry {
|
||||||
const registry = new PluginRegistry();
|
const registry = new PluginRegistry();
|
||||||
registry.register(corePlugin);
|
registry.register(corePlugin);
|
||||||
registry.register(examplePlugin);
|
|
||||||
return registry;
|
return registry;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import { registerSessionProxyRoutes } from "./sessiond/sessionProxyRoutes.js";
|
|||||||
import { registerWorkspaceExplorerRoutes } from "./workspaceExplorerRoutes.js";
|
import { registerWorkspaceExplorerRoutes } from "./workspaceExplorerRoutes.js";
|
||||||
import { registerGitRoutes } from "./gitRoutes.js";
|
import { registerGitRoutes } from "./gitRoutes.js";
|
||||||
import { registerTerminalProxyRoutes } from "./terminalProxyRoutes.js";
|
import { registerTerminalProxyRoutes } from "./terminalProxyRoutes.js";
|
||||||
|
import { PiWebPluginService } from "./piWebPluginService.js";
|
||||||
|
|
||||||
export interface AppDependencies {
|
export interface AppDependencies {
|
||||||
projects?: ProjectService;
|
projects?: ProjectService;
|
||||||
@@ -27,6 +28,15 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
|
|||||||
|
|
||||||
const projects = deps.projects ?? new ProjectService(new ProjectStore());
|
const projects = deps.projects ?? new ProjectService(new ProjectStore());
|
||||||
const workspaces = deps.workspaces ?? new WorkspaceService();
|
const workspaces = deps.workspaces ?? new WorkspaceService();
|
||||||
|
const piWebPlugins = new PiWebPluginService();
|
||||||
|
|
||||||
|
app.get("/pi-web-plugins/manifest.json", async () => 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());
|
app.get("/api/projects", async () => projects.list());
|
||||||
|
|
||||||
|
|||||||
@@ -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<PiWebPluginManifest> {
|
||||||
|
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<PluginRecord[]> {
|
||||||
|
const records = new Map<string, PluginRecord>();
|
||||||
|
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<PluginRecord[]> {
|
||||||
|
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<PluginRecord | undefined> {
|
||||||
|
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<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ export default defineConfig({
|
|||||||
...(config.allowedHosts === undefined ? {} : { allowedHosts: config.allowedHosts }),
|
...(config.allowedHosts === undefined ? {} : { allowedHosts: config.allowedHosts }),
|
||||||
proxy: {
|
proxy: {
|
||||||
"/api": { target: `http://localhost:${String(apiPort)}`, ws: true },
|
"/api": { target: `http://localhost:${String(apiPort)}`, ws: true },
|
||||||
|
"/pi-web-plugins": { target: `http://localhost:${String(apiPort)}` },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user