Files
mnote/scripts/desktop-hot.js
T

612 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* 同时热启动前端、FastAPI 与 Celery。
* 可使用以下环境变量调整行为:
* - FRONTEND_CMD:覆盖前端启动命令,默认为 "pnpm dev"
* - BACKEND_CMD:覆盖 FastAPI 启动命令,默认为 "python -m uvicorn app.main:app --reload --port 8000"
* - CELERY_CMD:覆盖 Celery 启动命令,默认为 "celery -A app.workers.celery_app worker --loglevel=info"
* - CELERY_POOL:只在 CELERY_CMD 未覆盖时生效,设置 Celery worker poolWindows 默认 "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 入口。
* - NEXT_LEGACY_PORTRust gateway 模式下 Next legacy upstream 端口,默认 3100。
* - SKIP_NEXT_LEGACY:设为 "1" or "true" 时只启动 Rust gateway,不启动 Next legacy upstream。
*/
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 skipCelery =
(process.env.SKIP_CELERY || "").toLowerCase() === "1" ||
(process.env.SKIP_CELERY || "").toLowerCase() === "true";
const celeryCmdFromEnv = process.env.CELERY_CMD;
const redisUrl = process.env.REDIS_URL || "redis://localhost:6379/0";
const frontendPortFromEnv = Number(process.env.FRONTEND_PORT || 3000);
const backendPortFromEnv = Number(process.env.BACKEND_PORT || 8000);
const nextLegacyPortFromEnv = Number(process.env.NEXT_LEGACY_PORT || 3100);
function isEnabledEnv(value) {
const normalized = String(value || "").toLowerCase();
return normalized === "1" || normalized === "true";
}
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 skipNextLegacy = !skipGateway && isEnabledEnv(env.SKIP_NEXT_LEGACY);
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 = skipNextLegacy ? "0" : env.MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT || "1";
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() {
// 说明:Next dev 在异常退出时可能残留 `.next/dev/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 (!skipMnoteWebGateway) {
nextLegacyPort = runtimePlan.legacyPort;
const nextLegacyPortOk = await ensurePortFree(nextLegacyPort, "next-legacy");
if (!nextLegacyPortOk) {
console.error(`Next legacy 端口 ${nextLegacyPort} 无法释放,已中止启动。`);
process.exit(1);
}
}
// 说明:在 Windows 的 cmd.exe 下,`pnpm dev -- -p 3000` 会把 `--` 原样传给 next,导致 next 把 `-p` 误当成目录。
// 用 `pnpm dev -p 3000` 在 PowerShell/cmd.exe 下都能正确传参。
const frontendTask = skipMnoteWebGateway ? findTask("frontend") : findTask("next-legacy");
if (!frontendTask) {
throw new Error("缺少前端任务配置");
}
frontendTask.command = runtimePlan.frontendCommand;
logPrefix(frontendTask.name, `前端目录:${frontendDir}`);
if (skipMnoteWebGateway) {
logPrefix("frontend", `前端地址:${frontendUrl}`);
} else {
const legacyUrl = runtimePlan.legacyUrl;
logPrefix("mnote-web", `Rust gateway 公开入口:${frontendUrl}`);
logPrefix("next-legacy", `Next legacy upstream${legacyUrl}`);
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 (!skipCelery) {
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 或设置 SKIP_CELERY=1 显式跳过。`,
);
}
}
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,
terminatePid,
};