- packages/shared:TypeScript 專案參考建置鏈路、統一日誌模組(Asia/Taipei 格式) - apps/api:最小 NestJS 服務,GET /health - apps/web:最小 Next.js(App Router + Tailwind v4)首頁,套用品牌色票與亮暗模式 - scripts/dev-restart.mjs:讀 .dev-pids 以 process group 訊號安全停止舊行程、背景啟動 api/web 並輪詢健康檢查 - scripts/smoke:npm run smoke -- <群組代號> 冒煙測試框架,A 群組測試涵蓋 /health 與首頁內容 - 本機 port 3000 被既有服務佔用,web 開發埠改為 3100(已記錄於 todo.md) npm run restart && npm run smoke -- A 皆通過(A-V)。
180 lines
4.9 KiB
JavaScript
180 lines
4.9 KiB
JavaScript
#!/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();
|