import { writeFileSync, existsSync, mkdirSync, readFileSync } from "fs"; import browserSync, { BrowserSyncInstance } from "browser-sync"; import { execFile } from "child_process"; import { generateFileListJson } from "./files-list.js"; import { join, dirname, relative } from "path"; import { getFileMeta, PUBLIC_DIR, SRC_DIR } from "./utils.js"; import { SettledBatchWorker, createSrcWatcher, DEFAULT_AWF, } from "./utils.js"; import { startPythonServer, restartPythonServer, stopPythonServer, waitForHttpHealth, } from "./python-server.js"; import { componentMap } from "./component-map.js"; import { Socket } from "net"; import chalk from "chalk"; import { networkInterfaces, platform } from "os"; import { promisify } from "util"; import caspianConfig from "../caspian.config.json"; import { compactBrowserLog, devLogMiddleware, endBrowserLogSession, startBrowserLogSession, } from "./dev-log-bridge.js"; const { __dirname } = getFileMeta(); const bs: BrowserSyncInstance = browserSync.create(); const WORKSPACE_ROOT = join(__dirname, ".."); const PUBLIC_ROOT = join(WORKSPACE_ROOT, PUBLIC_DIR); const PUBLIC_IGNORE_DIRS = ["uploads"]; let previousRouteFiles: string[] = []; let shuttingDown = false; let serverReady = true; let pythonPort = 0; let bsPort = 0; const execFileAsync = promisify(execFile); const PORT_PROBE_HOST = "127.0.0.1"; function isWindows(): boolean { return platform() === "win32"; } function getReservedPorts(): Set { const reservedPorts = new Set(); if (!caspianConfig.mcp) { return reservedPorts; } const mcpSpecPath = join( __dirname, "..", "src", "lib", "mcp", "fastmcp.json", ); if (!existsSync(mcpSpecPath)) { return reservedPorts; } try { const mcpSpec = JSON.parse(readFileSync(mcpSpecPath, "utf-8")); const reservedPort = Number(mcpSpec?.deployment?.port); if (Number.isInteger(reservedPort) && reservedPort > 0) { reservedPorts.add(reservedPort); } } catch { // Ignore malformed local MCP config and fall back to dynamic allocation. } return reservedPorts; } function hasLoopbackListener(port: number): Promise { return new Promise((resolve) => { const socket = new Socket(); socket.setTimeout(250); socket.on("connect", () => { socket.destroy(); resolve(true); }); socket.on("timeout", () => { socket.destroy(); resolve(false); }); socket.on("error", () => { socket.destroy(); resolve(false); }); socket.connect(port, PORT_PROBE_HOST); }); } async function hasWindowsTcpListener(port: number): Promise { try { const { stdout } = await execFileAsync("netstat", ["-ano", "-p", "TCP"], { windowsHide: true, }); return stdout.split(/\r?\n/).some((line) => { const parts = line.trim().split(/\s+/); return ( parts.length >= 4 && parts[0].toUpperCase() === "TCP" && parts[1].endsWith(`:${port}`) && parts[3].toUpperCase() === "LISTENING" ); }); } catch { return false; } } async function isPortAvailable( port: number, reservedPorts: Set, ): Promise { if (reservedPorts.has(port)) { return false; } if (isWindows() && (await hasWindowsTcpListener(port))) { return false; } return !(await hasLoopbackListener(port)); } async function getAvailablePort( startPort: number, reservedPorts: Set = new Set(), ): Promise { let port = startPort; while (!(await isPortAvailable(port, reservedPorts))) { port += 1; } return port; } function getExternalIP(): string | null { const nets = networkInterfaces(); for (const name of Object.keys(nets)) { for (const net of nets[name]!) { if (net.family === "IPv4" && !net.internal) { return net.address; } } } return null; } function updateRouteFilesCache() { const filesListPath = join(__dirname, "files-list.json"); if (existsSync(filesListPath)) { const filesList = JSON.parse(readFileSync(filesListPath, "utf-8")); previousRouteFiles = filesList.filter( (f: string) => f.startsWith("./src/") && (f.endsWith(".py") || f.endsWith(".html")), ); } } function getCurrentRouteFiles(): string[] { const filesListPath = join(__dirname, "files-list.json"); if (!existsSync(filesListPath)) return []; const filesList = JSON.parse(readFileSync(filesListPath, "utf-8")); return filesList.filter( (file: string) => file.startsWith("./src/") && (file.endsWith(".py") || file.endsWith(".html")), ); } const changeCoordinator = new SettledBatchWorker( async (changes) => { const batch = [...changes]; const srcChanges = batch.filter((change) => change.startsWith("src:")); const needsGeneratedMetadata = srcChanges.length > 0; let structureChanged = false; console.log( chalk.cyan(`-> Processing ${batch.length} settled file change(s)...`), ); // Source edits invalidate the browser log: everything in it was produced by // code that just changed. Compact rather than truncate, so an interaction // error nobody has re-tested survives an unrelated save. Keyed on `src:` so // regenerated CSS and other build output do not churn the log. if (srcChanges.length > 0) { compactBrowserLog(`${srcChanges.length} source file change(s)`); } if (needsGeneratedMetadata) { await generateFileListJson(); await componentMap(); const routeFiles = getCurrentRouteFiles(); structureChanged = previousRouteFiles.length > 0 && (previousRouteFiles.length !== routeFiles.length || !routeFiles.every((file) => previousRouteFiles.includes(file))); previousRouteFiles = routeFiles; } const needsPythonRestart = structureChanged || batch.some( (change) => change.startsWith("main:") || change.startsWith("utils:") || (change.startsWith("src:") && change.endsWith(".py")), ); if (!needsPythonRestart) return; if (structureChanged) { console.log( chalk.yellow("-> Route structure changed; restarting Python server..."), ); } serverReady = false; await restartPythonServer(pythonPort, bsPort); serverReady = await waitForHttpHealth(pythonPort); if (!serverReady) { console.error( chalk.red("Warning: Python server health check failed or timed out."), ); } }, () => { if (serverReady && bs.active) { console.log(chalk.cyan("-> Changes settled, reloading browser once...")); bs.reload(); } }, 1500, "bs-change-coordinator", ); function isIgnoredPublicPath(absPath: string): boolean { const normalizedPath = relative(PUBLIC_ROOT, absPath).replace(/\\/g, "/"); return PUBLIC_IGNORE_DIRS.some( (dir) => normalizedPath === dir || normalizedPath.startsWith(`${dir}/`), ); } type ClosableWatcher = { close: () => Promise; }; const sourceWatchers: ClosableWatcher[] = []; async function closeWatchers(): Promise { await Promise.all( sourceWatchers.splice(0).map(async (watcher) => { try { await watcher.close(); } catch {} }), ); } async function shutdown(exitCode: number): Promise { if (shuttingDown) { return; } shuttingDown = true; // Close the browser log first: a reader that finds no end marker treats the // session as abandoned, so a clean exit should say so before anything can hang. endBrowserLogSession(); await closeWatchers(); if (bs.active) { bs.exit(); } stopPythonServer(); process.exit(exitCode); } (async () => { const reservedPorts = getReservedPorts(); console.log("[browserSync] Selecting available frontend and backend ports..."); bsPort = await getAvailablePort(5090, reservedPorts); pythonPort = await getAvailablePort(5200, reservedPorts); if (bsPort !== 5090 || pythonPort !== 5200) { console.warn( chalk.yellow( `[browserSync] Default ports are occupied; using frontend ${bsPort} and backend ${pythonPort}. ` + "Another dev stack may already be running.", ), ); } startBrowserLogSession(bsPort); updateRouteFilesCache(); sourceWatchers.push( createSrcWatcher(join(SRC_DIR, "**", "*"), { onEvent: (_ev, _abs, rel) => { if (rel.includes("__pycache__") || rel.endsWith(".pyc")) return; changeCoordinator.schedule(`src:${_ev}:${rel}`); }, awaitWriteFinish: DEFAULT_AWF, logPrefix: "watch-src", usePolling: true, interval: 1000, }), ); sourceWatchers.push( createSrcWatcher(join(PUBLIC_DIR, "**", "*"), { onEvent: (_ev, abs, _) => { const relFromPublic = relative(PUBLIC_ROOT, abs).replace(/\\/g, "/"); if (isIgnoredPublicPath(abs)) return; changeCoordinator.schedule(`public:${_ev}:${relFromPublic}`); }, awaitWriteFinish: DEFAULT_AWF, logPrefix: "watch-public", usePolling: true, interval: 1000, }), ); const viteFlagFile = join(__dirname, "..", ".casp", ".vite-build-complete"); mkdirSync(dirname(viteFlagFile), { recursive: true }); if (!existsSync(viteFlagFile)) writeFileSync(viteFlagFile, "0"); sourceWatchers.push( createSrcWatcher(viteFlagFile, { onEvent: (ev) => { if (ev === "change") { changeCoordinator.schedule("vite:build-complete"); } }, awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 50 }, logPrefix: "watch-vite", usePolling: true, interval: 500, }), ); sourceWatchers.push( createSrcWatcher(join(__dirname, "..", "utils", "**", "*.py"), { onEvent: (_ev, _abs, rel) => { if (_abs.includes("__pycache__")) return; changeCoordinator.schedule(`utils:${_ev}:${rel}`); }, awaitWriteFinish: DEFAULT_AWF, logPrefix: "watch-utils", usePolling: true, interval: 1000, }), ); sourceWatchers.push( createSrcWatcher(join(__dirname, "..", "main.py"), { onEvent: (_ev) => { changeCoordinator.schedule(`main:${_ev}:main.py`); }, awaitWriteFinish: DEFAULT_AWF, logPrefix: "watch-main", usePolling: true, interval: 1000, }), ); startPythonServer(pythonPort, bsPort); bs.init( { proxy: { target: `http://localhost:${pythonPort}`, ws: true, }, port: bsPort, online: true, middleware: [ (_req, res, next) => { res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate"); res.setHeader("Pragma", "no-cache"); res.setHeader("Expires", "0"); next(); }, // Serves the console hook and receives its reports, so browser-side // PulsePoint errors show up in this terminal instead of only in DevTools. devLogMiddleware, ], notify: false, open: false, ghostMode: false, codeSync: true, logLevel: "silent", }, (err, bsInstance) => { if (err) { console.error(chalk.red("BrowserSync failed to start:"), err); return; } const urls = bsInstance.getOption("urls"); const localUrl = urls.get("local") || `http://localhost:${bsPort}`; const externalIP = getExternalIP(); const externalUrl = urls.get("external") || (externalIP ? `http://${externalIP}:${bsPort}` : null); const uiUrl = urls.get("ui"); const uiExtUrl = urls.get("ui-external"); console.log(""); console.log(chalk.green.bold("OK Ports Configured:")); console.log( ` ${chalk.blue.bold("Frontend (BrowserSync):")} ${chalk.magenta(localUrl)}`, ); console.log( ` ${chalk.yellow.bold("Backend (Python):")} ${chalk.magenta( `http://localhost:${pythonPort}`, )}`, ); console.log(chalk.gray(" ------------------------------------")); if (externalUrl) { console.log( ` ${chalk.bold("External:")} ${chalk.magenta(externalUrl)}`, ); } if (uiUrl) { console.log(` ${chalk.bold("UI:")} ${chalk.magenta(uiUrl)}`); } const out = { local: localUrl, external: externalUrl, ui: uiUrl, uiExternal: uiExtUrl, backend: `http://localhost:${pythonPort}`, }; writeFileSync( join(__dirname, "bs-config.json"), JSON.stringify(out, null, 2), ); console.log(`\n${chalk.gray("Press Ctrl+C to stop.")}\n`); }, ); })(); process.once("SIGINT", () => { void shutdown(0); }); process.once("SIGTERM", () => { void shutdown(0); }); process.once("uncaughtException", (error) => { console.error(error); void shutdown(1); }); process.once("unhandledRejection", (reason) => { console.error(reason); void shutdown(1); });