#!/usr/bin/env node import { spawn, spawnSync } from "node:child_process"; import { readFile, writeFile, rm, mkdir } from "node:fs/promises"; import { existsSync, openSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { log } from "../packages/shared/dist/index.js"; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const PID_FILE = path.join(ROOT, ".dev-pids"); const LOG_DIR = path.join(ROOT, ".dev-logs"); const API_PORT = process.env.PORT_API ?? "3001"; const WEB_PORT = process.env.PORT_WEB ?? "3100"; const HEALTH_TIMEOUT_MS = 60_000; const POLL_INTERVAL_MS = 1_000; function isAlive(pid) { try { process.kill(pid, 0); return true; } catch { return false; } } // 啟動時使用 detached: true,子行程會成為新 process group 的 leader(pgid === pid)。 // next dev 之類的指令會再往下多層 fork(npx → sh -c → next-server),只送 SIGTERM 給 // 最外層 pid 可能殺不到孫行程、留下孤兒繼續佔用埠號,因此一律用 -pid 對整個 process group 送訊號。 function isGroupAlive(pid) { try { process.kill(-pid, 0); return true; } catch { return false; } } async function stopExisting() { if (!existsSync(PID_FILE)) { return; } const raw = await readFile(PID_FILE, "utf8"); let pids; try { pids = JSON.parse(raw); } catch { log("重啟", "WRN", ".dev-pids 內容無法解析,略過停止舊行程"); await rm(PID_FILE, { force: true }); return; } for (const [name, pid] of Object.entries(pids)) { if (typeof pid !== "number" || !isGroupAlive(pid)) { continue; } log("重啟", "INF", `停止舊行程 ${name}(pid=${pid})`); try { process.kill(-pid, "SIGTERM"); } catch { // 行程可能已結束 } } const deadline = Date.now() + 5_000; for (const pid of Object.values(pids)) { while (typeof pid === "number" && isGroupAlive(pid) && Date.now() < deadline) { await new Promise((r) => setTimeout(r, 200)); } if (typeof pid === "number" && isGroupAlive(pid)) { log("重啟", "WRN", `舊行程 pid=${pid} 未在時限內結束,強制終止`); try { process.kill(-pid, "SIGKILL"); } catch { // 行程可能已結束 } } } await rm(PID_FILE, { force: true }); } function buildTypeScript() { log("重啟", "INF", "建置 TypeScript 專案(tsc -b)"); const result = spawnSync("npx", ["tsc", "-b"], { cwd: ROOT, stdio: "inherit" }); if (result.status !== 0) { log("重啟", "ERR", "tsc -b 建置失敗"); process.exit(1); } } async function openLogFd(name) { await mkdir(LOG_DIR, { recursive: true }); return openSync(path.join(LOG_DIR, `${name}.log`), "a"); } async function startApi() { const fd = await openLogFd("api"); const child = spawn("node", ["apps/api/dist/main.js"], { cwd: ROOT, env: { ...process.env, PORT: API_PORT }, detached: true, stdio: ["ignore", fd, fd], }); child.unref(); log("重啟", "INF", `啟動 api(pid=${child.pid},port=${API_PORT})`); return child.pid; } async function startWeb() { const fd = await openLogFd("web"); const child = spawn("npx", ["next", "dev", "-p", WEB_PORT], { cwd: path.join(ROOT, "apps/web"), env: { ...process.env, PORT: WEB_PORT }, detached: true, stdio: ["ignore", fd, fd], }); child.unref(); log("重啟", "INF", `啟動 web(pid=${child.pid},port=${WEB_PORT})`); return child.pid; } async function pollUntilOk(name, pid, check, deadline) { while (Date.now() < deadline) { if (!isAlive(pid)) { log("重啟", "ERR", `${name} 對應的行程(pid=${pid})已提前結束,請查看 .dev-logs`); return false; } try { if (await check()) { log("重啟", "INF", `${name} 健康檢查通過`); return true; } } catch { // 服務尚未就緒,繼續輪詢 } await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS)); } log("重啟", "ERR", `${name} 健康檢查逾時(${HEALTH_TIMEOUT_MS}ms)`); return false; } async function main() { await stopExisting(); buildTypeScript(); const apiPid = await startApi(); const webPid = await startWeb(); await writeFile(PID_FILE, JSON.stringify({ api: apiPid, web: webPid }, null, 2)); const deadline = Date.now() + HEALTH_TIMEOUT_MS; const apiOk = await pollUntilOk( "api /health", apiPid, async () => { const res = await fetch(`http://localhost:${API_PORT}/health`); if (!res.ok) return false; const body = await res.json(); return body.status === "ok"; }, deadline, ); const webOk = await pollUntilOk( "web 首頁", webPid, async () => { const res = await fetch(`http://localhost:${WEB_PORT}/`); return res.ok; }, deadline, ); if (!apiOk || !webOk) { process.exit(1); } log("重啟", "INF", "api 與 web 皆已就緒"); } main();