Files
mnote/scripts/desktop-prod.js
T

357 lines
12 KiB
JavaScript
Raw Normal View History

2026-01-11 12:35:53 +08:00
#!/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);
}
}
2026-01-15 20:54:21 +08:00
function ensureStandaloneStatic(frontendDir) {
// 说明:当 next.config.ts 配置了 output: "standalone" 时,推荐使用
// `node .next/standalone/server.js` 启动。
// 但 Next 不会自动把 `.next/static` 放入 `.next/standalone/.next/static`
// 若缺失会导致 `/_next/static/*` 404(远程访问尤其明显)。
const standaloneServer = path.join(frontendDir, ".next", "standalone", "server.js");
if (!fs.existsSync(standaloneServer)) return false;
const src = path.join(frontendDir, ".next", "static");
const dest = path.join(frontendDir, ".next", "standalone", ".next", "static");
if (!fs.existsSync(src)) return true;
try {
fs.mkdirSync(path.dirname(dest), { recursive: true });
// Node 16+fs.cpSync
fs.cpSync(src, dest, { recursive: true });
return true;
} catch (err) {
logPrefix(
"frontend",
`复制 .next/static 到 standalone 失败:${err instanceof Error ? err.message : String(err)}`,
);
// 失败时不强制退出,仍尝试启动(方便你自行排查)
return true;
}
}
2026-01-11 12:35:53 +08:00
async function main() {
2026-01-15 20:54:21 +08:00
const frontendProdEnv = path.join(frontendDir, ".env.production.local");
2026-01-11 12:35:53 +08:00
// 说明: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",
};
2026-01-15 20:54:21 +08:00
// Next 的 API Route 也会用到这些“内部服务”环境变量(Search / MinerU / LightRAG 等)。
// 规则:从仓库根目录 .env.local/.env 读取并注入,避免 production server 缺少配置。
const passthroughKeys = [
"MINERU_ENDPOINT",
"LIGHTRAG_URL",
"LIGHTRAG_API_KEY",
"SEARXNG_BASE_URL",
"SEARXNG_API_TOKEN",
];
for (const key of passthroughKeys) {
if (envRoot[key] && !envFrontend[key]) {
envFrontend[key] = envRoot[key];
}
}
2026-01-11 12:35:53 +08:00
// 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");
}
2026-01-15 20:54:21 +08:00
const useStandalone = ensureStandaloneStatic(frontendDir);
2026-01-11 12:35:53 +08:00
// 预检查:提醒必要端口是否已就绪(不强制退出,避免误伤已有进程)
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
2026-01-15 20:54:21 +08:00
const envFrontendServer = {
...envFrontend,
PORT: String(frontendPort),
HOSTNAME: "0.0.0.0",
};
2026-01-11 12:35:53 +08:00
spawnTask({
name: "frontend",
2026-01-15 20:54:21 +08:00
// 说明:next.config.ts 配置了 output: "standalone" 时,应使用 standalone server 启动,避免 next start 行为不一致。
command: useStandalone
? "node .next/standalone/server.js"
: `pnpm -C wolai-frontend exec next start -p ${frontendPort} -H 0.0.0.0`,
cwd: useStandalone ? frontendDir : rootDir,
env: envFrontendServer,
2026-01-11 12:35:53 +08:00
});
// 启动 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);
});