Files
mnote/scripts/desktop-prod.js
T
2026-01-11 12:35:53 +08:00

306 lines
10 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
/**
* 一键启动“生产模式”的前后端与相关服务(适用于 Cloudflare Tunnel / 公网访问)。
*
* 目标:
* - 前端使用 Next production(读取 wolai-frontend/.env.production.local
* - 后端与内部服务使用本机/内网地址(读取各自 .env 或仓库根目录 .env.local
*
* 可选环境变量:
* - SKIP_FRONTEND_BUILD=1:跳过前端 build(仅 start
* - FRONTEND_PORT:默认 3000
* - BACKEND_PORT:默认 8000
* - RAG_GATEWAY_PORT:默认 8778
* - INGEST_PORT:默认 8779
* - PYTHON_BIN:默认 python
* - CELERY_BIN:默认 celery
* - SKIP_CELERY=1:跳过 Celery worker
* - REDIS_URL:用于探测 Redis,默认 redis://127.0.0.1:6379/0
*/
const { spawn } = require("child_process");
const fs = require("fs");
const net = require("net");
const path = require("path");
const { URL } = require("url");
const rootDir = path.resolve(__dirname, "..");
const frontendDir = path.join(rootDir, "wolai-frontend");
const backendDir = path.join(rootDir, "wolai-backend");
const ingestDir = path.join(rootDir, "services", "ingest_service");
const ragGatewayDir = path.join(rootDir, "services", "rag_gateway");
const pythonBin = process.env.PYTHON_BIN || "python";
const celeryBin = process.env.CELERY_BIN || "celery";
const skipCelery =
(process.env.SKIP_CELERY || "").toLowerCase() === "1" ||
(process.env.SKIP_CELERY || "").toLowerCase() === "true";
const redisUrl = process.env.REDIS_URL || "redis://127.0.0.1:6379/0";
const frontendPort = Number(process.env.FRONTEND_PORT || 3000);
const backendPort = Number(process.env.BACKEND_PORT || 8000);
const ragGatewayPort = Number(process.env.RAG_GATEWAY_PORT || 8778);
const ingestPort = Number(process.env.INGEST_PORT || 8779);
const children = [];
let shuttingDown = false;
function logPrefix(name, message) {
console.log(`[${name}] ${message}`);
}
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;
}, {});
}
async function waitForExit(child, name) {
return await new Promise((resolve) => {
child.on("exit", (code, signal) => {
const status = signal ? `信号 ${signal}` : `退出码 ${code ?? "null"}`;
logPrefix(name, `进程结束(${status}`);
resolve(code ?? 0);
});
child.on("error", () => resolve(1));
});
}
function spawnTask({ name, command, cwd, env }) {
logPrefix(name, `启动命令:${command}`);
const child = spawn(command, {
cwd,
stdio: "inherit",
shell: true,
env,
});
child.on("exit", (code, signal) => {
if (shuttingDown) return;
const status = signal !== null ? `因信号 ${signal} 退出` : `退出码 ${code ?? "null"}`;
logPrefix(name, `进程结束(${status}),准备清理其它任务。`);
shutdown(code ?? 0);
});
child.on("error", (err) => {
logPrefix(name, `启动失败:${err.message}`);
shutdown(1);
});
children.push(child);
return child;
}
function shutdown(code) {
if (shuttingDown) return;
shuttingDown = true;
logPrefix("system", "收到终止信号,正在关闭所有子进程…");
for (const child of children) {
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 checkPortOpen(host, port, timeoutMs = 1200) {
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);
});
});
}
async function checkRedisReachable(urlString, timeoutMs = 2000) {
try {
const url = new URL(urlString);
const host = url.hostname || "127.0.0.1";
const port = Number(url.port) || 6379;
return await checkPortOpen(host, port, timeoutMs);
} catch (error) {
logPrefix("celery", `REDIS_URL (${urlString}) 解析失败:${error.message},跳过连通性检查。`);
return true;
}
}
function requireFile(filePath, hint) {
if (!fs.existsSync(filePath)) {
console.error(`[system] 缺少文件:${filePath}`);
if (hint) console.error(`[system] 提示:${hint}`);
process.exit(1);
}
}
async function main() {
const frontendProdEnv = path.join(frontendDir, ".env.production.local");
// 说明:Next.js 在 production 也会加载 wolai-frontend/.env.local。
// 为避免被本地开发环境(可能指向远端 Supabase)的配置污染,这里把“服务端专用”的关键 env 显式注入。
// 规则:优先使用 wolai-frontend/.env.production.local,其次使用仓库根目录 .env.local/.env。
const envRoot = {
...loadEnvFile(path.join(rootDir, ".env.local")),
...loadEnvFile(path.join(rootDir, ".env")),
};
requireFile(
frontendProdEnv,
"请先创建 wolai-frontend/.env.production.local(可参考 wolai-frontend/.env.production.example",
);
// 生产模式:前端必须使用 production env,避免把 .env.local 的 127.0.0.1 泄露到公网用户
const envFrontend = {
...process.env,
...loadEnvFile(frontendProdEnv),
NODE_ENV: "production",
NEXT_TELEMETRY_DISABLED: "1",
};
// Next 服务端(API Route)需要 service role 才能生成签名 URL、清理资源等。
if (envRoot.SUPABASE_SERVICE_ROLE_KEY) {
envFrontend.SUPABASE_SERVICE_ROLE_KEY = envRoot.SUPABASE_SERVICE_ROLE_KEY;
}
// 兼容旧代码:部分模块读取 SUPABASE_URL(而非 SUPABASE_INTERNAL_URL)。
// 强制覆盖:避免从系统环境变量或 wolai-frontend/.env.local 继承到“远端 Supabase”。
envFrontend.SUPABASE_URL = envFrontend.SUPABASE_INTERNAL_URL || envRoot.SUPABASE_URL || envFrontend.SUPABASE_URL || "";
// 后端/内部服务:尽量使用本机/内网配置(不会暴露给浏览器)
// wolai-backend 使用自身目录下 .env
const envBackend = {
...process.env,
...loadEnvFile(path.join(backendDir, ".env")),
PYTHONUTF8: "1",
};
// ingest_service / rag_gateway 主要依赖仓库根目录 .env.local/.env
const envInternalServices = {
...process.env,
...loadEnvFile(path.join(rootDir, ".env.local")),
...loadEnvFile(path.join(rootDir, ".env")),
PYTHONUTF8: "1",
};
const shouldSkipBuild =
(process.env.SKIP_FRONTEND_BUILD || "").toLowerCase() === "1" ||
(process.env.SKIP_FRONTEND_BUILD || "").toLowerCase() === "true";
if (!shouldSkipBuild) {
logPrefix("frontend", "开始 production build…");
const build = spawn("pnpm -C wolai-frontend build", {
cwd: rootDir,
stdio: "inherit",
shell: true,
env: envFrontend,
});
const code = await waitForExit(build, "frontend");
if (code !== 0) {
process.exit(code);
}
} else {
logPrefix("frontend", "已跳过 buildSKIP_FRONTEND_BUILD=1");
}
// 预检查:提醒必要端口是否已就绪(不强制退出,避免误伤已有进程)
const portChecks = [
{ name: "frontend", port: frontendPort },
{ name: "backend", port: backendPort },
{ name: "rag_gateway", port: ragGatewayPort },
{ name: "ingest_service", port: ingestPort },
{ name: "supabase(kong)", port: 18000 },
];
for (const item of portChecks) {
// 仅检查本机 127.0.0.1 是否已有服务在监听,方便你快速判断“是否重复启动”
// 监听着不代表冲突:可能是你已经手动启动了。
// eslint-disable-next-line no-await-in-loop
const open = await checkPortOpen("127.0.0.1", item.port);
if (open) {
logPrefix("system", `检测到端口 ${item.port} 已有服务监听(${item.name}),若你准备全量启动,请确认不会端口冲突。`);
}
}
// 启动前端(production
spawnTask({
name: "frontend",
// Windows 下 pnpm 对 `start -- ...` 的参数转发偶发不稳定;用 `pnpm exec next start` 更可靠。
command: `pnpm -C wolai-frontend exec next start -p ${frontendPort} -H 0.0.0.0`,
cwd: rootDir,
env: envFrontend,
});
// 启动 wolai-backendFastAPI
spawnTask({
name: "backend",
command: `${pythonBin} -m uvicorn app.main:app --host 0.0.0.0 --port ${backendPort}`,
cwd: backendDir,
env: envBackend,
});
// Celery(可选)
if (!skipCelery) {
const ok = await checkRedisReachable(envBackend.REDIS_URL || redisUrl);
if (ok) {
spawnTask({
name: "celery",
command: `${celeryBin} -A app.workers.celery_app worker --loglevel=info`,
cwd: backendDir,
env: envBackend,
});
} else {
logPrefix("celery", `检测到 Redis 不可达(${envBackend.REDIS_URL || redisUrl}),跳过 Celery。`);
}
} else {
logPrefix("celery", "已跳过 CelerySKIP_CELERY=1");
}
// 启动 ingest_service(自动入库、OCR 触发、延迟删除清理)
spawnTask({
name: "ingest",
command: `${pythonBin} -m uvicorn app.main:app --host 0.0.0.0 --port ${ingestPort}`,
cwd: ingestDir,
env: envInternalServices,
});
// 启动 rag_gateway/rag 查询网关)
spawnTask({
name: "rag_gateway",
command: `${pythonBin} -m uvicorn app.main:app --host 0.0.0.0 --port ${ragGatewayPort}`,
cwd: ragGatewayDir,
env: envInternalServices,
});
logPrefix(
"system",
`已启动:frontend:${frontendPort} backend:${backendPort} ingest:${ingestPort} rag_gateway:${ragGatewayPort}(如需公网访问,请确保 cloudflared tunnel 正在运行)`,
);
}
main().catch((error) => {
console.error(`[system] 启动失败:${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
});