Files
Kokorone/scripts/dev-restart.mjs
T
Jeffery ae9c44dde7 feat: 完成 A 群組 — 專案骨架與可重啟的服務
- 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)。
2026-08-13 09:18:40 +08:00

180 lines
4.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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();