#!/usr/bin/env node /** * 同时热启动前端、FastAPI,以及按需启用的 Celery。 * 可使用以下环境变量调整行为: * - FRONTEND_CMD:覆盖历史 Next 启动命令,仅在显式启用 legacy compat 或跳过 Rust gateway 时生效 * - BACKEND_CMD:覆盖 FastAPI 启动命令,默认为 "python -m uvicorn app.main:app --reload --port 8000" * - ENABLE_CELERY:设为 "1" or "true" 时启用默认 Celery worker * - CELERY_CMD:覆盖 Celery 启动命令;设置后即视为显式启用 Celery * - CELERY_POOL:只在 CELERY_CMD 未覆盖时生效,设置 Celery worker pool;Windows 默认 "solo",其他平台默认使用 Celery 自身默认值 * - PYTHON_BIN:只在 BACKEND_CMD 未覆盖时,设置 Python 可执行文件,默认 "python" * - CELERY_BIN:只在 CELERY_CMD 未覆盖时,设置 Celery 可执行文件,默认 "celery" * - REDIS_URL:仅用于探测 Redis 是否就绪,默认 "redis://localhost:6379/0" * - SKIP_CELERY:设为 "1" or "true" 可强制跳过 Celery。 * - MNOTE_WEB_SKIP_GATEWAY:设为 "1" or "true" 临时恢复旧 Next 3000 入口。 * - MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT:设为 "1" or "true" 时才启动 Next legacy upstream。 * - NEXT_LEGACY_PORT:显式启用 legacy compat 时的 Next upstream 端口,默认 3100。 * - SKIP_NEXT_LEGACY:兼容旧环境变量;设为 "1" or "true" 时强制只启动 Rust gateway。 */ const { spawn, execSync } = require("child_process"); const path = require("path"); const net = require("net"); const { URL } = require("url"); const fs = require("fs"); const rootDir = path.resolve(__dirname, ".."); const frontendDir = path.join(rootDir, "wolai-frontend"); const backendDir = path.join(rootDir, "wolai-backend"); function resolveBackendExecutable(envName, fallbackName) { const fromEnv = (process.env[envName] || "").trim(); if (fromEnv) return fromEnv; const isWin = process.platform === "win32"; const candidates = isWin ? [ path.join(backendDir, ".venv", "Scripts", `${fallbackName}.exe`), path.join(backendDir, ".venv312", "Scripts", `${fallbackName}.exe`), path.join(backendDir, "venv", "Scripts", `${fallbackName}.exe`), ] : [ path.join(backendDir, ".venv-linux", "bin", fallbackName), path.join(backendDir, ".venv", "bin", fallbackName), path.join(backendDir, "venv", "bin", fallbackName), ]; for (const candidate of candidates) { if (fs.existsSync(candidate)) { return candidate; } } return fallbackName; } const pythonBin = resolveBackendExecutable("PYTHON_BIN", "python"); const celeryBin = resolveBackendExecutable("CELERY_BIN", "celery"); const celeryPoolFromEnv = (process.env.CELERY_POOL || "").trim(); const celeryCmdFromEnv = (process.env.CELERY_CMD || "").trim(); const redisUrl = process.env.REDIS_URL || "redis://localhost:6379/0"; const backendPortFromEnv = Number(process.env.BACKEND_PORT || 8000); function isEnabledEnv(value) { const normalized = String(value || "").toLowerCase(); return normalized === "1" || normalized === "true"; } function shouldStartCelery(env = process.env) { if (isEnabledEnv(env.SKIP_CELERY)) return false; if (String(env.CELERY_CMD || "").trim()) return true; return isEnabledEnv(env.ENABLE_CELERY); } function resolveRuntimePlan(env = process.env) { const frontendPort = Number(env.FRONTEND_PORT || 3000); const nextLegacyPort = Number(env.NEXT_LEGACY_PORT || 3100); const skipGateway = isEnabledEnv(env.MNOTE_WEB_SKIP_GATEWAY); const legacyCompatRequested = !skipGateway && isEnabledEnv(env.MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT) && !isEnabledEnv(env.SKIP_NEXT_LEGACY); const skipNextLegacy = !skipGateway && !legacyCompatRequested; const publicPort = Number.isFinite(frontendPort) ? Math.floor(frontendPort) : 3000; const legacyPort = Number.isFinite(nextLegacyPort) ? Math.floor(nextLegacyPort) : 3100; const legacyUrl = `http://127.0.0.1:${legacyPort}`; const legacyCompatEnabled = legacyCompatRequested ? "1" : "0"; return { skipGateway, skipNextLegacy, publicPort, legacyPort, publicUrl: `http://localhost:${publicPort}`, legacyUrl, frontendTaskName: skipGateway ? "frontend" : skipNextLegacy ? null : "next-legacy", frontendCommand: env.FRONTEND_CMD || `pnpm dev -p ${skipGateway ? publicPort : legacyPort}`, mnoteWebCommand: env.MNOTE_WEB_CMD || "cargo run -p mnote-web --bin mnote-web", mnoteWebEnv: skipGateway ? {} : { MNOTE_WEB_BIND: env.MNOTE_WEB_BIND || `127.0.0.1:${publicPort}`, MNOTE_WEB_PUBLIC_BIND: env.MNOTE_WEB_PUBLIC_BIND || `127.0.0.1:${publicPort}`, ...(skipNextLegacy ? {} : { MNOTE_WEB_LEGACY_NEXT_BASE_URL: env.MNOTE_WEB_LEGACY_NEXT_BASE_URL || legacyUrl }), MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: legacyCompatEnabled, }, }; } const runtimePlan = resolveRuntimePlan(process.env); const skipMnoteWebGateway = runtimePlan.skipGateway; const frontendTasks = runtimePlan.frontendTaskName ? [ { name: runtimePlan.frontendTaskName, command: runtimePlan.frontendCommand, cwd: frontendDir, }, ] : []; const tasks = [ ...frontendTasks, ...(skipMnoteWebGateway ? [] : [ { name: "mnote-web", command: process.env.MNOTE_WEB_CMD || runtimePlan.mnoteWebCommand, cwd: path.join(rootDir, "rust"), }, ]), { name: "backend", command: process.env.BACKEND_CMD || `${pythonBin} -m uvicorn app.main:app --reload --port 8000`, cwd: backendDir, }, ]; function findTask(name) { return tasks.find((task) => task.name === name); } const children = []; let shuttingDown = false; async function isPortFree(host, port, timeoutMs = 400) { return await new Promise((resolve) => { const socket = net.createConnection({ host, port }); const timer = setTimeout(() => { socket.destroy(); resolve(true); }, timeoutMs); socket.once("connect", () => { clearTimeout(timer); socket.end(); resolve(false); }); socket.once("error", () => { clearTimeout(timer); resolve(true); }); }); } async function findFreePort(startPort) { let port = Number.isFinite(startPort) ? Math.floor(startPort) : 3000; port = Math.max(1, Math.min(65535, port)); // 最多尝试 20 个端口,避免无限循环。 for (let i = 0; i < 20; i += 1) { // eslint-disable-next-line no-await-in-loop const free = await isPortFree("127.0.0.1", port); if (free) return port; port += 1; } return startPort; } function getListeningPidsByPort(port) { const pids = new Set(); if (process.platform !== "win32") { try { const out = execSync(`ss -ltnp 'sport = :${port}'`, { encoding: "utf8" }); for (const match of out.matchAll(/pid=(\d+)/g)) { const pid = Number(match[1]); if (Number.isFinite(pid) && pid > 0) { pids.add(pid); } } } catch { // ignore } if (pids.size > 0) { return [...pids]; } try { const out = execSync(`lsof -nP -iTCP:${port} -sTCP:LISTEN -t`, { encoding: "utf8" }); for (const line of out.split(/\r?\n/)) { const pid = Number(line.trim()); if (Number.isFinite(pid) && pid > 0) { pids.add(pid); } } } catch { // ignore } return [...pids]; } try { // 说明:netstat 输出示例: // TCP 0.0.0.0:3000 0.0.0.0:0 LISTENING 12345 const out = execSync("netstat -ano -p tcp", { encoding: "utf8" }); for (const line of out.split(/\r?\n/)) { if (!line.includes(`:${port}`)) continue; if (!/LISTENING/i.test(line)) continue; const parts = line.trim().split(/\s+/); const pid = Number(parts[parts.length - 1]); if (Number.isFinite(pid) && pid > 0) { pids.add(pid); } } return [...pids]; } catch { return []; } } function getProcessNameByPid(pid) { if (process.platform !== "win32") { try { return execSync(`ps -p ${pid} -o comm=`, { encoding: "utf8" }).trim(); } catch { return ""; } } try { // 输出为 CSV,示例: // "Image Name","PID","Session Name","Session#","Mem Usage" // "python.exe","12345","Console","1","12,345 K" const out = execSync(`tasklist /FI "PID eq ${pid}" /FO CSV /NH`, { encoding: "utf8", }).trim(); if (!out || /No tasks are running/i.test(out)) return ""; const firstLine = out.split(/\r?\n/)[0].trim(); if (!firstLine) return ""; const cells = firstLine .split('","') .map((s) => s.replace(/^"/, "").replace(/"$/, "")); return (cells[0] || "").trim(); } catch { return ""; } } function terminatePid(pid) { if (process.platform === "win32") { execSync(`taskkill /PID ${pid} /T /F`, { stdio: "ignore" }); return; } try { process.kill(pid, "SIGTERM"); } catch { return; } } function forceKillPid(pid) { if (process.platform === "win32") { execSync(`taskkill /PID ${pid} /T /F`, { stdio: "ignore" }); return; } try { process.kill(pid, "SIGKILL"); } catch { // 进程可能已经退出。 } } async function ensurePortFree(port, nameForLog) { const free = await isPortFree("127.0.0.1", port); if (free) return true; const pids = getListeningPidsByPort(port); if (pids.length === 0) { logPrefix(nameForLog, `检测到端口 ${port} 被占用,但无法定位 PID。`); return false; } let killPids = pids; // 安全策略:后端默认只结束 python 进程(避免误杀其他服务)。 if (nameForLog === "backend") { killPids = pids.filter((pid) => { const procName = getProcessNameByPid(pid).toLowerCase(); return procName.includes("python"); }); if (killPids.length === 0) { logPrefix( nameForLog, `检测到端口 ${port} 被占用,但没有可安全结束的 python 进程(PIDs=${pids.join(", ")})。请手动释放端口后重试。`, ); return false; } } logPrefix(nameForLog, `检测到端口 ${port} 被占用,准备重启(结束旧进程):${killPids.join(", ")}`); for (const pid of killPids) { try { terminatePid(pid); } catch { // ignore } } // 等待端口释放 for (let i = 0; i < 20; i += 1) { // eslint-disable-next-line no-await-in-loop const ok = await isPortFree("127.0.0.1", port, 250); if (ok) return true; // eslint-disable-next-line no-await-in-loop await new Promise((r) => setTimeout(r, 150)); } if (process.platform !== "win32") { for (const pid of killPids) { forceKillPid(pid); } for (let i = 0; i < 10; i += 1) { // eslint-disable-next-line no-await-in-loop const ok = await isPortFree("127.0.0.1", port, 250); if (ok) return true; // eslint-disable-next-line no-await-in-loop await new Promise((r) => setTimeout(r, 150)); } } logPrefix(nameForLog, `端口 ${port} 仍未释放,可能有其他程序占用。`); return false; } function loadEnvFile(filePath) { if (!fs.existsSync(filePath)) return {}; const content = fs.readFileSync(filePath, "utf8"); return content .split(/\r?\n/) .filter((line) => line.trim() && !line.trim().startsWith("#")) .reduce((acc, line) => { const idx = line.indexOf("="); if (idx === -1) return acc; const key = line.slice(0, idx).trim(); const value = line.slice(idx + 1).trim(); acc[key] = value; return acc; }, {}); } // 约定:全局仅使用仓库根目录的 .env.all 作为配置来源(生产优先)。 const envAllPath = path.join(rootDir, ".env.all"); if (!fs.existsSync(envAllPath)) { console.error("[system] 缺少 .env.all:请在仓库根目录创建全局唯一 env 文件后重试。"); process.exit(1); } const envFromAll = loadEnvFile(envAllPath); const mergedEnv = { ...process.env, ...envFromAll, }; function logPrefix(name, message) { console.log(`[${name}] ${message}`); } function getDefaultCeleryPool() { if (celeryPoolFromEnv) return celeryPoolFromEnv; if (process.platform === "win32") return "solo"; return ""; } function buildDefaultCeleryCommand() { const pool = getDefaultCeleryPool(); const poolArg = pool ? ` --pool=${pool}` : ""; return `${celeryBin} -A app.workers.celery_app worker --loglevel=info${poolArg}`; } function startTask(task) { logPrefix(task.name, `启动命令:${task.command}`); const child = spawn(task.command, { cwd: task.cwd, stdio: "inherit", shell: true, env: mergedEnv, }); child.on("exit", (code, signal) => { if (shuttingDown) { return; } const status = signal !== null ? `因信号 ${signal} 退出` : `退出码 ${code ?? "null"}`; logPrefix(task.name, `进程结束(${status}),准备清理其它任务。`); shutdown(code ?? 0); }); child.on("error", (err) => { logPrefix(task.name, `启动失败:${err.message}`); shutdown(1); }); children.push(child); } function shutdown(code) { if (shuttingDown) { return; } shuttingDown = true; logPrefix("system", "收到终止信号,正在关闭所有子进程…"); for (const child of children) { if (!child.pid) continue; // Windows 下,shell 子进程常常无法可靠传播 SIGINT/SIGTERM 到孙进程(例如 uvicorn --reload)。 // 这里优先用 taskkill /T /F 确保整个进程树被结束,避免残留占用端口导致下次启动失败。 if (process.platform === "win32") { try { execSync(`taskkill /PID ${child.pid} /T /F`, { stdio: "ignore" }); continue; } catch { // fallback to signals } } if (!child.killed) { child.kill("SIGINT"); setTimeout(() => { if (!child.killed) { child.kill("SIGTERM"); } }, 5000); } } setTimeout(() => process.exit(code), 200); } process.on("SIGINT", () => shutdown(0)); process.on("SIGTERM", () => shutdown(0)); async function checkRedisReachable(urlString, timeoutMs = 2000) { try { const url = new URL(urlString); const host = url.hostname || "localhost"; const port = Number(url.port) || 6379; return await new Promise((resolve) => { const socket = net.createConnection({ host, port }); const timer = setTimeout(() => { socket.destroy(); resolve(false); }, timeoutMs); socket.once("connect", () => { clearTimeout(timer); socket.end(); resolve(true); }); socket.once("error", () => { clearTimeout(timer); resolve(false); }); }); } catch (error) { logPrefix("celery", `REDIS_URL (${urlString}) 解析失败:${error.message},跳过连通性检查。`); return true; } } async function main() { if (runtimePlan.frontendTaskName) { // 说明:Next dev 在异常退出时可能残留 `.next/dev/lock`,会导致后续启动直接失败。 // 只有显式启动历史 Next 时才清理该 lock,避免默认热启动继续触碰旧前端目录。 const nextDevLockPath = path.join(frontendDir, ".next", "dev", "lock"); try { if (fs.existsSync(nextDevLockPath)) { fs.rmSync(nextDevLockPath, { force: true }); logPrefix("frontend", `检测到残留的 Next dev lock,已移除:${nextDevLockPath}`); } } catch (error) { logPrefix("frontend", `尝试移除 Next dev lock 失败:${error.message}`); } } // 说明:你外网绑定了 3000 端口,这里默认强制使用 3000。 // 如果检测到 3000 被占用,则自动结束旧进程后重启,以保证始终跑在 3000。 const desiredFrontendPort = runtimePlan.publicPort; const frontendOwnerName = skipMnoteWebGateway ? "frontend" : "mnote-web"; const frontendPortOk = await ensurePortFree(desiredFrontendPort, frontendOwnerName); if (!frontendPortOk) { console.error(`前端端口 ${desiredFrontendPort} 无法释放,已中止启动。`); process.exit(1); } const frontendPort = desiredFrontendPort; const frontendUrl = `http://localhost:${frontendPort}`; let nextLegacyPort = null; if (runtimePlan.frontendTaskName === "next-legacy") { nextLegacyPort = runtimePlan.legacyPort; const nextLegacyPortOk = await ensurePortFree(nextLegacyPort, "next-legacy"); if (!nextLegacyPortOk) { console.error(`Next legacy 端口 ${nextLegacyPort} 无法释放,已中止启动。`); process.exit(1); } } const frontendTask = runtimePlan.frontendTaskName ? findTask(runtimePlan.frontendTaskName) : null; if (runtimePlan.frontendTaskName && !frontendTask) { throw new Error(`缺少前端任务配置:${runtimePlan.frontendTaskName}`); } if (frontendTask) { // 说明:在 Windows 的 cmd.exe 下,`pnpm dev -- -p 3000` 会把 `--` 原样传给 next,导致 next 把 `-p` 误当成目录。 // 用 `pnpm dev -p 3000` 在 PowerShell/cmd.exe 下都能正确传参。 frontendTask.command = runtimePlan.frontendCommand; logPrefix(frontendTask.name, `前端目录:${frontendDir}`); } if (skipMnoteWebGateway) { logPrefix("frontend", `前端地址:${frontendUrl}`); } else { logPrefix("mnote-web", `Rust gateway 公开入口:${frontendUrl}`); if (runtimePlan.frontendTaskName === "next-legacy") { logPrefix("next-legacy", `Next legacy upstream:${runtimePlan.legacyUrl}`); } else { logPrefix("next-legacy", "默认不启动历史 Next upstream;如需临时兼容请设置 MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT=1。"); } const gatewayTask = tasks.find((task) => task.name === "mnote-web"); if (gatewayTask) { gatewayTask.command = runtimePlan.mnoteWebCommand; } Object.assign(mergedEnv, runtimePlan.mnoteWebEnv); } const desiredBackendPort = backendPortFromEnv; if (!process.env.BACKEND_CMD) { const backendPortOk = await ensurePortFree(desiredBackendPort, "backend"); if (!backendPortOk) { console.error(`后端端口 ${desiredBackendPort} 无法释放,已中止启动。`); process.exit(1); } const backendTask = findTask("backend"); if (!backendTask) { throw new Error("缺少后端任务配置"); } backendTask.command = `${pythonBin} -m uvicorn app.main:app --reload --port ${desiredBackendPort}`; } if (shouldStartCelery(process.env)) { const defaultCeleryCommand = buildDefaultCeleryCommand(); const celeryTask = { name: "celery", command: celeryCmdFromEnv || defaultCeleryCommand, cwd: backendDir, }; if (celeryCmdFromEnv) { tasks.push(celeryTask); } else if (await checkRedisReachable(redisUrl)) { const defaultPool = getDefaultCeleryPool(); if (defaultPool) { logPrefix("celery", `未显式设置 CELERY_CMD,当前平台默认使用 worker pool:${defaultPool}`); } tasks.push(celeryTask); } else { // Redis 未就绪时直接跳过 Celery,避免热调试流程整体退出。 logPrefix( "celery", `检测到 ${redisUrl} 无法连接,自动跳过 Celery。请先启动 Redis,或继续保持当前默认关闭策略。`, ); } } else if (isEnabledEnv(process.env.SKIP_CELERY)) { logPrefix("celery", "已跳过 Celery(SKIP_CELERY=1)。"); } else { logPrefix("celery", "默认不启动 Celery;当前主线页面不依赖 Redis/Celery。如需启用请设置 ENABLE_CELERY=1 或 CELERY_CMD。"); } if (tasks.length === 0) { console.error("未配置任何可运行的任务,检查环境变量设置。"); process.exit(1); } for (const task of tasks) { startTask(task); } } if (require.main === module) { main().catch((error) => { logPrefix("system", `启动失败:${error.message}`); process.exit(1); }); } module.exports = { ensurePortFree, getListeningPidsByPort, getProcessNameByPid, isPortFree, resolveRuntimePlan, resolveBackendExecutable, shouldStartCelery, terminatePid, };