Files
mnote/scripts/desktop-hot.js
T

505 lines
14 KiB
JavaScript

#!/usr/bin/env node
/**
* 热启动 mnote-web 单入口,以及按需启用的 FastAPI。
* 可使用以下环境变量调整行为:
* - ENABLE_BACKEND:设为 "1" or "true" 时启用默认 FastAPI 后端
* - BACKEND_CMD:覆盖 FastAPI 启动命令;设置后即视为显式启用后端
* - SKIP_BACKEND:设为 "1" or "true" 可强制跳过 FastAPI 后端
* - PYTHON_BIN:只在 BACKEND_CMD 未覆盖时,设置 Python 可执行文件,默认 "python"
*/
const { spawn, execSync } = require("child_process");
const path = require("path");
const net = require("net");
const fs = require("fs");
const rootDir = path.resolve(__dirname, "..");
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 backendPortFromEnv = Number(process.env.BACKEND_PORT || 8000);
function hasCommand(command) {
try {
execSync(`command -v ${command}`, { stdio: "ignore", shell: "/bin/sh" });
return true;
} catch {
return false;
}
}
function shouldUseUvBackendRuntime() {
if (String(process.env.PYTHON_BIN || "").trim()) return false;
if (!fs.existsSync(path.join(backendDir, "requirements.txt"))) return false;
return hasCommand("uv");
}
function buildDefaultBackendCommand(port) {
if (shouldUseUvBackendRuntime()) {
return `uv run --with-requirements requirements.txt python -m uvicorn app.main:app --reload --port ${port}`;
}
return `${pythonBin} -m uvicorn app.main:app --reload --port ${port}`;
}
function isEnabledEnv(value) {
const normalized = String(value || "").toLowerCase();
return normalized === "1" || normalized === "true";
}
function shouldStartBackend(env = process.env) {
if (isEnabledEnv(env.SKIP_BACKEND)) return false;
if (String(env.BACKEND_CMD || "").trim()) return true;
return isEnabledEnv(env.ENABLE_BACKEND);
}
function resolveRuntimePlan(env = process.env) {
const frontendPort = Number(env.FRONTEND_PORT || 3000);
const skipGateway = false;
const publicPort = Number.isFinite(frontendPort) ? Math.floor(frontendPort) : 3000;
return {
skipGateway,
publicPort,
publicUrl: `http://localhost:${publicPort}`,
mnoteWebCommand: env.MNOTE_WEB_CMD || "cargo run -p mnote-web --bin mnote-web",
mnoteWebEnv: {
MNOTE_WEB_BIND: env.MNOTE_WEB_BIND || `0.0.0.0:${publicPort}`,
MNOTE_WEB_PUBLIC_BIND: env.MNOTE_WEB_PUBLIC_BIND || `127.0.0.1:${publicPort}`,
},
};
}
const runtimePlan = resolveRuntimePlan(process.env);
const skipMnoteWebGateway = runtimePlan.skipGateway;
const tasks = [
...(skipMnoteWebGateway
? []
: [
{
name: "mnote-web",
command:
process.env.MNOTE_WEB_CMD ||
runtimePlan.mnoteWebCommand,
cwd: path.join(rootDir, "rust"),
},
]),
...(shouldStartBackend(process.env)
? [
{
name: "backend",
command:
process.env.BACKEND_CMD ||
buildDefaultBackendCommand(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 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 main() {
// 说明:你外网绑定了 3000 端口,这里默认强制使用 3000。
// 如果检测到 3000 被占用,则自动结束旧进程后重启,以保证始终跑在 3000。
const desiredFrontendPort = runtimePlan.publicPort;
const frontendOwnerName = "mnote-web";
const frontendPortOk = await ensurePortFree(desiredFrontendPort, frontendOwnerName);
if (!frontendPortOk) {
console.error(`前端端口 ${desiredFrontendPort} 无法释放,已中止启动。`);
process.exit(1);
}
const frontendPort = desiredFrontendPort;
const frontendUrl = `http://localhost:${frontendPort}`;
if (skipMnoteWebGateway) {
logPrefix("frontend", `前端地址:${frontendUrl}`);
} else {
logPrefix("mnote-web", `Rust gateway 公开入口:${frontendUrl}`);
const gatewayTask = tasks.find((task) => task.name === "mnote-web");
if (gatewayTask) {
gatewayTask.command = runtimePlan.mnoteWebCommand;
}
Object.assign(mergedEnv, runtimePlan.mnoteWebEnv);
}
const desiredBackendPort = backendPortFromEnv;
if (shouldStartBackend(process.env) && !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 = buildDefaultBackendCommand(desiredBackendPort);
} else if (isEnabledEnv(process.env.SKIP_BACKEND)) {
logPrefix("backend", "已跳过 FastAPI 后端(SKIP_BACKEND=1)。");
} else if (!shouldStartBackend(process.env)) {
}
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,
shouldStartBackend,
terminatePid,
};