mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-07-09 17:18:24 +00:00
486 lines
14 KiB
JavaScript
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);
|