feat: consolidate local-first mnote web runtime
This commit is contained in:
+31
-167
@@ -1,33 +1,20 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* 热启动 mnote-web 单入口,以及按需启用的 FastAPI / Celery。
|
||||
* 热启动 mnote-web 单入口,以及按需启用的 FastAPI。
|
||||
* 可使用以下环境变量调整行为:
|
||||
* - FRONTEND_CMD:覆盖历史 Next 启动命令,仅在显式启用 legacy compat 或跳过 Rust gateway 时生效
|
||||
* - ENABLE_BACKEND:设为 "1" or "true" 时启用默认 FastAPI 后端
|
||||
* - BACKEND_CMD:覆盖 FastAPI 启动命令;设置后即视为显式启用后端
|
||||
* - SKIP_BACKEND:设为 "1" or "true" 可强制跳过 FastAPI 后端
|
||||
* - 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:已废弃;desktop:hot 默认不再启动 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) {
|
||||
@@ -57,23 +44,35 @@ function resolveBackendExecutable(envName, 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 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 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 shouldStartBackend(env = process.env) {
|
||||
if (isEnabledEnv(env.SKIP_BACKEND)) return false;
|
||||
if (String(env.BACKEND_CMD || "").trim()) return true;
|
||||
@@ -82,49 +81,25 @@ function shouldStartBackend(env = process.env) {
|
||||
|
||||
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;
|
||||
const skipGateway = false;
|
||||
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 = "0";
|
||||
|
||||
return {
|
||||
skipGateway,
|
||||
skipNextLegacy,
|
||||
publicPort,
|
||||
legacyPort,
|
||||
publicUrl: `http://localhost:${publicPort}`,
|
||||
legacyUrl,
|
||||
frontendTaskName: skipGateway ? "frontend" : null,
|
||||
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 || `0.0.0.0:${publicPort}`,
|
||||
MNOTE_WEB_PUBLIC_BIND: env.MNOTE_WEB_PUBLIC_BIND || `127.0.0.1:${publicPort}`,
|
||||
MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: legacyCompatEnabled,
|
||||
},
|
||||
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 frontendTasks = runtimePlan.frontendTaskName
|
||||
? [
|
||||
{
|
||||
name: runtimePlan.frontendTaskName,
|
||||
command: runtimePlan.frontendCommand,
|
||||
cwd: frontendDir,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
const tasks = [
|
||||
...frontendTasks,
|
||||
...(skipMnoteWebGateway
|
||||
? []
|
||||
: [
|
||||
@@ -142,7 +117,7 @@ const tasks = [
|
||||
name: "backend",
|
||||
command:
|
||||
process.env.BACKEND_CMD ||
|
||||
`${pythonBin} -m uvicorn app.main:app --reload --port 8000`,
|
||||
buildDefaultBackendCommand(8000),
|
||||
cwd: backendDir,
|
||||
},
|
||||
]
|
||||
@@ -394,18 +369,6 @@ 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, {
|
||||
@@ -470,55 +433,11 @@ function shutdown(code) {
|
||||
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 frontendOwnerName = "mnote-web";
|
||||
const frontendPortOk = await ensurePortFree(desiredFrontendPort, frontendOwnerName);
|
||||
if (!frontendPortOk) {
|
||||
console.error(`前端端口 ${desiredFrontendPort} 无法释放,已中止启动。`);
|
||||
@@ -527,35 +446,10 @@ async function main() {
|
||||
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 {
|
||||
|
||||
}
|
||||
const gatewayTask = tasks.find((task) => task.name === "mnote-web");
|
||||
if (gatewayTask) {
|
||||
gatewayTask.command = runtimePlan.mnoteWebCommand;
|
||||
@@ -574,42 +468,13 @@ async function main() {
|
||||
if (!backendTask) {
|
||||
throw new Error("缺少后端任务配置");
|
||||
}
|
||||
backendTask.command = `${pythonBin} -m uvicorn app.main:app --reload --port ${desiredBackendPort}`;
|
||||
backendTask.command = buildDefaultBackendCommand(desiredBackendPort);
|
||||
} else if (isEnabledEnv(process.env.SKIP_BACKEND)) {
|
||||
logPrefix("backend", "已跳过 FastAPI 后端(SKIP_BACKEND=1)。");
|
||||
} else if (!shouldStartBackend(process.env)) {
|
||||
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
}
|
||||
|
||||
if (tasks.length === 0) {
|
||||
console.error("未配置任何可运行的任务,检查环境变量设置。");
|
||||
process.exit(1);
|
||||
@@ -635,6 +500,5 @@ module.exports = {
|
||||
resolveRuntimePlan,
|
||||
resolveBackendExecutable,
|
||||
shouldStartBackend,
|
||||
shouldStartCelery,
|
||||
terminatePid,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user