feat: load plugin frontends at runtime and remove plugin ids from the shell

This commit is contained in:
LukeGus committed 2026-09-22 20:15:52 -05:00
1 parent 49f0286a14
commit e88d5a52e3
721 files changed
+54265 -44416

No files matched your search

+218
View File
@@ -0,0 +1,218 @@
// Serves a plugin's built frontend: its bundle, stylesheet and locales.
//
// Unauthenticated on purpose, the same as /assets: import() cannot carry the
// bearer header, and nothing here is secret. It only ever serves files from
// the two directories a plugin ships for the browser, so a request can never
// reach the plugin's backend bundle, migrations or anything outside it.
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import express, { type Request, type Response } from "express";
import {
DEFAULT_FRONTEND_ENTRY,
DEFAULT_LOCALES_DIR,
type PluginManifest,
} from "@termix/plugin-sdk/manifest";
import type { LoadedPlugin } from "./loader.js";
type AssetPlugin = Pick<LoadedPlugin, "id" | "dir" | "manifest">;
export interface PluginFrontendInfo {
frontend: boolean;
css: boolean;
/** Changes whenever the built bundle does, for cache busting. */
assetVersion: string | null;
/** Language codes with a locale file, "en" plus xx_YY names. */
locales: string[];
}
function frontendEntry(
plugin: AssetPlugin,
): { dir: string; file: string } | null {
const manifest = plugin.manifest as PluginManifest;
const entry = path.resolve(
plugin.dir,
manifest.frontend ?? DEFAULT_FRONTEND_ENTRY,
);
if (!isInside(plugin.dir, entry)) return null;
return { dir: path.dirname(entry), file: entry };
}
function localesDir(plugin: AssetPlugin): string | null {
const manifest = plugin.manifest as PluginManifest;
const dir = path.resolve(plugin.dir, manifest.locales ?? DEFAULT_LOCALES_DIR);
return isInside(plugin.dir, dir) ? dir : null;
}
function isInside(root: string, target: string): boolean {
const relative = path.relative(path.resolve(root), path.resolve(target));
return (
relative === "" ||
(!relative.startsWith("..") && !path.isAbsolute(relative))
);
}
function fileExists(file: string): boolean {
try {
return fs.statSync(file).isFile();
} catch {
return false;
}
}
export function describePluginFrontend(
plugin: AssetPlugin | undefined,
): PluginFrontendInfo {
const none: PluginFrontendInfo = {
frontend: false,
css: false,
assetVersion: null,
locales: [],
};
if (!plugin) return none;
const locales: string[] = [];
const dir = localesDir(plugin);
if (dir && fileExists(path.join(dir, "en.json"))) locales.push("en");
if (dir) {
try {
for (const name of fs.readdirSync(path.join(dir, "translated"))) {
if (/^[a-z]{2}_[A-Z]{2}\.json$/.test(name)) {
locales.push(name.slice(0, -".json".length));
}
}
} catch {
// No translations yet.
}
}
const entry = frontendEntry(plugin);
if (!entry || !fileExists(entry.file)) return { ...none, locales };
const stat = fs.statSync(entry.file);
const assetVersion = crypto
.createHash("sha256")
.update(`${plugin.manifest.version}:${stat.size}:${stat.mtimeMs}`)
.digest("hex")
.slice(0, 12);
return {
frontend: true,
css: fileExists(path.join(entry.dir, "frontend.css")),
assetVersion,
locales: locales.sort(),
};
}
/**
* Maps a request path under /plugin-assets/<id>/ to a file, or null.
*
* `locales/...` reads the plugin's locales directory; anything else reads the
* directory the frontend bundle sits in. Only .js, .css, .json and .map are
* served, and never a path that escapes either directory.
*/
export function resolvePluginAsset(
plugin: AssetPlugin,
requestPath: string,
): string | null {
const clean = requestPath.replace(/^\/+/, "");
if (!clean || clean.includes("\0") || clean.includes("\\")) return null;
if (!/\.(js|css|json|map)$/.test(clean)) return null;
let root: string | null;
let rest: string;
if (clean.startsWith("locales/")) {
root = localesDir(plugin);
rest = clean.slice("locales/".length);
} else {
root = frontendEntry(plugin)?.dir ?? null;
rest = clean;
// The backend bundle sits beside the frontend one in dist/.
if (/^backend\.js(\.map)?$/.test(rest)) return null;
}
if (!root) return null;
const file = path.resolve(root, rest);
if (!isInside(root, file) || !fileExists(file)) return null;
return file;
}
const CONTENT_TYPES: Record<string, string> = {
".js": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".json": "application/json; charset=utf-8",
".map": "application/json; charset=utf-8",
};
export function createPluginAssetsRouter(
getPlugin: (id: string) => AssetPlugin | undefined,
): express.Router {
const router = express.Router();
/**
* @openapi
* /plugin-assets/{pluginId}/{path}:
* get:
* summary: Serve a plugin's frontend bundle, stylesheet or locale file
* description: >
* Public, like the app's own /assets, because a module import cannot
* send an auth header and plugin frontends are not secret. Serves only
* the directory holding the plugin's frontend bundle and its locales
* directory. A `v` query parameter (the assetVersion from GET
* /plugins) makes the response cacheable for a year.
* tags:
* - Plugins
* parameters:
* - in: path
* name: pluginId
* required: true
* schema:
* type: string
* - in: path
* name: path
* required: true
* schema:
* type: string
* - in: query
* name: v
* schema:
* type: string
* responses:
* 200:
* description: The file.
* 404:
* description: Unknown plugin or file.
*/
router.get(
/^\/([a-z][a-z0-9-]{1,39})\/(.+)$/,
(req: Request, res: Response) => {
const params = req.params as unknown as Record<string, string>;
const pluginId = params[0];
const plugin = getPlugin(pluginId);
const file = plugin ? resolvePluginAsset(plugin, params[1]) : null;
// Loaded as a module from Electron's file:// page and the Vite dev
// server, so any origin may read it. No credentials are involved.
res.setHeader("Access-Control-Allow-Origin", "*");
res.removeHeader("Access-Control-Allow-Credentials");
res.setHeader("X-Content-Type-Options", "nosniff");
if (!file) {
res.status(404).json({ error: "Not found" });
return;
}
res.setHeader(
"Cache-Control",
typeof req.query.v === "string" && req.query.v
? "public, max-age=31536000, immutable"
: "no-cache",
);
res.type(CONTENT_TYPES[path.extname(file)] ?? "application/octet-stream");
res.sendFile(file);
},
);
return router;
}