diff --git a/apps/frontend/components/id/auth-provider.tsx b/apps/frontend/components/id/auth-provider.tsx index 4ce2a4d..3a560ab 100644 --- a/apps/frontend/components/id/auth-provider.tsx +++ b/apps/frontend/components/id/auth-provider.tsx @@ -11,6 +11,7 @@ import { AUTH_USER_CACHE_KEY, AuthSessionResponse, AuthTokens, + ensureApiGatewayReady, fetchAuthSession, getDeviceFingerprint, IdentifyResponse, @@ -159,10 +160,10 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { cacheUserProfile(auth.user); setHasStoredSession(true); clearPinLock(); + setIsLoading(true); + void ensureApiGatewayReady().finally(() => setIsLoading(false)); if (auth.pinVerified !== false) { - window.setTimeout(() => { - void syncFedcmSession(auth.accessToken); - }, 300); + void syncFedcmSession(auth.accessToken); } }, [clearPinLock] @@ -255,6 +256,16 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { if (message) showToast(message); logout(); } finally { + const hasSession = Boolean( + window.localStorage.getItem(AUTH_TOKEN_KEY) || window.localStorage.getItem(AUTH_REFRESH_KEY) + ); + if (hasSession) { + try { + await ensureApiGatewayReady(); + } catch { + // Продолжаем даже если gateway не ответил в срок прогрева. + } + } setIsLoading(false); refreshInFlightRef.current = null; } diff --git a/apps/frontend/components/notifications/notification-bell.tsx b/apps/frontend/components/notifications/notification-bell.tsx index 6cd0b0f..1a72688 100644 --- a/apps/frontend/components/notifications/notification-bell.tsx +++ b/apps/frontend/components/notifications/notification-bell.tsx @@ -27,7 +27,7 @@ function formatTime(value: string) { export function NotificationBell() { const router = useRouter(); - const { token, isPinLocked } = useAuth(); + const { token, isPinLocked, isLoading } = useAuth(); const { showToast } = useToast(); const { unreadCount, setUnreadCount, subscribe } = useRealtime(); const [open, setOpen] = useState(false); @@ -36,7 +36,7 @@ export function NotificationBell() { const [respondingId, setRespondingId] = useState(null); const load = useCallback(async () => { - if (!token || isPinLocked) return; + if (!token || isPinLocked || isLoading) return; setLoading(true); try { const [list, count] = await Promise.all([fetchNotifications(token), fetchUnreadNotificationCount(token)]); @@ -48,7 +48,7 @@ export function NotificationBell() { } finally { setLoading(false); } - }, [isPinLocked, setUnreadCount, showToast, token]); + }, [isLoading, isPinLocked, setUnreadCount, showToast, token]); useEffect(() => { void load(); diff --git a/apps/frontend/components/notifications/realtime-provider.tsx b/apps/frontend/components/notifications/realtime-provider.tsx index b9fa46e..90058aa 100644 --- a/apps/frontend/components/notifications/realtime-provider.tsx +++ b/apps/frontend/components/notifications/realtime-provider.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { useAuth } from '@/components/id/auth-provider'; -import { getWsUrl, type ChatMessage } from '@/lib/api'; +import { ensureApiGatewayReady, getWsUrl, type ChatMessage } from '@/lib/api'; import { dispatchAvatarUpdated, dispatchChatRoomAvatarUpdated } from '@/lib/avatar-events'; export interface RealtimeEvent { @@ -31,7 +31,7 @@ interface RealtimeContextValue { const RealtimeContext = React.createContext(null); export function RealtimeProvider({ children }: { children: React.ReactNode }) { - const { user, token, isPinLocked } = useAuth(); + const { user, token, isPinLocked, isLoading } = useAuth(); const [unreadCount, setUnreadCount] = React.useState(0); const [connected, setConnected] = React.useState(false); const listenersRef = React.useRef(new Set()); @@ -48,7 +48,7 @@ export function RealtimeProvider({ children }: { children: React.ReactNode }) { }, []); React.useEffect(() => { - if (!user || !token || isPinLocked) { + if (!user || !token || isPinLocked || isLoading) { socketRef.current?.close(); socketRef.current = null; setConnected(false); @@ -57,7 +57,13 @@ export function RealtimeProvider({ children }: { children: React.ReactNode }) { let cancelled = false; - function connect() { + async function connect() { + if (cancelled) return; + try { + await ensureApiGatewayReady(); + } catch { + // Пробуем подключиться даже если health не ответил в срок. + } if (cancelled) return; const url = `${getWsUrl()}?token=${encodeURIComponent(token!)}`; const socket = new WebSocket(url); @@ -105,7 +111,7 @@ export function RealtimeProvider({ children }: { children: React.ReactNode }) { }; } - connect(); + void connect(); return () => { cancelled = true; @@ -114,7 +120,7 @@ export function RealtimeProvider({ children }: { children: React.ReactNode }) { socketRef.current = null; setConnected(false); }; - }, [emit, isPinLocked, token, user]); + }, [emit, isLoading, isPinLocked, token, user]); return ( diff --git a/apps/frontend/hooks/use-avatar-url.ts b/apps/frontend/hooks/use-avatar-url.ts index fa43135..c079a4b 100644 --- a/apps/frontend/hooks/use-avatar-url.ts +++ b/apps/frontend/hooks/use-avatar-url.ts @@ -11,11 +11,11 @@ interface AvatarAccessResponse { } export function useAvatarUrl(userId: string | undefined, hasAvatar: boolean | undefined, token: string | null) { - const { isPinLocked } = useAuth(); + const { isPinLocked, isLoading } = useAuth(); const [avatarUrl, setAvatarUrl] = useState(null); const refresh = useCallback(async () => { - if (!userId || !token || !hasAvatar || isPinLocked) { + if (!userId || !token || !hasAvatar || isPinLocked || isLoading) { setAvatarUrl(null); return; } @@ -25,7 +25,7 @@ export function useAvatarUrl(userId: string | undefined, hasAvatar: boolean | un } catch { setAvatarUrl(null); } - }, [hasAvatar, isPinLocked, token, userId]); + }, [hasAvatar, isLoading, isPinLocked, token, userId]); useEffect(() => { void refresh(); diff --git a/apps/frontend/hooks/use-e2e-chat.ts b/apps/frontend/hooks/use-e2e-chat.ts index e93760d..becae88 100644 --- a/apps/frontend/hooks/use-e2e-chat.ts +++ b/apps/frontend/hooks/use-e2e-chat.ts @@ -1,6 +1,7 @@ 'use client'; import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useAuth } from '@/components/id/auth-provider'; import { ChatMessage, ChatRoom, fetchUserE2EPublicKey, setUserE2EPublicKey } from '@/lib/api'; import { ensureE2EKeyPair } from '@/lib/e2e-crypto'; import { @@ -51,6 +52,7 @@ export function useE2EChat(options: { userId?: string; token?: string | null; }) { + const { isLoading } = useAuth(); const isE2E = options.room?.type === 'E2E'; const peerUserId = useMemo( () => resolveChatPeerUserId(options.room, options.userId), @@ -63,7 +65,7 @@ export function useE2EChat(options: { const [e2eErrors, setE2eErrors] = useState>({}); useEffect(() => { - if (!options.userId || !options.token) return; + if (!options.userId || !options.token || isLoading) return; void (async () => { try { const { publicKey } = await ensureE2EKeyPair(options.userId!); @@ -72,7 +74,7 @@ export function useE2EChat(options: { // фоновая инициализация E2E } })(); - }, [options.token, options.userId]); + }, [isLoading, options.token, options.userId]); useEffect(() => { if (!isE2E || !peerUserId || !options.token) { diff --git a/apps/frontend/hooks/use-primary-family.ts b/apps/frontend/hooks/use-primary-family.ts index bd062bb..b4689df 100644 --- a/apps/frontend/hooks/use-primary-family.ts +++ b/apps/frontend/hooks/use-primary-family.ts @@ -12,7 +12,7 @@ import { } from '@/lib/api'; export function useSelectedFamily(enabled = true) { - const { user, token, isPinLocked } = useAuth(); + const { user, token, isPinLocked, isLoading } = useAuth(); const { selectedGroupId, setSelectedGroupId } = useFamilyOverlay(); const [groups, setGroups] = useState([]); const [group, setGroup] = useState(null); @@ -20,7 +20,7 @@ export function useSelectedFamily(enabled = true) { const [loading, setLoading] = useState(true); const refresh = useCallback(async () => { - if (!enabled || !user || !token || isPinLocked) { + if (!enabled || !user || !token || isPinLocked || isLoading) { setGroups([]); setGroup(null); setPresenceMembers([]); @@ -60,7 +60,7 @@ export function useSelectedFamily(enabled = true) { } finally { setLoading(false); } - }, [enabled, isPinLocked, selectedGroupId, setSelectedGroupId, token, user]); + }, [enabled, isLoading, isPinLocked, selectedGroupId, setSelectedGroupId, token, user]); useEffect(() => { setLoading(true); @@ -68,10 +68,10 @@ export function useSelectedFamily(enabled = true) { }, [refresh]); useEffect(() => { - if (!enabled || !token || isPinLocked || !group) return; + if (!enabled || !token || isPinLocked || isLoading || !group) return; const timer = window.setInterval(() => void refresh(), 25_000); return () => window.clearInterval(timer); - }, [enabled, group, isPinLocked, refresh, token]); + }, [enabled, group, isLoading, isPinLocked, refresh, token]); const presenceByUserId = useMemo( () => new Map(presenceMembers.map((member) => [member.userId, member])), diff --git a/apps/frontend/lib/api.ts b/apps/frontend/lib/api.ts index d1af5c8..7d9a709 100644 --- a/apps/frontend/lib/api.ts +++ b/apps/frontend/lib/api.ts @@ -434,8 +434,13 @@ export function isPinRequiredError(error: unknown): error is ApiError { return error instanceof ApiError && error.code === 'PIN_REQUIRED'; } +export function isGatewayUnavailableError(error: unknown): boolean { + return error instanceof ApiError && [502, 503, 504].includes(error.status); +} + export function getApiErrorMessage(error: unknown, fallback: string): string | null { if (isPinRequiredError(error)) return null; + if (isGatewayUnavailableError(error)) return null; if (error instanceof ApiError && error.status === 403 && error.message.toLowerCase().includes('pin')) { return null; } @@ -522,6 +527,7 @@ export async function refreshAuthSession(): Promise { export async function syncFedcmSession(accessToken: string) { if (typeof window === 'undefined' || !accessToken.trim()) return; try { + await ensureApiGatewayReady(); await fetchWithGatewayRetry(`${getApiUrl()}/fedcm/session/sync`, { method: 'POST', headers: { Authorization: `Bearer ${accessToken.trim()}` }, @@ -625,12 +631,58 @@ export function buildSystemSettingPayload(setting: Pick { return new Promise((resolve) => setTimeout(resolve, ms)); } +let gatewayReadyResolved = false; +let gatewayReadyPromise: Promise | null = null; + +export function resetApiGatewayWarmup() { + gatewayReadyResolved = false; + gatewayReadyPromise = null; +} + +export function markApiGatewayReady() { + gatewayReadyResolved = true; +} + +async function probeGatewayHealth(): Promise { + try { + const response = await fetch(`${getApiUrl()}/health`, { + method: 'GET', + cache: 'no-store' + }); + return response.ok; + } catch { + return false; + } +} + +/** Ждёт готовности api-gateway (кратковременные 502 после перезапуска nginx/upstream). */ +export async function ensureApiGatewayReady(force = false): Promise { + if (typeof window === 'undefined') return; + if (gatewayReadyResolved && !force) return; + if (gatewayReadyPromise && !force) return gatewayReadyPromise; + + gatewayReadyPromise = (async () => { + for (let attempt = 0; attempt < GATEWAY_WARMUP_MAX_ATTEMPTS; attempt++) { + if (await probeGatewayHealth()) { + gatewayReadyResolved = true; + return; + } + await sleep(GATEWAY_WARMUP_DELAY_MS); + } + gatewayReadyResolved = true; + })(); + + return gatewayReadyPromise; +} + /** Повтор при кратковременном 502/503/504 (nginx не достучался до api-gateway после перезапуска). */ async function fetchWithGatewayRetry(url: string, init: RequestInit): Promise { let lastNetworkError: unknown; @@ -655,6 +707,10 @@ async function fetchWithGatewayRetry(url: string, init: RequestInit): Promise(path: string, options: RequestInit = {}, token?: string | null, allowRetry = true): Promise { + if (typeof window !== 'undefined' && path !== '/health' && !path.startsWith('/health?')) { + await ensureApiGatewayReady(); + } + const resolvedToken = token ?? (typeof window !== 'undefined' ? window.localStorage.getItem(AUTH_TOKEN_KEY) : null); const method = (options.method ?? 'GET').toUpperCase(); const hasBody = options.body !== undefined && options.body !== null;