claude-code-router/build/dev.mjs

534 lines
15 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 } 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 restartInFlight = false;
let restartQueued = false;
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 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, options = {}) {
const signature = options.metadataOnly
? metadataSignature(targetPath)
: contentSignature(targetPath);
watchSignatures.set(label, signature.key);
logDev(`watch baseline: ${label} ${relativePath(targetPath)}; ${signature.summary}`);
}
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 pollWatchedInput(label, targetPath, onChange, options = {}) {
const signature = options.metadataOnly
? metadataSignature(targetPath)
: contentSignature(targetPath);
const previousSignature = watchSignatures.get(label);
if (previousSignature === signature.key) {
return;
}
watchSignatures.set(label, signature.key);
logDev(`watch event: ${label} ${relativePath(targetPath)}; ${signature.summary}; content=changed`);
try {
onChange();
if (enabled.electron && options.restart !== false) {
scheduleRestart(label);
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
logDev(`watch action failed: ${label}; ${message}`);
}
}
function metadataSignature(targetPath) {
if (!existsSync(targetPath)) {
return {
key: "missing",
summary: "missing"
};
}
const stats = statSync(targetPath);
return {
key: `metadata:${stats.size}:${stats.mtimeMs}:${stats.ctimeMs}`,
summary: `size=${stats.size} mtime=${stats.mtime.toISOString()} ctime=${stats.ctime.toISOString()}`
};
}
function pollSourceWatchTargets() {
pollWatchedInput("home html", rendererHtmlInput, () => {
copyRendererHtml();
syncUiRendererToRuntimeDists();
});
pollWatchedInput("browser html", browserRendererHtmlInput, () => {
copyBrowserRendererHtml();
syncUiRendererToRuntimeDists();
});
pollWatchedInput("tray html", trayRendererHtmlInput, () => {
copyTrayRendererHtml();
syncUiRendererToRuntimeDists();
});
if (enabled.electron) {
pollWatchedInput("app assets", appAssetsInput, copyAppAssets);
}
if ((enabled.cli || enabled.electron) && existsSync(modelCatalogInput)) {
pollWatchedInput("model catalog", modelCatalogInput, copyModelCatalog, { metadataOnly: true });
}
}
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);
}
async function restartElectron() {
if (restartInFlight) {
restartQueued = true;
return;
}
restartInFlight = true;
const reasons = Array.from(new Set(pendingRestartReasons));
pendingRestartReasons = [];
restartTimer = null;
try {
if (electronProcess) {
await stopElectron(electronProcess);
}
if (shuttingDown) {
return;
}
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;
}
});
} finally {
restartInFlight = false;
if (restartQueued || pendingRestartReasons.length > 0) {
restartQueued = false;
scheduleRestart("changes queued during Electron restart");
}
}
}
async function stopElectron(child) {
logDev(`stopping Electron pid=${child.pid ?? "unknown"}`);
if (child.exitCode !== null || child.signalCode !== null) {
if (electronProcess === child) {
electronProcess = null;
}
return;
}
await new Promise((resolve) => {
let forceTimer = null;
let giveUpTimer = null;
const finish = () => {
if (forceTimer) clearTimeout(forceTimer);
if (giveUpTimer) clearTimeout(giveUpTimer);
child.off("exit", finish);
resolve();
};
child.once("exit", finish);
child.kill();
forceTimer = setTimeout(() => {
if (child.exitCode === null && child.signalCode === null) {
logDev(`force stopping Electron pid=${child.pid ?? "unknown"}`);
child.kill("SIGKILL");
}
}, 2_000);
giveUpTimer = setTimeout(finish, 5_000);
});
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, { metadataOnly: true });
}
const sourcePoller = setInterval(() => {
pollStyleWatchRoots();
pollSourceWatchTargets();
}, stylePollIntervalMs);
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) {
await stopElectron(electronProcess);
}
if (styleBuildTimer) {
clearTimeout(styleBuildTimer);
}
clearInterval(sourcePoller);
await Promise.all(contexts.map((context) => context.dispose()));
process.exit(0);
}
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);