claude-code-router/build/esbuild.config.mjs

602 lines
22 KiB
JavaScript

import esbuild from "esbuild";
import { spawn } from "node:child_process";
import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
import { builtinModules, createRequire } from "node:module";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const requireFromHere = createRequire(import.meta.url);
export const projectRoot = path.resolve(__dirname, "..");
export const packagesRoot = path.join(projectRoot, "packages");
export const cliRoot = path.join(packagesRoot, "cli");
export const coreRoot = path.join(packagesRoot, "core");
export const electronRoot = path.join(packagesRoot, "electron");
export const uiRoot = path.join(packagesRoot, "ui");
export const cliSourceRoot = path.join(cliRoot, "src");
export const coreSourceRoot = path.join(coreRoot, "src");
export const electronSourceRoot = path.join(electronRoot, "src");
export const uiSourceRoot = path.join(uiRoot, "src");
export const legacyDistDir = path.join(projectRoot, "dist");
export const cliDistDir = path.join(cliRoot, "dist");
export const coreDistDir = path.join(coreRoot, "dist");
export const electronDistDir = path.join(electronRoot, "dist");
export const uiDistDir = path.join(uiRoot, "dist");
export const distDir = electronDistDir;
export const cliMainOutDir = path.join(cliDistDir, "main");
export const coreMainOutDir = path.join(coreDistDir, "main");
export const electronMainOutDir = path.join(electronDistDir, "main");
export const mainOutDir = electronMainOutDir;
export const gatewayPackageRoot = path.dirname(requireFromHere.resolve("@the-next-ai/ai-gateway/package.json"));
export const gatewayRuntimeInput = path.join(gatewayPackageRoot, "bin", "next-ai-gateway.js");
export const electronGatewayRuntimeOutput = path.join(electronMainOutDir, "next-ai-gateway.js");
export const botGatewaySdkPackageRoot = path.dirname(requireFromHere.resolve("@the-next-ai/bot-gateway-sdk/package.json"));
export const botGatewaySdkEntryInput = path.join(botGatewaySdkPackageRoot, "dist", "index.js");
export const botGatewaySdkRunnerInput = path.join(botGatewaySdkPackageRoot, "bin", "bot-gateway-stdio.mjs");
export const electronBotGatewaySdkRootDir = path.join(electronMainOutDir, "bot-gateway-sdk");
export const electronBotGatewaySdkDistDir = path.join(electronBotGatewaySdkRootDir, "dist");
export const electronBotGatewaySdkBinDir = path.join(electronBotGatewaySdkRootDir, "bin");
export const electronBotGatewaySdkPackageOutput = path.join(electronBotGatewaySdkRootDir, "package.json");
export const electronBotGatewaySdkEntryOutput = path.join(electronBotGatewaySdkDistDir, "index.js");
export const electronBotGatewaySdkRunnerOutput = path.join(electronBotGatewaySdkBinDir, "bot-gateway-stdio.mjs");
export const rendererOutDir = path.join(uiDistDir, "renderer");
export const cliRendererOutDir = path.join(cliDistDir, "renderer");
export const coreRendererOutDir = path.join(coreDistDir, "renderer");
export const electronRendererOutDir = path.join(electronDistDir, "renderer");
export const runtimeRendererOutDirs = [cliRendererOutDir, coreRendererOutDir, electronRendererOutDir];
export const appAssetsDir = path.join(electronDistDir, "assets");
export const rendererAssetsDir = path.join(rendererOutDir, "assets");
export const cliMarketplacePluginsDir = path.join(cliDistDir, "marketplace", "plugins");
export const coreMarketplacePluginsDir = path.join(coreDistDir, "marketplace", "plugins");
export const electronMarketplacePluginsDir = path.join(electronDistDir, "marketplace", "plugins");
export const marketplacePluginsDir = electronMarketplacePluginsDir;
export const appAssetsInput = path.join(electronRoot, "assets");
export const modelCatalogInput = path.join(coreRoot, "models.json");
export const cliModelCatalogOutput = path.join(cliDistDir, "models.json");
export const coreModelCatalogOutput = path.join(coreDistDir, "models.json");
export const electronModelCatalogOutput = path.join(electronDistDir, "models.json");
export const modelCatalogOutput = electronModelCatalogOutput;
export const rendererRoot = uiSourceRoot;
export const rendererHtmlInput = path.join(rendererRoot, "pages", "home", "index.html");
export const rendererHtmlOutput = path.join(rendererOutDir, "pages", "home", "index.html");
export const browserRendererHtmlInput = path.join(rendererRoot, "pages", "browser", "index.html");
export const browserRendererHtmlOutput = path.join(rendererOutDir, "pages", "browser", "index.html");
export const trayRendererHtmlInput = path.join(rendererRoot, "pages", "tray", "index.html");
export const trayRendererHtmlOutput = path.join(rendererOutDir, "pages", "tray", "index.html");
export const cssInput = path.join(rendererRoot, "styles", "globals.css");
export const cssOutput = path.join(rendererAssetsDir, "main.css");
export const webClientBridgeOutput = path.join(rendererAssetsDir, "web-client-bridge.js");
export const electronUndiciProxyAgentInput = path.join(coreSourceRoot, "proxy", "undici-proxy-agent.ts");
const lightweightMcpBundleNames = ["browser-web-search-proxy-mcp.js", "fusion-vision-mcp.js", "fusion-tool-fallback-mcp.js"];
const lightweightMcpBundleMaxBytes = 128 * 1024;
const forbiddenLightweightMcpInputs = [
{ prefix: "packages/core/src/config/", reason: "config modules can pull in native storage side effects" },
{ prefix: "packages/core/src/storage/", reason: "native SQLite storage is not allowed in lightweight MCP subprocesses" },
{ prefix: "packages/electron/src/", reason: "Electron runtime modules are not allowed in lightweight MCP subprocesses" },
{ prefix: "packages/ui/src/", reason: "UI modules do not belong in stdio MCP subprocesses" },
{ prefix: "node_modules/better-sqlite3/", reason: "native SQLite is not allowed in lightweight MCP subprocesses" },
{ prefix: "node_modules/electron/", reason: "Electron runtime modules are not allowed in lightweight MCP subprocesses" }
];
const forbiddenLightweightMcpExternalImports = new Set(["better-sqlite3", "electron"]);
const nodeExternals = [
"electron",
"better-sqlite3",
...builtinModules,
...builtinModules.map((moduleName) => `node:${moduleName}`)
];
export function cleanDist() {
rmSync(legacyDistDir, { force: true, recursive: true });
rmSync(cliDistDir, { force: true, recursive: true });
rmSync(coreDistDir, { force: true, recursive: true });
rmSync(electronDistDir, { force: true, recursive: true });
rmSync(uiDistDir, { force: true, recursive: true });
ensureDist();
}
export function ensureDist() {
mkdirSync(cliMainOutDir, { recursive: true });
mkdirSync(coreMainOutDir, { recursive: true });
mkdirSync(electronMainOutDir, { recursive: true });
mkdirSync(electronBotGatewaySdkDistDir, { recursive: true });
mkdirSync(electronBotGatewaySdkBinDir, { recursive: true });
mkdirSync(appAssetsDir, { recursive: true });
mkdirSync(cliMarketplacePluginsDir, { recursive: true });
mkdirSync(coreMarketplacePluginsDir, { recursive: true });
mkdirSync(electronMarketplacePluginsDir, { recursive: true });
mkdirSync(rendererAssetsDir, { recursive: true });
for (const outputDir of runtimeRendererOutDirs) {
mkdirSync(path.join(outputDir, "assets"), { recursive: true });
}
mkdirSync(path.dirname(rendererHtmlOutput), { recursive: true });
mkdirSync(path.dirname(browserRendererHtmlOutput), { recursive: true });
mkdirSync(path.dirname(trayRendererHtmlOutput), { recursive: true });
}
export function copyAppAssets() {
ensureDist();
if (existsSync(appAssetsInput)) {
cpSync(appAssetsInput, appAssetsDir, { recursive: true });
}
}
export function copyModelCatalog() {
ensureDist();
if (existsSync(modelCatalogInput)) {
cpSync(modelCatalogInput, cliModelCatalogOutput);
cpSync(modelCatalogInput, coreModelCatalogOutput);
cpSync(modelCatalogInput, electronModelCatalogOutput);
}
}
export function copyRendererHtml() {
copyRendererPageHtml(rendererHtmlInput, rendererHtmlOutput, "main.js", {
beforeModuleScriptTags: [' <script src="../../assets/web-client-bridge.js"></script>']
});
}
export function copyTrayRendererHtml() {
copyRendererPageHtml(trayRendererHtmlInput, trayRendererHtmlOutput, "tray.js");
}
export function copyBrowserRendererHtml() {
copyRendererPageHtml(browserRendererHtmlInput, browserRendererHtmlOutput, "browser.js");
}
export function copyMarketplacePlugins() {
ensureDist();
for (const filename of ["claude-design-plugin.cjs", "cursor-proxy-plugin.cjs"]) {
const source = path.join(projectRoot, "examples", "plugins", filename);
if (existsSync(source)) {
cpSync(source, path.join(cliMarketplacePluginsDir, filename));
cpSync(source, path.join(coreMarketplacePluginsDir, filename));
cpSync(source, path.join(electronMarketplacePluginsDir, filename));
}
}
}
export function syncUiRendererToRuntimeDists() {
ensureDist();
for (const outputDir of runtimeRendererOutDirs) {
rmSync(outputDir, { force: true, recursive: true });
if (existsSync(rendererOutDir)) {
cpSync(rendererOutDir, outputDir, { recursive: true });
}
}
}
function copyRendererPageHtml(input, output, scriptName, options = {}) {
ensureDist();
const source = readFileSync(input, "utf8");
const styleTag = ' <link rel="stylesheet" href="../../assets/main.css" />';
const scriptTag = ` <script type="module" src="../../assets/${scriptName}"></script>`;
let html = source.includes('<script type="module" src="./main.tsx"></script>')
? source.replace(' <script type="module" src="./main.tsx"></script>', scriptTag)
: source.replace("</body>", `${scriptTag}\n </body>`);
for (const extraScriptTag of options.beforeModuleScriptTags ?? []) {
if (!hasScriptTag(html, extraScriptTag)) {
html = html.replace(scriptTag, `${extraScriptTag}\n${scriptTag}`);
}
}
if (!html.includes('href="../../assets/main.css"')) {
html = html.replace("</head>", `${styleTag}\n </head>`);
}
writeFileSync(output, html, "utf8");
}
function hasScriptTag(html, scriptTag) {
const sourceMatch = scriptTag.match(/\bsrc="([^"]+)"/);
return sourceMatch ? html.includes(sourceMatch[1]) : html.includes(scriptTag);
}
function normalizeDuplicateShebangs(source) {
const lines = source.split("\n");
if (!lines[0]?.startsWith("#!")) {
return source;
}
let index = 1;
while (lines[index]?.startsWith("#!")) {
index += 1;
}
return [lines[0], ...lines.slice(index)].join("\n");
}
export function createMainBuildOptions({ mode = "production", plugins = [] } = {}) {
return {
absWorkingDir: projectRoot,
bundle: true,
entryNames: "[name]",
entryPoints: [
path.join(electronSourceRoot, "main", "main.ts"),
path.join(electronSourceRoot, "main", "browser-preload.ts"),
gatewayRuntimeInput,
path.join(coreSourceRoot, "mcp", "browser-web-search-proxy-mcp.ts"),
path.join(coreSourceRoot, "mcp", "fusion-vision-mcp.ts"),
path.join(coreSourceRoot, "mcp", "fusion-tool-fallback-mcp.ts"),
path.join(coreSourceRoot, "mcp", "toolhub-mcp.ts"),
electronUndiciProxyAgentInput,
path.join(electronSourceRoot, "main", "preload.ts")
],
external: nodeExternals,
format: "cjs",
legalComments: "none",
logLevel: "info",
metafile: true,
minify: mode === "production",
outdir: electronMainOutDir,
platform: "node",
plugins: [packageAliasPlugin(), ...plugins],
sourcemap: mode !== "production",
target: "node22"
};
}
export function createCliBuildOptions({ mode = "production", plugins = [] } = {}) {
return {
absWorkingDir: projectRoot,
bundle: true,
entryNames: "[name]",
entryPoints: [
path.join(cliSourceRoot, "cli.ts"),
path.join(coreSourceRoot, "mcp", "fusion-vision-mcp.ts"),
path.join(coreSourceRoot, "mcp", "fusion-tool-fallback-mcp.ts"),
path.join(coreSourceRoot, "mcp", "toolhub-mcp.ts")
],
external: nodeExternals.filter((moduleName) => moduleName !== "electron"),
format: "cjs",
legalComments: "none",
logLevel: "info",
minify: mode === "production",
outdir: cliMainOutDir,
platform: "node",
plugins: [forbidCliElectronPlugin(), packageAliasPlugin(), ...plugins],
sourcemap: mode !== "production",
target: "node22"
};
}
export function createCoreServerBuildOptions({ mode = "production", plugins = [] } = {}) {
return {
absWorkingDir: projectRoot,
bundle: true,
entryNames: "[name]",
entryPoints: [
path.join(coreSourceRoot, "entrypoints", "server.ts"),
path.join(coreSourceRoot, "mcp", "fusion-vision-mcp.ts"),
path.join(coreSourceRoot, "mcp", "fusion-tool-fallback-mcp.ts"),
path.join(coreSourceRoot, "mcp", "toolhub-mcp.ts")
],
external: nodeExternals.filter((moduleName) => moduleName !== "electron"),
format: "cjs",
legalComments: "none",
logLevel: "info",
minify: mode === "production",
outdir: coreMainOutDir,
platform: "node",
plugins: [forbidCliElectronPlugin(), packageAliasPlugin(), ...plugins],
sourcemap: mode !== "production",
target: "node22"
};
}
export function createRendererBuildOptions({ mode = "production", plugins = [] } = {}) {
return {
absWorkingDir: projectRoot,
assetNames: "assets/[name]-[hash]",
bundle: true,
define: {
"process.env.NODE_ENV": JSON.stringify(mode)
},
entryPoints: [path.join(rendererRoot, "pages", "home", "main.tsx")],
format: "esm",
jsx: "automatic",
legalComments: "none",
loader: {
".gif": "file",
".ico": "file",
".jpg": "file",
".jpeg": "file",
".png": "file",
".svg": "file",
".webp": "file"
},
logLevel: "info",
minify: mode === "production",
outfile: path.join(rendererAssetsDir, "main.js"),
platform: "browser",
plugins: [rendererAliasPlugin(), packageAliasPlugin(), ...plugins],
publicPath: "../../assets",
sourcemap: mode !== "production",
target: "chrome120"
};
}
export function createTrayRendererBuildOptions({ mode = "production", plugins = [] } = {}) {
return {
...createRendererBuildOptions({ mode, plugins }),
entryPoints: [path.join(rendererRoot, "pages", "tray", "main.tsx")],
outfile: path.join(rendererAssetsDir, "tray.js")
};
}
export function createBrowserRendererBuildOptions({ mode = "production", plugins = [] } = {}) {
return {
...createRendererBuildOptions({ mode, plugins }),
entryPoints: [path.join(rendererRoot, "pages", "browser", "main.tsx")],
outfile: path.join(rendererAssetsDir, "browser.js")
};
}
export function createWebClientBridgeBuildOptions({ mode = "production", plugins = [] } = {}) {
return {
absWorkingDir: projectRoot,
bundle: true,
entryPoints: [path.join(uiSourceRoot, "web-client-bridge.ts")],
format: "iife",
legalComments: "none",
logLevel: "info",
minify: mode === "production",
outfile: webClientBridgeOutput,
platform: "browser",
plugins: [packageAliasPlugin(), ...plugins],
sourcemap: mode !== "production",
target: "chrome120"
};
}
export function createBotGatewaySdkBuildOptions({ mode = "production", plugins = [] } = {}) {
return {
absWorkingDir: projectRoot,
bundle: true,
entryPoints: [botGatewaySdkEntryInput],
external: [
...builtinModules,
...builtinModules.map((moduleName) => `node:${moduleName}`)
],
format: "esm",
legalComments: "none",
logLevel: "info",
minify: mode === "production",
outfile: electronBotGatewaySdkEntryOutput,
platform: "node",
plugins,
sourcemap: mode !== "production",
target: "node22"
};
}
export function watchPlugin(name, onEnd) {
return {
name: `${name}-watch`,
setup(build) {
build.onEnd((result) => {
if (result.errors.length === 0) {
onEnd(name);
}
});
}
};
}
export async function buildMain(options = {}) {
const [mainBuildResult] = await Promise.all([
esbuild.build(createMainBuildOptions(options)),
buildBotGatewaySdkRuntime(options),
buildCoreServer(options),
buildCli(options)
]);
copyCliRuntimeToElectronDist();
validateLightweightMcpBundles(mainBuildResult.metafile);
}
export async function buildBotGatewaySdkRuntime(options = {}) {
ensureDist();
await esbuild.build(createBotGatewaySdkBuildOptions(options));
writeFileSync(
electronBotGatewaySdkPackageOutput,
`${JSON.stringify({ private: true, type: "module" }, null, 2)}\n`,
"utf8"
);
writeFileSync(
electronBotGatewaySdkRunnerOutput,
normalizeDuplicateShebangs(readFileSync(botGatewaySdkRunnerInput, "utf8")),
"utf8"
);
chmodSync(electronBotGatewaySdkRunnerOutput, 0o755);
}
export async function buildCli(options = {}) {
await esbuild.build(createCliBuildOptions(options));
}
export async function buildCoreServer(options = {}) {
await esbuild.build(createCoreServerBuildOptions(options));
}
export async function buildRenderer(options = {}) {
await esbuild.build(createRendererBuildOptions(options));
}
export async function buildTrayRenderer(options = {}) {
await esbuild.build(createTrayRendererBuildOptions(options));
}
export async function buildBrowserRenderer(options = {}) {
await esbuild.build(createBrowserRendererBuildOptions(options));
}
export async function buildWebClientBridge(options = {}) {
await esbuild.build(createWebClientBridgeBuildOptions(options));
}
export function copyCliRuntimeToElectronDist() {
ensureDist();
const cliRuntime = path.join(cliMainOutDir, "cli.js");
if (existsSync(cliRuntime)) {
cpSync(cliRuntime, path.join(electronMainOutDir, "cli.js"));
}
}
export async function buildStyles({ minify = false } = {}) {
ensureDist();
const args = ["-i", cssInput, "-o", cssOutput];
if (minify) {
args.push("--minify");
}
await runCommand(binPath("tailwindcss"), args);
}
export function binPath(name) {
const extension = process.platform === "win32" ? ".cmd" : "";
return path.join(projectRoot, "node_modules", ".bin", `${name}${extension}`);
}
export function runCommand(command, args, options = {}) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
cwd: projectRoot,
stdio: "inherit",
shell: process.platform === "win32",
...options
});
child.on("error", reject);
child.on("exit", (code) => {
if (code === 0) {
resolve();
return;
}
reject(new Error(`${path.basename(command)} exited with code ${code}`));
});
});
}
function rendererAliasPlugin() {
return {
name: "renderer-alias",
setup(build) {
build.onResolve({ filter: /^@\// }, (args) => {
return { path: resolveRendererImport(args.path.slice(2)) };
});
}
};
}
function packageAliasPlugin() {
return {
name: "ccr-package-alias",
setup(build) {
build.onResolve({ filter: /^@ccr\/cli\// }, (args) => {
return { path: resolvePackageImport(cliSourceRoot, args.path.slice("@ccr/cli/".length)) };
});
build.onResolve({ filter: /^@ccr\/core\// }, (args) => {
return { path: resolvePackageImport(coreSourceRoot, args.path.slice("@ccr/core/".length)) };
});
build.onResolve({ filter: /^@ccr\/electron\// }, (args) => {
return { path: resolvePackageImport(electronSourceRoot, args.path.slice("@ccr/electron/".length)) };
});
build.onResolve({ filter: /^@ccr\/ui\// }, (args) => {
return { path: resolvePackageImport(uiSourceRoot, args.path.slice("@ccr/ui/".length)) };
});
}
};
}
function forbidCliElectronPlugin() {
return {
name: "forbid-cli-electron",
setup(build) {
build.onResolve({ filter: /^electron$/ }, () => {
return {
errors: [
{
text: "CLI bundle must not import electron. Move the dependency behind a desktop-only boundary."
}
]
};
});
}
};
}
function validateLightweightMcpBundles(metafile) {
if (!metafile) {
return;
}
const outputsByName = new Map(
Object.entries(metafile.outputs).map(([outputPath, output]) => [path.basename(outputPath), { output, outputPath }])
);
for (const bundleName of lightweightMcpBundleNames) {
const entry = outputsByName.get(bundleName);
if (!entry) {
continue;
}
const violations = [];
if (entry.output.bytes > lightweightMcpBundleMaxBytes) {
violations.push(`bundle size ${entry.output.bytes} bytes exceeds ${lightweightMcpBundleMaxBytes} bytes`);
}
for (const inputPath of Object.keys(entry.output.inputs ?? {})) {
const normalizedInput = normalizeBuildPath(inputPath);
for (const rule of forbiddenLightweightMcpInputs) {
if (normalizedInput.startsWith(rule.prefix)) {
violations.push(`${normalizedInput} (${rule.reason})`);
}
}
}
for (const imported of entry.output.imports ?? []) {
if (imported.external && forbiddenLightweightMcpExternalImports.has(imported.path)) {
violations.push(`${imported.path} (external native/runtime dependency is not allowed)`);
}
}
if (violations.length > 0) {
throw new Error([
`Lightweight MCP bundle ${bundleName} crossed its dependency boundary.`,
...violations.map((violation) => `- ${violation}`)
].join("\n"));
}
}
}
function normalizeBuildPath(value) {
return value.split(path.sep).join("/");
}
function resolveRendererImport(importPath) {
return resolvePackageImport(rendererRoot, importPath);
}
function resolvePackageImport(rootDir, importPath) {
const packageBasePath = path.resolve(rootDir, importPath);
const candidates = [
packageBasePath,
`${packageBasePath}.tsx`,
`${packageBasePath}.ts`,
`${packageBasePath}.jsx`,
`${packageBasePath}.js`,
`${packageBasePath}.json`,
`${packageBasePath}.css`,
path.join(packageBasePath, "index.tsx"),
path.join(packageBasePath, "index.ts"),
path.join(packageBasePath, "index.jsx"),
path.join(packageBasePath, "index.js")
];
for (const candidate of candidates) {
if (existsSync(candidate) && statSync(candidate).isFile()) {
return candidate;
}
}
return packageBasePath;
}