mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
feat: load plugin frontends at runtime and remove plugin ids from the shell
This commit is contained in:
1 parent
49f0286a14
commit
e88d5a52e3
721 files changed
+54265
-44416
No files matched your search
@@ -0,0 +1,404 @@
|
||||
import type { ResourceKey } from "i18next";
|
||||
import type {
|
||||
FrontendModule,
|
||||
PluginManifest,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import i18n, { setPluginLocaleResolver } from "@/i18n/i18n";
|
||||
import { getPlugins, type PluginSummary } from "@/api/plugins-api";
|
||||
import { getBackendUrl } from "@/main-axios";
|
||||
import { createPluginApp, type PluginAppHandle } from "./app";
|
||||
import { installPluginHostBridge } from "./bridge";
|
||||
import {
|
||||
getPluginRecord,
|
||||
getPluginStoreState,
|
||||
markPluginsSettled,
|
||||
setFrontendState,
|
||||
setPluginSummaries,
|
||||
} from "./plugin-store";
|
||||
import { workspaceFrontends, workspaceLocales } from "./workspace-plugins";
|
||||
import { syncHostPluginsTab } from "@/settings/host-plugins-tab";
|
||||
|
||||
/**
|
||||
* Loads plugin frontends into the running shell.
|
||||
*
|
||||
* After login, and whenever plugin state may have changed, it fetches
|
||||
* GET /plugins and reconciles: plugins that are now enabled are imported and
|
||||
* activated in dependency order, plugins that are no longer enabled are
|
||||
* deactivated and everything they registered is removed. No reload either way.
|
||||
*
|
||||
* A plugin whose import or activate throws is marked failed and cleaned up;
|
||||
* the others carry on.
|
||||
*/
|
||||
|
||||
interface ActivePlugin {
|
||||
id: string;
|
||||
assetVersion: string | null;
|
||||
handle: PluginAppHandle;
|
||||
module: FrontendModule;
|
||||
cssLink: HTMLLinkElement | null;
|
||||
}
|
||||
|
||||
export interface PluginLoaderDeps {
|
||||
fetchPlugins: () => Promise<PluginSummary[]>;
|
||||
importFrontend: (summary: PluginSummary) => Promise<FrontendModule>;
|
||||
loadLocale: (
|
||||
summary: PluginSummary,
|
||||
file: string,
|
||||
) => Promise<ResourceKey | null>;
|
||||
/** Adds the plugin stylesheet; returns the element to remove later. */
|
||||
injectCss: (summary: PluginSummary) => HTMLLinkElement | null;
|
||||
}
|
||||
|
||||
const active = new Map<string, ActivePlugin>();
|
||||
/** Bundle version that failed, so the same broken bundle is not retried. */
|
||||
const failedVersions = new Map<string, string | null>();
|
||||
let queue: Promise<void> = Promise.resolve();
|
||||
let deps: PluginLoaderDeps | null = null;
|
||||
let started = false;
|
||||
let guestMode = false;
|
||||
|
||||
function assetUrl(summary: PluginSummary, file: string): string {
|
||||
const version = summary.assetVersion
|
||||
? `?v=${encodeURIComponent(summary.assetVersion)}`
|
||||
: "";
|
||||
return getBackendUrl(
|
||||
`/plugin-assets/${encodeURIComponent(summary.id)}/${file}${version}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Absolute URL, so import() resolves it against the page, not this module. */
|
||||
function absolute(url: string): string {
|
||||
return new URL(url, window.location.href).href;
|
||||
}
|
||||
|
||||
const defaultDeps: PluginLoaderDeps = {
|
||||
fetchPlugins: getPlugins,
|
||||
|
||||
async importFrontend(summary) {
|
||||
const workspace = workspaceFrontends[summary.id];
|
||||
if (workspace) return (await workspace()) as FrontendModule;
|
||||
return (await import(
|
||||
/* @vite-ignore */ absolute(assetUrl(summary, "frontend.js"))
|
||||
)) as FrontendModule;
|
||||
},
|
||||
|
||||
async loadLocale(summary, file) {
|
||||
const workspace = workspaceLocales[summary.id]?.[file];
|
||||
if (workspace) return (await workspace()) as ResourceKey;
|
||||
const hasFile = summary.locales?.includes(file);
|
||||
if (!hasFile) return null;
|
||||
const path = file === "en" ? "en.json" : `translated/${file}.json`;
|
||||
const response = await fetch(
|
||||
absolute(assetUrl(summary, `locales/${path}`)),
|
||||
);
|
||||
if (!response.ok) return null;
|
||||
return (await response.json()) as ResourceKey;
|
||||
},
|
||||
|
||||
injectCss(summary) {
|
||||
if (!summary.css || typeof document === "undefined") return null;
|
||||
const link = document.createElement("link");
|
||||
link.rel = "stylesheet";
|
||||
link.href = absolute(assetUrl(summary, "frontend.css"));
|
||||
link.dataset.plugin = summary.id;
|
||||
document.head.appendChild(link);
|
||||
return link;
|
||||
},
|
||||
};
|
||||
|
||||
function currentDeps(): PluginLoaderDeps {
|
||||
return deps ?? defaultDeps;
|
||||
}
|
||||
|
||||
/** Test seam: swaps how plugins are fetched, imported and localized. */
|
||||
export function configurePluginLoader(next: Partial<PluginLoaderDeps>): void {
|
||||
deps = { ...defaultDeps, ...next };
|
||||
}
|
||||
|
||||
function serverAllowsFrontend(summary: PluginSummary): boolean {
|
||||
return (
|
||||
summary.enabled &&
|
||||
summary.state !== "failed" &&
|
||||
summary.state !== "blocked" &&
|
||||
!!summary.frontend
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enabled plugins in an order where each comes after its dependencies. A
|
||||
* cycle or a missing hard dependency leaves the plugin out, marked blocked.
|
||||
*/
|
||||
export function orderForActivation(summaries: PluginSummary[]): {
|
||||
order: PluginSummary[];
|
||||
blocked: Map<string, string>;
|
||||
} {
|
||||
const byId = new Map(summaries.map((summary) => [summary.id, summary]));
|
||||
const order: PluginSummary[] = [];
|
||||
const blocked = new Map<string, string>();
|
||||
const state = new Map<string, "visiting" | "done">();
|
||||
|
||||
const visit = (summary: PluginSummary): boolean => {
|
||||
const mark = state.get(summary.id);
|
||||
if (mark === "done") return !blocked.has(summary.id);
|
||||
if (mark === "visiting") {
|
||||
blocked.set(summary.id, "dependency cycle");
|
||||
return false;
|
||||
}
|
||||
state.set(summary.id, "visiting");
|
||||
|
||||
for (const dependency of Object.keys(summary.dependencies ?? {})) {
|
||||
const target = byId.get(dependency);
|
||||
if (!target || !serverAllowsFrontend(target) || !visit(target)) {
|
||||
blocked.set(summary.id, `needs ${dependency}`);
|
||||
}
|
||||
}
|
||||
for (const dependency of Object.keys(summary.optionalDependencies ?? {})) {
|
||||
const target = byId.get(dependency);
|
||||
if (target && serverAllowsFrontend(target)) visit(target);
|
||||
}
|
||||
|
||||
state.set(summary.id, "done");
|
||||
if (blocked.has(summary.id)) return false;
|
||||
order.push(summary);
|
||||
return true;
|
||||
};
|
||||
|
||||
for (const summary of summaries) {
|
||||
if (serverAllowsFrontend(summary)) visit(summary);
|
||||
}
|
||||
return { order, blocked };
|
||||
}
|
||||
|
||||
async function deactivate(pluginId: string): Promise<void> {
|
||||
const plugin = active.get(pluginId);
|
||||
if (!plugin) return;
|
||||
active.delete(pluginId);
|
||||
try {
|
||||
await plugin.module.deactivate?.();
|
||||
} catch (error) {
|
||||
console.error(`[plugins] ${pluginId}: deactivate threw`, error);
|
||||
}
|
||||
// Runs even when deactivate threw or does not exist.
|
||||
plugin.handle.dispose();
|
||||
plugin.cssLink?.remove();
|
||||
setFrontendState(pluginId, "inactive");
|
||||
}
|
||||
|
||||
async function activate(summary: PluginSummary): Promise<void> {
|
||||
const loader = currentDeps();
|
||||
setFrontendState(summary.id, "loading");
|
||||
|
||||
let cssLink: HTMLLinkElement | null = null;
|
||||
let handle: PluginAppHandle | null = null;
|
||||
try {
|
||||
const module = await loader.importFrontend(summary);
|
||||
if (typeof module?.activate !== "function") {
|
||||
throw new Error("frontend entry does not export activate(app)");
|
||||
}
|
||||
cssLink = loader.injectCss(summary);
|
||||
handle = createPluginApp(
|
||||
summary.id,
|
||||
manifestFor(summary),
|
||||
summary.contributes,
|
||||
{ guest: guestMode },
|
||||
);
|
||||
await module.activate(handle.app);
|
||||
active.set(summary.id, {
|
||||
id: summary.id,
|
||||
assetVersion: summary.assetVersion ?? null,
|
||||
handle,
|
||||
module,
|
||||
cssLink,
|
||||
});
|
||||
failedVersions.delete(summary.id);
|
||||
setFrontendState(summary.id, "active");
|
||||
} catch (error) {
|
||||
// A failed activate is still cleaned up, and its deactivate is not
|
||||
// called: it never finished starting.
|
||||
handle?.dispose();
|
||||
cssLink?.remove();
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(`[plugins] ${summary.id} failed to start`, error);
|
||||
failedVersions.set(summary.id, summary.assetVersion ?? null);
|
||||
setFrontendState(summary.id, "failed", message);
|
||||
}
|
||||
}
|
||||
|
||||
/** The manifest subset the browser has. The full one stays on the server. */
|
||||
function manifestFor(summary: PluginSummary): PluginManifest {
|
||||
return {
|
||||
id: summary.id,
|
||||
name: summary.name,
|
||||
version: summary.version,
|
||||
description: "",
|
||||
author: { name: "" },
|
||||
license: "",
|
||||
category: "",
|
||||
engine: { termix: "", api: "1" },
|
||||
capabilities: summary.capabilities ?? [],
|
||||
dependencies: summary.dependencies,
|
||||
optionalDependencies: summary.optionalDependencies,
|
||||
contributes: summary.contributes as PluginManifest["contributes"],
|
||||
icon: summary.icon,
|
||||
};
|
||||
}
|
||||
|
||||
async function loadNamespaces(summaries: PluginSummary[]): Promise<void> {
|
||||
const ids = summaries.map((summary) => summary.id);
|
||||
if (ids.length === 0) return;
|
||||
// Reloading picks up a plugin that was upgraded while the app was open.
|
||||
await i18n.reloadResources(undefined, ids).catch(() => {});
|
||||
await i18n.loadNamespaces(ids).catch(() => {});
|
||||
}
|
||||
|
||||
async function reconcile(summaries: PluginSummary[]): Promise<void> {
|
||||
setPluginSummaries(summaries);
|
||||
// The host editor's Plugins tab exists only while something fills it.
|
||||
syncHostPluginsTab(summaries);
|
||||
await loadNamespaces(summaries);
|
||||
|
||||
const { order, blocked } = orderForActivation(summaries);
|
||||
const wanted = new Map(order.map((summary) => [summary.id, summary]));
|
||||
|
||||
// Dependents go first, so nothing outlives what it depends on.
|
||||
for (const id of [...active.keys()].reverse()) {
|
||||
const next = wanted.get(id);
|
||||
const current = active.get(id)!;
|
||||
if (!next || (next.assetVersion ?? null) !== current.assetVersion) {
|
||||
await deactivate(id);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [id, reason] of blocked) {
|
||||
if (getPluginRecord(id)?.frontend !== "blocked") {
|
||||
setFrontendState(id, "blocked", reason);
|
||||
}
|
||||
}
|
||||
|
||||
// A plugin that was switched off gets a fresh attempt when it comes back.
|
||||
for (const id of [...failedVersions.keys()]) {
|
||||
if (!wanted.has(id)) failedVersions.delete(id);
|
||||
}
|
||||
|
||||
for (const summary of order) {
|
||||
if (active.has(summary.id)) continue;
|
||||
// Do not retry a bundle that already failed until it changes.
|
||||
if (
|
||||
failedVersions.has(summary.id) &&
|
||||
failedVersions.get(summary.id) === (summary.assetVersion ?? null)
|
||||
) {
|
||||
setFrontendState(
|
||||
summary.id,
|
||||
"failed",
|
||||
getPluginRecord(summary.id)?.error,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
await activate(summary);
|
||||
}
|
||||
}
|
||||
|
||||
function enqueue(task: () => Promise<void>): Promise<void> {
|
||||
queue = queue.then(task, task);
|
||||
return queue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches plugin state and brings the loaded frontends in line with it. Safe
|
||||
* to call as often as needed; calls run one after another.
|
||||
*/
|
||||
export function syncPlugins(summaries?: PluginSummary[]): Promise<void> {
|
||||
return enqueue(async () => {
|
||||
let list = summaries;
|
||||
if (!list) {
|
||||
try {
|
||||
list = await currentDeps().fetchPlugins();
|
||||
} catch (error) {
|
||||
console.error("[plugins] could not load the plugin list", error);
|
||||
// The shell must not wait on plugins forever.
|
||||
if (!getPluginStoreState().loaded) setPluginSummaries([]);
|
||||
markPluginsSettled();
|
||||
return;
|
||||
}
|
||||
}
|
||||
await reconcile(list);
|
||||
markPluginsSettled();
|
||||
});
|
||||
}
|
||||
|
||||
/** Fired by anything that changes plugin state, e.g. the admin toggle. */
|
||||
export const PLUGINS_CHANGED_EVENT = "termix:plugins-changed";
|
||||
|
||||
let lastFocusSync = 0;
|
||||
const FOCUS_SYNC_INTERVAL_MS = 30_000;
|
||||
|
||||
function onPluginsChanged() {
|
||||
void syncPlugins();
|
||||
}
|
||||
|
||||
function onFocus() {
|
||||
const now = Date.now();
|
||||
if (now - lastFocusSync < FOCUS_SYNC_INTERVAL_MS) return;
|
||||
lastFocusSync = now;
|
||||
void syncPlugins();
|
||||
}
|
||||
|
||||
async function fetchGuestPlugins(): Promise<PluginSummary[]> {
|
||||
const response = await fetch(getBackendUrl("/plugins/public"));
|
||||
if (!response.ok) return [];
|
||||
const body = (await response.json()) as unknown;
|
||||
return Array.isArray(body) ? (body as PluginSummary[]) : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the runtime. Idempotent. `guest` is for anonymous pages (shared
|
||||
* session links): only plugins declaring contributes.guest load, from the
|
||||
* public list, and nothing is refetched on focus.
|
||||
*/
|
||||
export function startPluginRuntime(
|
||||
options: { guest?: boolean } = {},
|
||||
): Promise<void> {
|
||||
installPluginHostBridge();
|
||||
if (options.guest) {
|
||||
guestMode = true;
|
||||
if (!deps) configurePluginLoader({ fetchPlugins: fetchGuestPlugins });
|
||||
return syncPlugins();
|
||||
}
|
||||
setPluginLocaleResolver((namespace, _language, file) => {
|
||||
const summary = getPluginRecord(namespace)?.summary;
|
||||
if (!summary) return Promise.resolve(null);
|
||||
return currentDeps().loadLocale(summary, file);
|
||||
});
|
||||
|
||||
if (!started && typeof window !== "undefined") {
|
||||
started = true;
|
||||
lastFocusSync = Date.now();
|
||||
window.addEventListener(PLUGINS_CHANGED_EVENT, onPluginsChanged);
|
||||
window.addEventListener("focus", onFocus);
|
||||
}
|
||||
return syncPlugins();
|
||||
}
|
||||
|
||||
/** Deactivates every plugin, on logout. */
|
||||
export function stopPluginRuntime(): Promise<void> {
|
||||
if (started && typeof window !== "undefined") {
|
||||
started = false;
|
||||
window.removeEventListener(PLUGINS_CHANGED_EVENT, onPluginsChanged);
|
||||
window.removeEventListener("focus", onFocus);
|
||||
}
|
||||
return enqueue(async () => {
|
||||
for (const id of [...active.keys()].reverse()) await deactivate(id);
|
||||
});
|
||||
}
|
||||
|
||||
export function isPluginFrontendActive(pluginId: string): boolean {
|
||||
return active.has(pluginId);
|
||||
}
|
||||
|
||||
/** Test seam. */
|
||||
export async function resetPluginLoader(): Promise<void> {
|
||||
await stopPluginRuntime();
|
||||
deps = null;
|
||||
queue = Promise.resolve();
|
||||
failedVersions.clear();
|
||||
}
|
||||
Reference in new issue
Block a user