From 2ab097ec8ae0df5e943582b67a532310f012ba93 Mon Sep 17 00:00:00 2001 From: josh Date: Mon, 27 Apr 2026 19:55:50 -0400 Subject: [PATCH] Add backend health check, fetch timeouts, stale token cleanup, and error screen Frontend now checks /health before starting auth flow. Shows a clear "Cannot Connect to Server" screen with retry button when backend is unreachable. Stale non-JWT tokens in localStorage are detected and cleared automatically. All API calls have a 10s timeout via AbortController. Co-Authored-By: Claude Opus 4.6 --- apps/web/src/App.tsx | 37 ++++++++++++++-- apps/web/src/hooks/useAuthGate.ts | 71 ++++++++++++++++++------------ apps/web/src/hooks/useCloudSave.ts | 9 ++-- apps/web/src/lib/api.ts | 43 +++++++++++++----- 4 files changed, 117 insertions(+), 43 deletions(-) diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index fc21f0b..f7978ee 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -7,7 +7,7 @@ import { InviteGateScreen } from '@/components/game/InviteGateScreen'; import { useGameLoop } from '@/hooks/useGameLoop'; import { useAuthGate } from '@/hooks/useAuthGate'; import { TICK_INTERVAL_MS } from '@ai-tycoon/shared'; -import { Sparkles } from 'lucide-react'; +import { Sparkles, RefreshCw, WifiOff } from 'lucide-react'; function LoadingScreen() { return ( @@ -25,8 +25,35 @@ function LoadingScreen() { ); } +function BackendErrorScreen({ error, onRetry }: { error: string; onRetry: () => void }) { + return ( +
+
+
+ +

+ AI Tycoon +

+
+ +
+ +

Cannot Connect to Server

+

{error}

+ +
+
+
+ ); +} + export function App() { - const { loading: authLoading, needsInvite, needsPasswordReset, setRegistered, setNeedsPasswordReset } = useAuthGate(); + const { loading: authLoading, backendError, needsInvite, needsPasswordReset, setRegistered, setNeedsPasswordReset, retry } = useAuthGate(); const companyName = useGameStore((s) => s.meta.companyName); const lastTickTimestamp = useGameStore((s) => s.meta.lastTickTimestamp); const [catchUpTicks, setCatchUpTicks] = useState(null); @@ -43,12 +70,16 @@ export function App() { } }, [companyName, lastTickTimestamp, catchUpDone]); - useGameLoop(!catchUpDone || authLoading || needsInvite || needsPasswordReset); + useGameLoop(!catchUpDone || authLoading || !!backendError || needsInvite || needsPasswordReset); if (authLoading) { return ; } + if (backendError) { + return ; + } + if (needsInvite || needsPasswordReset) { return ( void; setNeedsPasswordReset: (value: boolean) => void; + retry: () => void; } export function useAuthGate(): AuthGateState { const [config, setConfig] = useState<{ requireInvite: boolean; userInvitations: number } | null>(null); const [loading, setLoading] = useState(true); + const [backendError, setBackendError] = useState(null); const [registered, setRegistered] = useState(false); const [passwordReset, setPasswordReset] = useState(false); const [admin, setAdmin] = useState(false); + const [initCount, setInitCount] = useState(0); - useEffect(() => { - let cancelled = false; + const init = useCallback(async () => { + setLoading(true); + setBackendError(null); - async function init() { - try { - const [cfg] = await Promise.all([ - api.config.get(), - ensureAuth(), - ]); + validateStoredToken(); - if (cancelled) return; - - setConfig(cfg); - - const payload = getTokenPayload(); - const reg = checkRegistered(); - setRegistered(reg); - setPasswordReset(checkNeedsReset()); - setAdmin(payload?.role === 'admin'); - } catch { - // Config fetch failed — allow game to load (fail open) - setConfig({ requireInvite: false, userInvitations: 0 }); - } finally { - if (!cancelled) setLoading(false); - } + try { + await api.health(); + } catch (e) { + setBackendError(e instanceof Error ? e.message : 'Cannot connect to server'); + setLoading(false); + return; } - init(); - return () => { cancelled = true; }; + try { + const cfg = await api.config.get(); + setConfig(cfg); + } catch { + setConfig({ requireInvite: false, userInvitations: 0 }); + } + + try { + await ensureAuth(); + } catch { + // auth failed — will show as unregistered + } + + const payload = getTokenPayload(); + setRegistered(checkRegistered()); + setPasswordReset(checkNeedsReset()); + setAdmin(payload?.role === 'admin'); + setLoading(false); }, []); + // Run init on mount and on retry + useState(() => { init(); }); + + const retry = useCallback(() => { + setInitCount(c => c + 1); + init(); + }, [init]); + const handleSetRegistered = useCallback((value: boolean) => { setRegistered(value); const payload = getTokenPayload(); @@ -68,6 +83,7 @@ export function useAuthGate(): AuthGateState { return { loading, + backendError, needsInvite, needsPasswordReset: passwordReset, registered, @@ -75,5 +91,6 @@ export function useAuthGate(): AuthGateState { config, setRegistered: handleSetRegistered, setNeedsPasswordReset: handleSetPasswordReset, + retry, }; } diff --git a/apps/web/src/hooks/useCloudSave.ts b/apps/web/src/hooks/useCloudSave.ts index efebe08..bf0a38b 100644 --- a/apps/web/src/hooks/useCloudSave.ts +++ b/apps/web/src/hooks/useCloudSave.ts @@ -1,6 +1,6 @@ import { useEffect, useRef } from 'react'; import { useGameStore } from '@/store'; -import { api, getAuthToken, setAuthToken } from '@/lib/api'; +import { api, getAuthToken, setAuthToken, clearAuthToken, decodeTokenPayload } from '@/lib/api'; import { AUTO_SAVE_INTERVAL_TICKS } from '@ai-tycoon/shared'; export function useCloudSave() { @@ -31,8 +31,11 @@ export function useCloudSave() { } export async function ensureAuth(): Promise { - let token = getAuthToken(); - if (token) return token; + const token = getAuthToken(); + if (token) { + if (decodeTokenPayload(token)) return token; + clearAuthToken(); + } try { const result = await api.auth.anonymous(); diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 926352c..d1c7bdb 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -63,30 +63,53 @@ export function needsPasswordReset(): boolean { return payload?.mustResetPassword === true; } -async function request(path: string, options: RequestInit = {}): Promise { +async function request(path: string, options: RequestInit & { timeoutMs?: number } = {}): Promise { + const { timeoutMs = 10_000, ...fetchOptions } = options; + const headers: Record = { 'Content-Type': 'application/json', - ...(options.headers as Record), + ...(fetchOptions.headers as Record), }; if (authToken) { headers['Authorization'] = `Bearer ${authToken}`; } - const res = await fetch(`${API_BASE}${path}`, { - ...options, - headers, - }); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); - if (!res.ok) { - const body = await res.json().catch(() => ({ error: 'Unknown error' })); - throw new Error(body.error || `HTTP ${res.status}`); + try { + const res = await fetch(`${API_BASE}${path}`, { + ...fetchOptions, + headers, + signal: controller.signal, + }); + + if (!res.ok) { + const body = await res.json().catch(() => ({ error: 'Unknown error' })); + throw new Error(body.error || `HTTP ${res.status}`); + } + + return res.json(); + } catch (e) { + if (e instanceof DOMException && e.name === 'AbortError') { + throw new Error('Request timed out — server may be unreachable'); + } + throw e; + } finally { + clearTimeout(timeout); } +} - return res.json(); +export function validateStoredToken(): void { + const token = getAuthToken(); + if (token && !decodeTokenPayload(token)) { + clearAuthToken(); + } } export const api = { + health: () => request<{ status: string }>('/health', { timeoutMs: 5_000 }), auth: { anonymous: () => request<{ userId: string; token: string }>('/api/auth/anonymous', { method: 'POST' }), login: (login: string, password: string) =>