claude-code-router/build/dev.mjs
2026-07-05 19:57:39 +08:00

486 lines
14 KiB
JavaScript

import electron from "electron";
import esbuild from "esbuild";
import { createHash } from "node:crypto";
import { spawn } from "node:child_process";
import { existsSync, readdirSync, readFileSync, statSync, watch } from "node:fs";
import path from "node:path";
import {
buildStyles,
cleanDist,
browserRendererHtmlInput,
cliSourceRoot,
coreSourceRoot,
copyAppAssets,
copyBrowserRendererHtml,
copyCliRuntimeToElectronDist,
copyMarketplacePlugins,
copyModelCatalog,
copyRendererHtml,
copyTrayRendererHtml,
createBrowserRendererBuildOptions,
createCliBuildOptions,
createMainBuildOptions,
createRendererBuildOptions,
createTrayRendererBuildOptions,
createWebClientBridgeBuildOptions,
appAssetsInput,
modelCatalogInput,
projectRoot,
rendererRoot,
rendererHtmlInput,
syncUiRendererToRuntimeDists,
trayRendererHtmlInput,
watchPlugin
} from "./esbuild.config.mjs";
let electronProcess = null;
let restartTimer = null;
let pendingRestartReasons = [];
const watchSignatures = new Map();
let shuttingDown = false;
const restartDelayMs = 160;
const styleBuildDelayMs = 160;
const stylePollIntervalMs = 1000;
const ignoredSignatureEntries = new Set([".DS_Store"]);
let styleBuildTimer = null;
let styleBuildInFlight = false;
let queuedStyleBuildReason = null;
const ready = {
browser: false,
cli: false,
main: false,
renderer: false,
tray: false,
webBridge: false
};
const devTarget = parseDevTarget(process.argv.slice(2));
const enabled = {
cli: devTarget === "cli" || devTarget === "electron",
electron: devTarget === "electron",
ui: true
};
const coreSharedSourceRoot = path.join(coreSourceRoot, "shared");
const styleWatchRoots = [rendererRoot, coreSharedSourceRoot].filter((watchRoot) => existsSync(watchRoot));
const activeReadyNames = new Set([
...(enabled.ui ? ["browser", "renderer", "tray", "webBridge"] : []),
...(enabled.cli ? ["cli"] : []),
...(enabled.electron ? ["main"] : [])
]);
function parseDevTarget(args) {
const target = args[0] ?? "electron";
if (target === "--help" || target === "-h") {
console.log("Usage: node build/dev.mjs [ui|cli|electron]");
process.exit(0);
}
if (target === "ui" || target === "cli" || target === "electron") {
return target;
}
console.error(`Unknown dev target "${target}". Expected ui, cli, or electron.`);
process.exit(2);
}
function logDev(message) {
console.log(`[dev] ${new Date().toISOString()} ${message}`);
}
function relativePath(file) {
return path.relative(projectRoot, file) || ".";
}
function readyState() {
return Object.entries(ready)
.filter(([name]) => activeReadyNames.has(name))
.map(([name, value]) => `${name}:${value ? "ready" : "pending"}`)
.join(" ");
}
function describeWatchEvent(label, watchedPath, eventType, filename, isDirectory = false) {
const changedPath = filename
? path.join(isDirectory ? watchedPath : path.dirname(watchedPath), String(filename))
: watchedPath;
return `${label} ${eventType} ${relativePath(changedPath)}`;
}
function contentSignature(targetPath) {
try {
return readContentSignature(targetPath);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
key: `error:${message}`,
summary: `signature-error=${message}`
};
}
}
function readContentSignature(targetPath) {
if (!existsSync(targetPath)) {
return {
key: "missing",
summary: "missing"
};
}
const stats = statSync(targetPath);
if (stats.isDirectory()) {
return directorySignature(targetPath);
}
const content = readFileSync(targetPath);
const hash = createHash("sha1").update(content).digest("hex").slice(0, 12);
return {
key: `file:${hash}`,
summary: `size=${stats.size} mtime=${stats.mtime.toISOString()} ctime=${stats.ctime.toISOString()} sha1=${hash}`
};
}
function directorySignature(targetPath) {
const files = listDirectoryFiles(targetPath);
const hash = createHash("sha1");
let newestMtimeMs = 0;
for (const file of files) {
const absolutePath = path.join(targetPath, file);
const stats = statSync(absolutePath);
newestMtimeMs = Math.max(newestMtimeMs, stats.mtimeMs);
hash.update(file);
hash.update("\0");
hash.update(readFileSync(absolutePath));
hash.update("\0");
}
const digest = hash.digest("hex").slice(0, 12);
const newestMtime = newestMtimeMs > 0 ? new Date(newestMtimeMs).toISOString() : "none";
return {
key: `dir:${digest}`,
summary: `files=${files.length} newestMtime=${newestMtime} sha1=${digest}`
};
}
function listDirectoryFiles(targetPath, basePath = targetPath) {
const entries = readdirSync(targetPath, { withFileTypes: true })
.filter((entry) => !ignoredSignatureEntries.has(entry.name))
.sort((a, b) => a.name.localeCompare(b.name));
const files = [];
for (const entry of entries) {
const absolutePath = path.join(targetPath, entry.name);
const relative = path.relative(basePath, absolutePath);
if (entry.isDirectory()) {
files.push(...listDirectoryFiles(absolutePath, basePath));
} else if (entry.isFile()) {
files.push(relative);
}
}
return files;
}
function rememberWatchSignature(label, targetPath) {
const signature = contentSignature(targetPath);
watchSignatures.set(label, signature.key);
logDev(`watch baseline: ${label} ${relativePath(targetPath)}; ${signature.summary}`);
}
function handleWatchedInput(label, watchedPath, eventType, filename, options, onChange) {
const reason = describeWatchEvent(label, watchedPath, eventType, filename, options?.isDirectory);
const signature = contentSignature(watchedPath);
const previousSignature = watchSignatures.get(label);
const changed = previousSignature !== signature.key;
watchSignatures.set(label, signature.key);
logDev(`watch event: ${reason}; ${signature.summary}; content=${changed ? "changed" : "unchanged"}`);
if (!changed) {
logDev(`restart skipped: ${reason} (content unchanged)`);
return;
}
onChange();
if (enabled.electron && options?.restart !== false) {
scheduleRestart(reason);
}
}
function scheduleStyleBuild(reason) {
queuedStyleBuildReason = reason;
if (styleBuildTimer) {
clearTimeout(styleBuildTimer);
}
styleBuildTimer = setTimeout(() => {
styleBuildTimer = null;
void rebuildStyles(queuedStyleBuildReason ?? reason);
}, styleBuildDelayMs);
}
async function rebuildStyles(reason) {
if (styleBuildInFlight) {
queuedStyleBuildReason = reason;
return;
}
styleBuildInFlight = true;
queuedStyleBuildReason = null;
try {
logDev(`rebuilding styles: ${reason}`);
await buildStyles({ minify: false });
syncUiRendererToRuntimeDists();
if (enabled.electron) {
scheduleRestart(`styles rebuilt: ${reason}`);
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
logDev(`style rebuild failed: ${message}`);
} finally {
styleBuildInFlight = false;
if (queuedStyleBuildReason) {
const queuedReason = queuedStyleBuildReason;
queuedStyleBuildReason = null;
scheduleStyleBuild(queuedReason);
}
}
}
function pollStyleWatchRoots() {
for (const styleWatchRoot of styleWatchRoots) {
const label = `styles ${relativePath(styleWatchRoot)}`;
const signature = contentSignature(styleWatchRoot);
const previousSignature = watchSignatures.get(label);
if (previousSignature === signature.key) {
continue;
}
watchSignatures.set(label, signature.key);
logDev(`watch event: ${label}; ${signature.summary}; content=changed`);
scheduleStyleBuild(label);
}
}
function markReady(name, reason = `${name} esbuild completed`) {
if (name === "browser" || name === "cli" || name === "main" || name === "renderer" || name === "tray" || name === "webBridge") {
ready[name] = true;
}
logDev(`build ready: ${reason}; ${readyState()}`);
if (enabled.electron && Array.from(activeReadyNames).every((readyName) => ready[readyName])) {
scheduleRestart(reason);
}
}
function scheduleRestart(reason = "unknown trigger") {
if (shuttingDown) {
logDev(`restart ignored during shutdown: ${reason}`);
return;
}
pendingRestartReasons.push(reason);
if (restartTimer) {
clearTimeout(restartTimer);
logDev(`restart rescheduled in ${restartDelayMs}ms: ${reason}`);
} else {
logDev(`restart scheduled in ${restartDelayMs}ms: ${reason}`);
}
restartTimer = setTimeout(restartElectron, restartDelayMs);
}
function restartElectron() {
const reasons = Array.from(new Set(pendingRestartReasons));
pendingRestartReasons = [];
restartTimer = null;
if (electronProcess) {
logDev(`stopping Electron pid=${electronProcess.pid ?? "unknown"}`);
electronProcess.kill();
electronProcess = null;
}
logDev(`starting Electron; reasons=${reasons.join(" | ") || "initial start"}`);
const child = spawn(electron, ["."], {
cwd: projectRoot,
env: {
...process.env,
NODE_ENV: "development"
},
stdio: "inherit"
});
electronProcess = child;
logDev(`Electron started pid=${child.pid ?? "unknown"}`);
child.on("exit", (code, signal) => {
logDev(`Electron exited pid=${child.pid ?? "unknown"} code=${code ?? "null"} signal=${signal ?? "null"}`);
if (electronProcess === child) {
electronProcess = null;
}
});
}
logDev(`starting dev build target=${devTarget} ui=${enabled.ui ? "on" : "off"} cli=${enabled.cli ? "on" : "off"} electron=${enabled.electron ? "on" : "off"}`);
cleanDist();
if (enabled.electron) {
copyAppAssets();
}
if (enabled.cli || enabled.electron) {
copyMarketplacePlugins();
copyModelCatalog();
}
copyBrowserRendererHtml();
copyRendererHtml();
copyTrayRendererHtml();
await buildStyles({ minify: false });
syncUiRendererToRuntimeDists();
rememberWatchSignature("home html", rendererHtmlInput);
rememberWatchSignature("browser html", browserRendererHtmlInput);
rememberWatchSignature("tray html", trayRendererHtmlInput);
for (const styleWatchRoot of styleWatchRoots) {
rememberWatchSignature(`styles ${relativePath(styleWatchRoot)}`, styleWatchRoot);
}
if (enabled.electron) {
rememberWatchSignature("app assets", appAssetsInput);
}
if ((enabled.cli || enabled.electron) && existsSync(modelCatalogInput)) {
rememberWatchSignature("model catalog", modelCatalogInput);
}
const htmlWatcher = watch(rendererHtmlInput, { persistent: true }, (eventType, filename) => {
handleWatchedInput("home html", rendererHtmlInput, eventType, filename, undefined, () => {
copyRendererHtml();
syncUiRendererToRuntimeDists();
});
});
const browserHtmlWatcher = watch(browserRendererHtmlInput, { persistent: true }, (eventType, filename) => {
handleWatchedInput("browser html", browserRendererHtmlInput, eventType, filename, undefined, () => {
copyBrowserRendererHtml();
syncUiRendererToRuntimeDists();
});
});
const trayHtmlWatcher = watch(trayRendererHtmlInput, { persistent: true }, (eventType, filename) => {
handleWatchedInput("tray html", trayRendererHtmlInput, eventType, filename, undefined, () => {
copyTrayRendererHtml();
syncUiRendererToRuntimeDists();
});
});
const stylePoller = setInterval(pollStyleWatchRoots, stylePollIntervalMs);
const appAssetsWatcher = enabled.electron
? watch(appAssetsInput, { persistent: true }, (eventType, filename) => {
handleWatchedInput("app assets", appAssetsInput, eventType, filename, { isDirectory: true }, copyAppAssets);
})
: { close: () => undefined };
const modelCatalogWatcher = (enabled.cli || enabled.electron) && existsSync(modelCatalogInput)
? watch(modelCatalogInput, { persistent: true }, (eventType, filename) => {
handleWatchedInput("model catalog", modelCatalogInput, eventType, filename, undefined, copyModelCatalog);
})
: { close: () => undefined };
const contexts = [];
if (enabled.electron) {
contexts.push(
await esbuild.context(
createMainBuildOptions({
mode: "development",
plugins: [watchPlugin("main", (name) => markReady(name))]
})
)
);
}
if (enabled.cli) {
contexts.push(
await esbuild.context(
createCliBuildOptions({
mode: "development",
plugins: [
watchPlugin("cli", (name) => {
if (enabled.electron) {
copyCliRuntimeToElectronDist();
}
markReady(name);
})
]
})
)
);
}
if (enabled.ui) {
contexts.push(
await esbuild.context(
createRendererBuildOptions({
mode: "development",
plugins: [
watchPlugin("renderer", (name) => {
copyRendererHtml();
syncUiRendererToRuntimeDists();
markReady(name);
})
]
})
),
await esbuild.context(
createTrayRendererBuildOptions({
mode: "development",
plugins: [
watchPlugin("tray", (name) => {
copyTrayRendererHtml();
syncUiRendererToRuntimeDists();
markReady(name);
})
]
})
),
await esbuild.context(
createBrowserRendererBuildOptions({
mode: "development",
plugins: [
watchPlugin("browser", (name) => {
copyBrowserRendererHtml();
syncUiRendererToRuntimeDists();
markReady(name);
})
]
})
),
await esbuild.context(
createWebClientBridgeBuildOptions({
mode: "development",
plugins: [
watchPlugin("webBridge", (name) => {
syncUiRendererToRuntimeDists();
markReady(name);
})
]
})
)
);
}
await Promise.all(contexts.map((context) => context.watch()));
logDev("watchers are active");
async function shutdown() {
logDev("shutting down dev build");
shuttingDown = true;
if (restartTimer) {
clearTimeout(restartTimer);
}
if (electronProcess) {
electronProcess.kill();
}
if (styleBuildTimer) {
clearTimeout(styleBuildTimer);
}
htmlWatcher.close();
browserHtmlWatcher.close();
trayHtmlWatcher.close();
clearInterval(stylePoller);
appAssetsWatcher.close();
modelCatalogWatcher.close();
await Promise.all(contexts.map((context) => context.dispose()));
process.exit(0);
}
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);