- 将 3000 主入口继续收口到 mnote-web,补齐 /favicon.ico、/api/auth、session alias、AI run 等 Rust Web 路由边界。 - 更新登录页与 Convex Auth 代理,支持测试账号快速登录写入真实 Convex Auth cookie。 - 推进页面设置、Wolai 对齐、Phase 7 AI kernel/CLI-first 设计文档与相关 smoke 脚本。 - 更新 leptos-tiptap 生成资产、mnote-cli/bridge-runtime、前端依赖和 dev/prod 启动脚本。
363 lines
12 KiB
JavaScript
363 lines
12 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* 一键启动“生产模式”的前后端与相关服务(适用于 Cloudflare Tunnel / 公网访问)。
|
||
*
|
||
* 目标:
|
||
* - 前后端与内部服务统一读取仓库根目录 `.env.all`(生产优先,全局唯一 env 文件)
|
||
*
|
||
* 可选环境变量:
|
||
* - 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
|
||
* - ENABLE_CELERY=1:启用默认 Celery worker
|
||
* - CELERY_CMD:覆盖 Celery 启动命令;设置后即视为显式启用 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");
|
||
|
||
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 celeryCmdFromEnv = (process.env.CELERY_CMD || "").trim();
|
||
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 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 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);
|
||
}
|
||
}
|
||
|
||
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;
|
||
}
|
||
}
|
||
|
||
async function main() {
|
||
// 约定:全局仅使用仓库根目录的 .env.all 作为配置来源(生产优先)。
|
||
const envAllPath = path.join(rootDir, ".env.all");
|
||
requireFile(envAllPath, "请先在仓库根目录创建 .env.all(全局唯一 env 文件)");
|
||
const envAll = loadEnvFile(envAllPath);
|
||
|
||
// 生产模式:前端必须使用 production env,避免本地开发配置污染公网用户
|
||
const envFrontend = {
|
||
...process.env,
|
||
...envAll,
|
||
NODE_ENV: "production",
|
||
NEXT_TELEMETRY_DISABLED: "1",
|
||
};
|
||
|
||
// 后端/内部服务:尽量使用本机/内网配置(不会暴露给浏览器)
|
||
const envBackend = {
|
||
...process.env,
|
||
...envAll,
|
||
PYTHONUTF8: "1",
|
||
};
|
||
const envInternalServices = {
|
||
...process.env,
|
||
...envAll,
|
||
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", "已跳过 build(SKIP_FRONTEND_BUILD=1)");
|
||
}
|
||
|
||
const useStandalone = ensureStandaloneStatic(frontendDir);
|
||
|
||
// 预检查:提醒必要端口是否已就绪(不强制退出,避免误伤已有进程)
|
||
const portChecks = [
|
||
{ name: "frontend", port: frontendPort },
|
||
{ name: "backend", port: backendPort },
|
||
{ name: "rag_gateway", port: ragGatewayPort },
|
||
{ name: "ingest_service", port: ingestPort },
|
||
];
|
||
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)
|
||
const envFrontendServer = {
|
||
...envFrontend,
|
||
PORT: String(frontendPort),
|
||
HOSTNAME: "0.0.0.0",
|
||
};
|
||
spawnTask({
|
||
name: "frontend",
|
||
// 说明: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,
|
||
});
|
||
|
||
// 启动 wolai-backend(FastAPI)
|
||
spawnTask({
|
||
name: "backend",
|
||
command: `${pythonBin} -m uvicorn app.main:app --host 0.0.0.0 --port ${backendPort}`,
|
||
cwd: backendDir,
|
||
env: envBackend,
|
||
});
|
||
|
||
// Celery(可选)
|
||
if (shouldStartCelery(process.env)) {
|
||
const ok = await checkRedisReachable(envBackend.REDIS_URL || redisUrl);
|
||
if (ok) {
|
||
spawnTask({
|
||
name: "celery",
|
||
command: celeryCmdFromEnv || `${celeryBin} -A app.workers.celery_app worker --loglevel=info`,
|
||
cwd: backendDir,
|
||
env: envBackend,
|
||
});
|
||
} else {
|
||
logPrefix("celery", `检测到 Redis 不可达(${envBackend.REDIS_URL || redisUrl}),跳过 Celery。`);
|
||
}
|
||
} else if (isEnabledEnv(process.env.SKIP_CELERY)) {
|
||
logPrefix("celery", "已跳过 Celery(SKIP_CELERY=1)");
|
||
} else {
|
||
logPrefix("celery", "默认不启动 Celery;当前主线页面不依赖 Redis/Celery。如需启用请设置 ENABLE_CELERY=1 或 CELERY_CMD。");
|
||
}
|
||
|
||
// 启动 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 正在运行)`,
|
||
);
|
||
}
|
||
|
||
if (require.main === module) {
|
||
main().catch((error) => {
|
||
console.error(`[system] 启动失败:${error instanceof Error ? error.message : String(error)}`);
|
||
process.exit(1);
|
||
});
|
||
}
|
||
|
||
module.exports = {
|
||
shouldStartCelery,
|
||
};
|