mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-09 13:21:47 +00:00
205 lines
6.6 KiB
JavaScript
205 lines
6.6 KiB
JavaScript
/**
|
|
* Makes the shell a host for plugin bundles.
|
|
*
|
|
* - Every shared module (React, i18next, the SDK, and the legacy core modules
|
|
* bundled plugins still import) becomes an entry chunk, reached through a
|
|
* stable shim at dist/shared/<name>.js.
|
|
* - index.html gets an import map pointing those bare specifiers at the shims,
|
|
* so a plugin bundle's `import "react"` lands on the shell's React.
|
|
* - In `vite dev`, src/ui/plugin-host/workspace-plugins.ts is replaced with
|
|
* import.meta.glob calls, so the monorepo's plugins load through Vite with
|
|
* HMR, and the map points at dev-server module URLs instead.
|
|
*/
|
|
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { createRequire } from "node:module";
|
|
import {
|
|
importMapCspHash,
|
|
productionImportMap,
|
|
resolveLegacyCoreFile,
|
|
sharedModules,
|
|
shimName,
|
|
shimPath,
|
|
SHARED_VENDOR_MODULES,
|
|
SHARED_VENDORS,
|
|
} from "./lib/plugin-import-map.mjs";
|
|
|
|
const VIRTUAL_PREFIX = "\0termix-shared:";
|
|
const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
|
|
function hasDefaultExport(file) {
|
|
const source = fs.readFileSync(file, "utf8");
|
|
return (
|
|
/\bexport\s+default\b/.test(source) ||
|
|
/\bexport\s*\{[^}]*\bas\s+default\b/.test(source)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* CommonJS packages (React among them) lose their named exports through
|
|
* `export *`, so their facade lists every name explicitly, read from the
|
|
* package itself at build time.
|
|
*/
|
|
function vendorFacade(repoRoot, specifier) {
|
|
const vendor = SHARED_VENDORS.find((item) => item.specifier === specifier);
|
|
if (vendor && !vendor.cjs) {
|
|
const target = JSON.stringify(specifier);
|
|
return vendor.hasDefault
|
|
? `export * from ${target};
|
|
export { default } from ${target};`
|
|
: `export * from ${target};`;
|
|
}
|
|
const require = createRequire(path.join(repoRoot, "package.json"));
|
|
const names = Object.keys(require(specifier)).filter(
|
|
(name) => name !== "default" && name !== "__esModule" && IDENTIFIER.test(name),
|
|
);
|
|
const lines = [`import * as m from ${JSON.stringify(specifier)};`];
|
|
lines.push("const d = m.default ?? m;");
|
|
for (const name of names) {
|
|
lines.push(`export const ${name} = m.${name} ?? d.${name};`);
|
|
}
|
|
lines.push("export default d;");
|
|
return lines.join("\n");
|
|
}
|
|
|
|
function sourceFacade(file) {
|
|
const target = JSON.stringify(file.replaceAll("\\", "/"));
|
|
const lines = [`export * from ${target};`];
|
|
if (hasDefaultExport(file)) lines.push(`export { default } from ${target};`);
|
|
return lines.join("\n");
|
|
}
|
|
|
|
export function termixPluginHost({ repoRoot, sdkUiEntry, sdkFrontendEntry }) {
|
|
let command = "build";
|
|
let shared = [];
|
|
const workspaceModule = path
|
|
.join(repoRoot, "src", "ui", "plugin-host", "workspace-plugins.ts")
|
|
.replaceAll("\\", "/");
|
|
|
|
const facadeSource = (specifier) => {
|
|
if (SHARED_VENDOR_MODULES.includes(specifier)) {
|
|
return vendorFacade(repoRoot, specifier);
|
|
}
|
|
if (specifier === "@termix/plugin-sdk/ui") return sourceFacade(sdkUiEntry);
|
|
if (specifier === "@termix/plugin-sdk/frontend") {
|
|
return sourceFacade(sdkFrontendEntry);
|
|
}
|
|
const file = resolveLegacyCoreFile(repoRoot, specifier);
|
|
if (!file) {
|
|
throw new Error(
|
|
`termix-plugin-host: a plugin imports ${specifier}, which does not exist in core`,
|
|
);
|
|
}
|
|
return sourceFacade(file);
|
|
};
|
|
|
|
return {
|
|
name: "termix-plugin-host",
|
|
|
|
config(_config, env) {
|
|
command = env.command;
|
|
shared = sharedModules(repoRoot);
|
|
if (command !== "build") return;
|
|
const input = { index: path.join(repoRoot, "index.html") };
|
|
for (const specifier of shared) {
|
|
input[`shared-${shimName(specifier)}`] = VIRTUAL_PREFIX + specifier;
|
|
}
|
|
return {
|
|
build: {
|
|
rollupOptions: {
|
|
input,
|
|
// Entry exports are the contract with plugin bundles; they must
|
|
// survive tree-shaking even though nothing in the shell uses them.
|
|
preserveEntrySignatures: "exports-only",
|
|
},
|
|
},
|
|
};
|
|
},
|
|
|
|
resolveId(id) {
|
|
if (id.startsWith(VIRTUAL_PREFIX)) return id;
|
|
return null;
|
|
},
|
|
|
|
load(id) {
|
|
if (id.startsWith(VIRTUAL_PREFIX)) {
|
|
return facadeSource(id.slice(VIRTUAL_PREFIX.length));
|
|
}
|
|
if (
|
|
command === "serve" &&
|
|
id.replaceAll("\\", "/").split("?")[0] === workspaceModule
|
|
) {
|
|
return [
|
|
'const frontends = import.meta.glob("/plugins/*/src/frontend/index.{tsx,ts}");',
|
|
'const locales = import.meta.glob("/plugins/*/locales/**/*.json", { import: "default" });',
|
|
"export const workspaceFrontends = {};",
|
|
"export const workspaceLocales = {};",
|
|
"for (const [file, load] of Object.entries(frontends)) {",
|
|
' workspaceFrontends[file.split("/")[2]] = load;',
|
|
"}",
|
|
"for (const [file, load] of Object.entries(locales)) {",
|
|
' const parts = file.split("/");',
|
|
' const id = parts[2];',
|
|
' const name = parts[parts.length - 1].replace(/\\.json$/, "");',
|
|
" (workspaceLocales[id] ??= {})[name] = load;",
|
|
"}",
|
|
].join("\n");
|
|
}
|
|
return null;
|
|
},
|
|
|
|
generateBundle(_options, bundle) {
|
|
for (const chunk of Object.values(bundle)) {
|
|
if (chunk.type !== "chunk" || !chunk.isEntry) continue;
|
|
const facade = chunk.facadeModuleId ?? "";
|
|
if (!facade.startsWith(VIRTUAL_PREFIX)) continue;
|
|
const specifier = facade.slice(VIRTUAL_PREFIX.length);
|
|
const target = JSON.stringify(`../${chunk.fileName}`);
|
|
const lines = [`export * from ${target};`];
|
|
if (chunk.exports.includes("default")) {
|
|
lines.push(`export { default } from ${target};`);
|
|
}
|
|
this.emitFile({
|
|
type: "asset",
|
|
fileName: shimPath(specifier),
|
|
source: `${lines.join("\n")}\n`,
|
|
});
|
|
}
|
|
const text = productionImportMap(shared);
|
|
this.emitFile({
|
|
type: "asset",
|
|
fileName: "importmap.sha256",
|
|
source: `${importMapCspHash(text)}\n`,
|
|
});
|
|
},
|
|
|
|
transformIndexHtml: {
|
|
order: "pre",
|
|
handler() {
|
|
const text =
|
|
command === "build"
|
|
? productionImportMap(shared)
|
|
: JSON.stringify({
|
|
imports: Object.fromEntries(
|
|
[...shared]
|
|
.sort()
|
|
.map((specifier) => [
|
|
specifier,
|
|
`/@id/__x00__${VIRTUAL_PREFIX.slice(1)}${specifier}`,
|
|
]),
|
|
),
|
|
});
|
|
return [
|
|
{
|
|
tag: "script",
|
|
attrs: { type: "importmap" },
|
|
children: text,
|
|
injectTo: "head-prepend",
|
|
},
|
|
];
|
|
},
|
|
},
|
|
};
|
|
}
|