'use client'; import { useEffect } from 'react'; import { useSystemStore, FAILURE_THRESHOLD, type OfflineReason } from '@/stores/system-store'; import { getApiBaseUrl, getWsBaseUrl, getRelayToken } from '@/lib/constants'; import { getRelayHeaders, ensureRelayToken } from '@/lib/api'; /** * use-shutdown-watcher * * 對應 TDD v2 §2.6(M8-7)+ §2.6.2a(Minor 4 WebSocket 廣播)+ §12(M8-9 Boot-ID reload)。 * * 偵測機制(三個獨立管道,任一觸發即標記 forcedOffline): * 1. WebSocket `server:shutdown-imminent` 廣播 → 立即標記,依 reason 決定後續行為 * 2. WebSocket onclose(非 clean close)→ 立即標記為 healthcheck-failed * 3. 被動 polling fallback:health check 連續 2 次失敗 → 標記為 healthcheck-failed * * 模式: * - normal:10 s polling,server 正常時運轉 * - active retry:3 s polling,overlay 顯示後自動重試直到 server 回來 * * Page Visibility:背景 tab 暫停 polling,回前景立即 probe 一次。 * * 容錯:server 端 `/ws/system` endpoint 可能尚未實作(M8-4b 才會做), * 因此 WebSocket 連線失敗時不應觸發 forcedOffline,僅靠 polling fallback。 * * Boot-ID reload(M8-9): * - 每次 /system/boot-id 成功回來後,將 response.bootId 丟給 store.checkAndUpdateBootId: * • first → 首次載入,記下 bootId,markOnline * • match → bootId 一致,markOnline * • mismatch → bootId 變了,代表 server 已重啟 → force `window.location.reload()` * - reload 前用 sessionStorage 記下「已針對此 bootId 觸發過 reload」flag,避免 * 異常情況(例如每次 poll bootId 都不同的 bug)造成 reload loop。 * 正常情況下 reload 後 store reset bootId=null,會走 first 路徑,不會誤觸發。 */ /** sessionStorage key:記錄上次針對哪個 bootId 觸發過 reload,防 reload loop。 */ const RELOAD_LOOP_GUARD_KEY = 'visiona-reload-loop-guard'; const POLL_INTERVAL_NORMAL_MS = 10_000; const POLL_INTERVAL_ACTIVE_RETRY_MS = 3_000; const FETCH_TIMEOUT_MS = 3_000; const WS_RECONNECT_DELAY_MS = 5_000; const RESTART_DEFER_MS = 10_000; interface BootIdResponse { success: boolean; data?: { bootId: string; startedAt: number; }; } interface ShutdownImminentMessage { type: 'server:shutdown-imminent'; reason: 'app-closing' | 'manual-stop' | 'restart' | 'quit'; ts?: number; } /** 把 server WebSocket reason 對應到本地 store 的 OfflineReason。 */ function mapReason(serverReason: ShutdownImminentMessage['reason']): OfflineReason { switch (serverReason) { case 'quit': case 'app-closing': return 'quit'; case 'manual-stop': return 'quit'; case 'restart': return 'restart'; default: return 'unknown'; } } /** * 處理 boot-id 比對 + force reload(M8-9)。 * * 回傳值: * - `true` 代表呼叫端應繼續後續流程(first / match) * - `false` 代表已觸發(或即將觸發)reload,呼叫端不需要再做任何事 * * reload loop guard:用 sessionStorage 記下「上次針對哪個 bootId 觸發過 reload」。 * - 正常情況:reload 後 store.bootId=null → 走 first → 不會再 reload。 * - 異常情況(例如 server 每次回不同 bootId):sessionStorage 裡還是相同值 * → 跳過 reload 只 markOnline,避免 reload loop 癱瘓使用者介面。 */ function handleBootIdCheck(newBootId: string): boolean { const store = useSystemStore.getState(); const result = store.checkAndUpdateBootId(newBootId); if (result === 'first' || result === 'match') { return true; } // mismatch:server 重啟,force reload。 if (typeof window === 'undefined') { // SSR / 測試環境保險:不做 reload,只標示不再繼續處理。 return false; } try { const lastGuard = window.sessionStorage.getItem(RELOAD_LOOP_GUARD_KEY); if (lastGuard === newBootId) { // 同一個 bootId 已經觸發過 reload,避免 loop:改為接受這個 bootId + markOnline。 console.warn( '[boot-id] skip reload: already reloaded for this bootId this session', newBootId, ); store.setBootId(newBootId); store.markOnline(); return false; } window.sessionStorage.setItem(RELOAD_LOOP_GUARD_KEY, newBootId); } catch { // sessionStorage 可能在隱私模式等情境下失敗;失敗時仍然執行 reload。 } console.info('[boot-id] mismatch detected, force reloading tab', { oldBootId: store.bootId, newBootId, }); window.location.reload(); return false; } /** 打一次 boot-id;成功 → 比對 bootId(可能觸發 reload)+ markOnline;失敗 → recordFailure。 */ async function pollOnce(): Promise { const store = useSystemStore.getState(); try { await ensureRelayToken(); const res = await fetch(`${getApiBaseUrl()}/system/boot-id`, { cache: 'no-store', headers: getRelayHeaders(), signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), }); if (!res.ok) { store.recordFailure(); return; } const json = (await res.json()) as BootIdResponse; if (!json.success || !json.data?.bootId) { store.recordFailure(); return; } // 成功:先比對 bootId(M8-9),若 mismatch → reload,直接結束; // first / match → markOnline。 const shouldContinue = handleBootIdCheck(json.data.bootId); if (!shouldContinue) return; store.markOnline(); } catch { store.recordFailure(); } } export function useShutdownWatcher(): void { useEffect(() => { if (typeof window === 'undefined') return; let intervalHandle: ReturnType | null = null; let currentMode: 'normal' | 'active-retry' = 'normal'; let ws: WebSocket | null = null; let wsReconnectTimer: ReturnType | null = null; let wsEverConnected = false; // 只有曾經連上才把 onclose 視為「server 斷了」 let cancelled = false; let restartDeferTimer: ReturnType | null = null; const intervalFor = (mode: 'normal' | 'active-retry') => mode === 'active-retry' ? POLL_INTERVAL_ACTIVE_RETRY_MS : POLL_INTERVAL_NORMAL_MS; const startPolling = (mode: 'normal' | 'active-retry') => { if (intervalHandle !== null && currentMode === mode) return; if (intervalHandle !== null) clearInterval(intervalHandle); currentMode = mode; intervalHandle = setInterval(() => { void pollOnce(); }, intervalFor(mode)); }; const stopPolling = () => { if (intervalHandle !== null) { clearInterval(intervalHandle); intervalHandle = null; } }; // 訂閱 store:當 forcedOffline / consecutiveFailures 改變時,自動切 polling 模式 const unsubscribe = useSystemStore.subscribe((state) => { const shouldActiveRetry = state.forcedOffline || state.consecutiveFailures >= FAILURE_THRESHOLD; const targetMode: 'normal' | 'active-retry' = shouldActiveRetry ? 'active-retry' : 'normal'; // 連續失敗達到門檻但尚未 forcedOffline → 由 hook 補上 forceOffline if ( !state.forcedOffline && state.consecutiveFailures >= FAILURE_THRESHOLD && state.serverOnline !== false ) { useSystemStore.getState().forceOffline('healthcheck-failed'); } if (intervalHandle !== null && targetMode !== currentMode) { startPolling(targetMode); } }); // ─── WebSocket 訂閱 ─────────────────────────────────────────── // server 端 `/ws/system` 可能尚未實作(M8-4b),此處需容錯: // - 連不上 → onerror / onclose(wsEverConnected=false)→ 不觸發 forcedOffline // - 連上後再斷 → 視為 server 真的掛了 → 觸發 forcedOffline const connectWs = () => { if (cancelled) return; try { const token = getRelayToken(); let wsUrl = `${getWsBaseUrl()}/ws/system`; if (token) { wsUrl += `?token=${encodeURIComponent(token)}`; } ws = new WebSocket(wsUrl); } catch { scheduleWsReconnect(); return; } ws.onopen = () => { wsEverConnected = true; }; ws.onmessage = (ev) => { try { const msg = JSON.parse(String(ev.data)) as Partial; if (msg?.type !== 'server:shutdown-imminent') return; const serverReason = (msg as ShutdownImminentMessage).reason; const reason = mapReason(serverReason); if (reason === 'restart') { // restart:延遲 10 s 才顯示 overlay,期間若 polling 成功(bootId 變)→ M8-9 reload if (restartDeferTimer !== null) clearTimeout(restartDeferTimer); restartDeferTimer = setTimeout(() => { const s = useSystemStore.getState(); if (!s.serverOnline || s.consecutiveFailures >= FAILURE_THRESHOLD) { s.forceOffline('restart'); } }, RESTART_DEFER_MS); return; } // quit / unknown:立即顯示 useSystemStore.getState().forceOffline(reason); } catch { // 非 JSON / 格式錯誤 → 忽略 } }; ws.onclose = (ev) => { const wasEver = wsEverConnected; ws = null; // 只有「曾經連上後又斷」才視為 server 真的掛了; // 若從未連上(server 端尚未實作 /ws/system),不觸發 forcedOffline。 if (wasEver && !ev.wasClean) { // 立即觸發一次 polling 確認,若失敗才會被 polling 機制處理 void pollOnce(); } scheduleWsReconnect(); }; ws.onerror = () => { // 忽略 — onclose 會接續處理 }; }; const scheduleWsReconnect = () => { if (cancelled || wsReconnectTimer !== null) return; wsReconnectTimer = setTimeout(() => { wsReconnectTimer = null; if (!cancelled && document.visibilityState === 'visible') { connectWs(); } }, WS_RECONNECT_DELAY_MS); }; // ─── Page Visibility ─────────────────────────────────────────── const onVisibilityChange = () => { if (document.visibilityState === 'visible') { void pollOnce(); const s = useSystemStore.getState(); const mode: 'normal' | 'active-retry' = s.forcedOffline || s.consecutiveFailures >= FAILURE_THRESHOLD ? 'active-retry' : 'normal'; startPolling(mode); if (ws === null && wsReconnectTimer === null) { connectWs(); } } else { stopPolling(); } }; // ─── 初始啟動 ─────────────────────────────────────────────── void pollOnce(); if (document.visibilityState === 'visible') { startPolling('normal'); connectWs(); } document.addEventListener('visibilitychange', onVisibilityChange); // ─── 清理 ─────────────────────────────────────────────── return () => { cancelled = true; document.removeEventListener('visibilitychange', onVisibilityChange); unsubscribe(); stopPolling(); if (wsReconnectTimer !== null) { clearTimeout(wsReconnectTimer); wsReconnectTimer = null; } if (restartDeferTimer !== null) { clearTimeout(restartDeferTimer); restartDeferTimer = null; } if (ws) { ws.onclose = null; ws.onerror = null; ws.onmessage = null; ws.close(); ws = null; } }; }, []); } /** 匯出供 overlay 元件呼叫,手動 retry 按鈕用。 */ export async function retryServerHealth(): Promise { const store = useSystemStore.getState(); try { await ensureRelayToken(); const res = await fetch(`${getApiBaseUrl()}/system/boot-id`, { cache: 'no-store', headers: getRelayHeaders(), signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), }); if (!res.ok) { store.recordFailure(); return false; } const json = (await res.json()) as BootIdResponse; if (!json.success || !json.data?.bootId) { store.recordFailure(); return false; } // M8-9:手動 retry 也要比對 bootId,mismatch → reload。 const shouldContinue = handleBootIdCheck(json.data.bootId); if (!shouldContinue) { // 已觸發 reload(或在 SSR/guard 情境下被 skip):回傳 true 讓 overlay 視為成功。 return true; } store.markOnline(); return true; } catch { store.recordFailure(); return false; } }