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

+9 -12
View File
@@ -25,24 +25,21 @@ export const BACKEND_EXTERNALS = [
"drizzle-orm/*",
];
/**
* Resolved by the page's import map to the shell's own copies, because these
* must be one instance: React, i18next, the toast store, and the SDK entries
* that share state with core. Anything else a plugin imports is bundled.
*/
export const FRONTEND_EXTERNALS = [
"react",
"react-dom",
"react/jsx-runtime",
"react-dom/client",
"@termix/plugin-sdk",
"@termix/plugin-sdk/*",
"i18next",
"react-i18next",
// Core-provided UI libraries. A7 serves these through the SDK ui entry.
"lucide-react",
"sonner",
"axios",
"cytoscape",
"react-cytoscapejs",
"guacamole-common-js",
"react-xtermjs",
"@xterm/*",
// Legacy: the shell's own modules, still reached by alias. A7 removes.
"@/*",
"@termix/plugin-sdk/frontend",
"@termix/plugin-sdk/ui",
// Legacy: the shell's own modules, see legacy-core-imports.mjs. D1 removes.
"@termix/legacy-core/*",
];
@@ -12,6 +12,8 @@
* Delete this file in D1, once every plugin reaches core through the SDK.
*/
import { legacyCoreSpecifier } from "./legacy-core-specifier.mjs";
// "../../../src/backend/" or "../../../../src/backend/", any depth.
const CORE_BACKEND = /^(?:\.\.\/)+src\/backend\//;
// Shared types. Mostly erased at compile, but automations imports a real
@@ -27,10 +29,36 @@ const CROSS_PLUGIN = /^(?:\.\.\/)+([a-z0-9-]+)\/(?:src\/)?backend\//;
const COMPILED_CORE = "../../../backend/backend/";
const COMPILED_TYPES = "../../../backend/types/";
export function legacyCoreImports({ pluginId, platform }) {
const CORE_ALIAS = /^@\//;
export function legacyCoreImports({ pluginId, platform, insideTermix = true }) {
return {
name: "legacy-core-imports",
setup(build) {
// The shell's own modules, reached as "@/..." or by relative path.
// Bundling them would give the plugin a second copy of the shell's
// contexts and API client, so each becomes a bare specifier the page's
// import map resolves to the shell's own module.
if (platform === "browser") {
const toLegacy = (args) => {
const specifier = legacyCoreSpecifier(args.path);
if (!specifier) return null;
if (!insideTermix) {
return {
errors: [
{
text: `${pluginId}: "${args.path}" reaches Termix core. Only plugins built inside a Termix checkout may; use @termix/plugin-sdk instead.`,
},
],
};
}
return { path: specifier, external: true };
};
build.onResolve({ filter: CORE_ALIAS }, toLegacy);
build.onResolve({ filter: CORE_UI }, toLegacy);
build.onResolve({ filter: CORE_TYPES }, toLegacy);
}
build.onResolve({ filter: CORE_BACKEND }, (args) => ({
path: args.path.replace(CORE_BACKEND, COMPILED_CORE),
external: true,
@@ -41,16 +69,6 @@ export function legacyCoreImports({ pluginId, platform }) {
external: true,
}));
// The shell's own modules. Only plugins/docker's frontend entry reaches
// core this way; every other frontend uses the "@/" alias, which is
// externalized by name. A7 removes both.
if (platform === "browser") {
build.onResolve({ filter: CORE_UI }, (args) => ({
path: args.path,
external: true,
}));
}
build.onResolve({ filter: CROSS_PLUGIN }, (args) => {
const [, other] = CROSS_PLUGIN.exec(args.path);
if (other === "src" || other === pluginId) return null;
@@ -0,0 +1,62 @@
/**
* Legacy core modules a bundled plugin frontend still imports.
*
* Bundled plugin frontends predate the SDK and import the shell's own modules
* as "@/..." (or, in a couple of places, by relative path into src/). Bundling
* those would give the plugin a second copy of the shell's contexts and API
* client, so the CLI rewrites each one to a bare specifier under
* "@termix/legacy-core/" and core's build exposes exactly that module through
* the import map. Both sides use this one normalizer so they cannot disagree.
*
* Only plugins built inside a Termix checkout may do this. D1 deletes it.
*/
export const LEGACY_CORE_PREFIX = "@termix/legacy-core/";
const EXTENSION = /\.(tsx|ts|jsx|js|mjs)$/;
function clean(rest) {
return rest.replace(EXTENSION, "").replace(/\/+$/, "");
}
/**
* "@/components/button.tsx" -> "@termix/legacy-core/ui/components/button"
* "@/types" -> "@termix/legacy-core/types"
* "@/types/connection-log" -> "@termix/legacy-core/types/connection-log"
* "../../../src/ui/x.tsx" -> "@termix/legacy-core/ui/x"
* Anything else -> null
*/
export function legacyCoreSpecifier(specifier) {
if (specifier.startsWith("@/")) {
const rest = clean(specifier.slice(2));
if (rest === "types" || rest.startsWith("types/")) {
return LEGACY_CORE_PREFIX + rest;
}
return `${LEGACY_CORE_PREFIX}ui/${rest}`;
}
const relative = /^(?:\.\.\/)+src\/(ui|types)(\/.*)?$/.exec(specifier);
if (relative) {
return LEGACY_CORE_PREFIX + clean(`${relative[1]}${relative[2] ?? ""}`);
}
return null;
}
/**
* Every value import in a source file that names a legacy core module.
* Type-only imports are skipped: they are erased and never reach the map.
*/
export function findLegacyCoreImports(source) {
const found = new Set();
const patterns = [
/^\s*(?:import|export)\s+(?!type\s)[^'";]*?\sfrom\s+["']([^"']+)["']/gm,
/^\s*import\s+["']([^"']+)["']/gm,
/import\(\s*["']([^"']+)["']\s*\)/g,
];
for (const pattern of patterns) {
for (const match of source.matchAll(pattern)) {
const normalized = legacyCoreSpecifier(match[1]);
if (normalized) found.add(normalized);
}
}
return found;
}