mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-09 13:21:47 +00:00
173 lines
5.3 KiB
JavaScript
173 lines
5.3 KiB
JavaScript
/**
|
|
* Makes the shell a host for plugin bundles.
|
|
*
|
|
* - Every shared module (React, i18next and the SDK) 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` 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,
|
|
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 facadeSource = (specifier) => {
|
|
if (SHARED_VENDOR_MODULES.includes(specifier)) {
|
|
return vendorFacade(repoRoot, specifier);
|
|
}
|
|
if (specifier === "@termix-ssh/plugin-sdk/ui") return sourceFacade(sdkUiEntry);
|
|
if (specifier === "@termix-ssh/plugin-sdk/frontend") {
|
|
return sourceFacade(sdkFrontendEntry);
|
|
}
|
|
throw new Error(`termix-plugin-host: ${specifier} is not a shared module`);
|
|
};
|
|
|
|
return {
|
|
name: "termix-plugin-host",
|
|
|
|
config(_config, env) {
|
|
command = env.command;
|
|
shared = sharedModules();
|
|
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));
|
|
}
|
|
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",
|
|
},
|
|
];
|
|
},
|
|
},
|
|
};
|
|
}
|