/** * 本地开发一键启动:console + node + email worker。 * bun run dev # 常规启动(复用既有数据) * bun run dev -- --reset # 清空本地数据后全新安装 * * 流程: * 1. 构建前端 * 2. 启动 Console(:18787,自动迁移 + admin/admin 种子) * 3. 确保存在本地 Node(无则创建)并完成 sim 模式 OAuth 自动安装(幂等) * 4. 启动 Node(:18788,enrollment 换取身份,config.json 0600 落盘) * 5. 启动 Email Worker(:18789,转发到 Node) * 6. Ctrl-C 一起退出 */ const ROOT = new URL("..", import.meta.url).pathname; const CONSOLE_PORT = 18787; const NODE_PORT = 18788; const WORKER_PORT = 18789; const BASE = `http://127.0.0.1:${CONSOLE_PORT}`; const reset = process.argv.includes("--reset"); if (reset) { await Bun.$`rm -rf ${ROOT}console/data ${ROOT}node/config.json`.quiet(); console.log("[dev] 本地数据已重置"); } /** 前端构建(产物已存在时可跳过:SKIP_BUILD=1) */ if (!process.env.SKIP_BUILD) { console.log("[dev] 构建前端..."); await Bun.$`bun run build`.cwd(ROOT); } interface Child { name: string; proc: Bun.Subprocess<"ignore", "inherit", "inherit">; } const children: Child[] = []; /** 启动子进程并登记 */ function spawn(name: string, args: string[], cwd: string, env?: Record) { const proc = Bun.spawn(["bun", ...args], { cwd, env: { ...process.env, ...env }, stdout: "inherit", stderr: "inherit", stdin: "ignore", }); children.push({ name, proc }); return proc; } /** 等待 HTTP 就绪 */ async function waitReady(url: string, label: string, tries = 60): Promise { for (let i = 0; i < tries; i++) { try { const resp = await fetch(url); if (resp.ok) return; } catch { // not ready } await new Promise((r) => setTimeout(r, 500)); } throw new Error(`${label} 未就绪:${url}`); } // 1. Console spawn("console", ["console/src/local/main.ts"], ROOT); await waitReady(`${BASE}/api/health`, "console"); console.log("[dev] console ready"); // 2. 登录拿会话 const loginResp = await fetch(`${BASE}/api/auth/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: "admin", password: "admin" }), }); if (!loginResp.ok) throw new Error("admin 登录失败"); const cookie = (loginResp.headers.get("set-cookie") ?? "").split(";")[0]; const authHeaders = { Cookie: cookie, "Content-Type": "application/json" }; // 3. 确保本地 Node 存在 const nodesResp = await fetch(`${BASE}/api/nodes`, { headers: authHeaders }); const existing = ((await nodesResp.json()) as { items: { id: string; name: string }[] }).items; let nodeId: string; let ingressSecret: string; let enrollmentToken: string | null = null; if (existing.length > 0) { nodeId = existing[0].id; ingressSecret = ((await (await fetch(`${BASE}/api/nodes/${nodeId}/ingress-secret`, { headers: authHeaders })).json()) as { ingress_secret: string }).ingress_secret; // 无论 config.json 是否存在,都发一个新 token:有身份的 Node 会忽略它,无身份的用它 enrollment const rotated = (await ( await fetch(`${BASE}/api/nodes/${nodeId}/rotate`, { method: "POST", headers: authHeaders }) ).json()) as { enrollment_token: string }; enrollmentToken = rotated.enrollment_token; console.log(`[dev] 复用既有节点 ${nodeId}`); } else { const created = (await ( await fetch(`${BASE}/api/nodes`, { method: "POST", headers: authHeaders, body: JSON.stringify({ name: "Local Node", ingress_url: `http://127.0.0.1:${NODE_PORT}/ingress/email` }), }) ).json()) as { node_id: string; enrollment_token: string; ingress_secret: string }; nodeId = created.node_id; ingressSecret = created.ingress_secret; enrollmentToken = created.enrollment_token; console.log(`[dev] 已创建节点 ${nodeId}`); } // 4. sim OAuth 自动安装(幂等;节点已 online 时跳过) const nodeDetail = (await (await fetch(`${BASE}/api/nodes/${nodeId}`, { headers: authHeaders })).json()) as { node: { status: string }; steps: Record; }; if (nodeDetail.node.status === "pending_oauth") { const startResp = await fetch(`${BASE}/api/oauth/cloudflare/start?node_id=${nodeId}`, { headers: authHeaders }); const { state } = (await startResp.json()) as { state: string }; const approve = await fetch(`${BASE}/api/oauth/cloudflare/sim/approve`, { method: "POST", headers: authHeaders, body: JSON.stringify({ state }), }); const approveRes = (await approve.json()) as { ok: boolean; error?: string }; if (!approveRes.ok) throw new Error(`自动安装失败:${approveRes.error}`); console.log("[dev] Cloudflare OAuth(sim)自动安装完成:Email Worker / Routing / Domain / Mailbox 就绪"); } else { console.log(`[dev] 节点已安装(status=${nodeDetail.node.status}),跳过安装`); } // 5. Node(首次用 enrollment token 换身份;之后 config.json 已有身份) const nodeEnv: Record = { WMAILHUB_CONSOLE_URL: BASE, WMAILHUB_INGRESS_SECRET: ingressSecret, WMAILHUB_PORT: String(NODE_PORT), }; if (enrollmentToken) nodeEnv.WMAILHUB_ENROLLMENT_TOKEN = enrollmentToken; spawn("node", ["node/src/main.ts"], ROOT, nodeEnv); await waitReady(`http://127.0.0.1:${NODE_PORT}/__health`, "node"); console.log("[dev] node ready"); // 6. Email Worker spawn("worker", ["worker/src/local.ts"], ROOT, { NODE_INGRESS_URL: `http://127.0.0.1:${NODE_PORT}/ingress/email`, NODE_INGRESS_SECRET: ingressSecret, WMAILHUB_WORKER_PORT: String(WORKER_PORT), }); await waitReady(`http://127.0.0.1:${WORKER_PORT}/__health`, "email worker"); console.log("[dev] email worker ready"); console.log(""); console.log("======== WmailHub 本地环境已就绪 ========"); console.log(` Console : ${BASE} (admin / admin)`); console.log(` Node : http://127.0.0.1:${NODE_PORT}/__health`); console.log(` Email Worker: http://127.0.0.1:${WORKER_PORT}/__health`); console.log(" 发测试邮件 : curl -X POST http://127.0.0.1:18789/__email_test \\"); console.log(" -H 'X-WmailHub-Ingress-Secret: ' \\"); console.log(" -H 'X-WmailHub-Test-To: hello@example.test' --data-binary @mail.eml"); console.log("=========================================="); /** 退出时统一收割子进程 */ function shutdown() { for (const { name, proc } of children) { proc.kill(); console.log(`[dev] ${name} stopped`); } process.exit(0); } process.on("SIGINT", shutdown); process.on("SIGTERM", shutdown); // 任一子进程退出则整体退出 for (const { name, proc } of children) { void proc.exited.then((code) => { console.error(`[dev] ${name} exited with ${code}`); shutdown(); }); }