From 4e98f6bfab934d28c8dd03608e824456d71c45cf Mon Sep 17 00:00:00 2001 From: lendry Date: Tue, 30 Jun 2026 09:39:35 +0300 Subject: [PATCH] fix and update --- apps/frontend/components/id/auth-provider.tsx | 137 +++++++++++------- .../notifications/notification-bell.tsx | 6 +- .../notifications/realtime-provider.tsx | 6 +- apps/frontend/hooks/use-avatar-url.ts | 8 +- apps/frontend/hooks/use-e2e-chat.ts | 6 +- apps/frontend/hooks/use-primary-family.ts | 10 +- apps/frontend/hooks/use-require-auth.ts | 9 +- apps/frontend/lib/api.ts | 120 ++++++++++----- 8 files changed, 192 insertions(+), 110 deletions(-) diff --git a/apps/frontend/components/id/auth-provider.tsx b/apps/frontend/components/id/auth-provider.tsx index f735f33..954c06f 100644 --- a/apps/frontend/components/id/auth-provider.tsx +++ b/apps/frontend/components/id/auth-provider.tsx @@ -16,6 +16,9 @@ import { buildAuthDevicePayload, IdentifyResponse, getApiErrorMessage, + invalidateApiGatewayReady, + isApiGatewayReady, + isGatewayUnavailableError, isPinRequiredError, OtpSendResponse, PasswordlessAuthResponse, @@ -25,6 +28,7 @@ import { resetPinRequiredNotification, scheduleFedcmSessionSync, setPinRequiredHandler, + subscribeApiReady, } from '@/lib/api'; import { useToast } from './toast-provider'; import { PinLockModal } from './pin-lock-modal'; @@ -35,6 +39,7 @@ interface AuthContextValue { user: PublicUser | null; token: string | null; isLoading: boolean; + isApiReady: boolean; isPinLocked: boolean; hasStoredSession: boolean; login: (login: string, password: string) => Promise; @@ -103,6 +108,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const [token, setToken] = React.useState(null); const [user, setUser] = React.useState(null); const [isLoading, setIsLoading] = React.useState(true); + const [isApiReady, setIsApiReady] = React.useState(false); const [isPinLocked, setIsPinLocked] = React.useState(false); const [hasStoredSession, setHasStoredSession] = React.useState(false); const [lockedSessionId, setLockedSessionId] = React.useState(null); @@ -112,6 +118,15 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const refreshInFlightRef = React.useRef | null>(null); const initialBootstrapDoneRef = React.useRef(false); + React.useEffect(() => { + if (isApiGatewayReady()) { + setIsApiReady(true); + } + return subscribeApiReady(() => { + setIsApiReady(true); + }); + }, []); + React.useEffect(() => { isPinLockedRef.current = isPinLocked; }, [isPinLocked]); @@ -160,6 +175,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { setHasStoredSession(true); clearPinLock(); initialBootstrapDoneRef.current = true; + setIsApiReady(true); setIsLoading(false); if (auth.pinVerified !== false) { scheduleFedcmSessionSync(auth.accessToken, 2000); @@ -186,7 +202,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { return refreshInFlightRef.current; } - const task = (async () => { + const task: Promise = (async () => { const currentToken = window.localStorage.getItem(AUTH_TOKEN_KEY); const refreshToken = window.localStorage.getItem(AUTH_REFRESH_KEY); setHasStoredSession(Boolean(refreshToken)); @@ -200,78 +216,94 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const isInitialBootstrap = !initialBootstrapDoneRef.current; if (isInitialBootstrap) { setIsLoading(true); - await ensureApiGatewayReady(); } try { - if (currentToken) { - setToken(currentToken); + for (let bootstrapAttempt = 0; bootstrapAttempt < 6; bootstrapAttempt += 1) { try { - const session = await fetchAuthSession(currentToken); - applySessionState(session, { setUser, setToken, activatePinLock, clearPinLock }); - return; + if (isInitialBootstrap) { + await ensureApiGatewayReady(); + } + + if (currentToken) { + setToken(currentToken); + try { + const session = await fetchAuthSession(currentToken); + applySessionState(session, { setUser, setToken, activatePinLock, clearPinLock }); + return; + } catch (error) { + if (isPinRequiredError(error)) { + activatePinLock(error.sessionId ?? window.localStorage.getItem(AUTH_SESSION_KEY) ?? ''); + return; + } + if (!(error instanceof ApiError) || error.code !== 'TOKEN_EXPIRED') { + throw error; + } + } + } + + if (refreshToken) { + const refreshed = await refreshAuthSession(); + if (refreshed.accessToken) { + window.localStorage.setItem(AUTH_TOKEN_KEY, refreshed.accessToken); + setToken(refreshed.accessToken); + } + if (refreshed.sessionId) { + window.localStorage.setItem(AUTH_SESSION_KEY, refreshed.sessionId); + } + if (refreshed.requiresPin) { + if (refreshed.user) { + setUser(refreshed.user); + cacheUserProfile(refreshed.user); + } + activatePinLock(refreshed.sessionId ?? ''); + return; + } + if (refreshed.accessToken) { + const session = await fetchAuthSession(refreshed.accessToken); + applySessionState(session, { setUser, setToken, activatePinLock, clearPinLock }); + return; + } + } + + throw new ApiError('Сессия недействительна', 401); } catch (error) { + if (isGatewayUnavailableError(error) && isInitialBootstrap && bootstrapAttempt + 1 < 6) { + invalidateApiGatewayReady(); + await new Promise((resolve) => setTimeout(resolve, 1200)); + continue; + } if (isPinRequiredError(error)) { activatePinLock(error.sessionId ?? window.localStorage.getItem(AUTH_SESSION_KEY) ?? ''); return; } - if (!(error instanceof ApiError) || error.code !== 'TOKEN_EXPIRED') { - throw error; + if (refreshToken && error instanceof ApiError && error.status === 403) { + activatePinLock(window.localStorage.getItem(AUTH_SESSION_KEY) ?? ''); + return; } - } - } - - if (refreshToken) { - const refreshed = await refreshAuthSession(); - if (refreshed.accessToken) { - window.localStorage.setItem(AUTH_TOKEN_KEY, refreshed.accessToken); - setToken(refreshed.accessToken); - } - if (refreshed.sessionId) { - window.localStorage.setItem(AUTH_SESSION_KEY, refreshed.sessionId); - } - if (refreshed.requiresPin) { - if (refreshed.user) { - setUser(refreshed.user); - cacheUserProfile(refreshed.user); + if (error instanceof ApiError && (error.code === 'NO_REFRESH' || error.status === 401)) { + logout(); + return; } - activatePinLock(refreshed.sessionId ?? ''); - return; - } - if (refreshed.accessToken) { - const session = await fetchAuthSession(refreshed.accessToken); - applySessionState(session, { setUser, setToken, activatePinLock, clearPinLock }); + const message = getApiErrorMessage(error, 'Не удалось загрузить профиль'); + if (message) showToast(message); + logout(); return; } } - - throw new ApiError('Сессия недействительна', 401); - } catch (error) { - if (isPinRequiredError(error)) { - activatePinLock(error.sessionId ?? window.localStorage.getItem(AUTH_SESSION_KEY) ?? ''); - return; - } - if (refreshToken && error instanceof ApiError && error.status === 403) { - activatePinLock(window.localStorage.getItem(AUTH_SESSION_KEY) ?? ''); - return; - } - if (error instanceof ApiError && (error.code === 'NO_REFRESH' || error.status === 401)) { - logout(); - return; - } - const message = getApiErrorMessage(error, 'Не удалось загрузить профиль'); - if (message) showToast(message); - logout(); } finally { if (isInitialBootstrap) { initialBootstrapDoneRef.current = true; + setIsApiReady(isApiGatewayReady()); setIsLoading(false); } - refreshInFlightRef.current = null; } })(); refreshInFlightRef.current = task; + void task.finally(() => { + refreshInFlightRef.current = null; + }); return task; }, [activatePinLock, clearPinLock, logout, showToast]); @@ -510,7 +542,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { ); const isBootstrapping = - isLoading && !isPinLocked && Boolean(hasStoredSession || token || user); + !isPinLocked && + Boolean(hasStoredSession || token || user) && + (isLoading || !isApiReady); return ( (null); const load = useCallback(async () => { - if (!token || isPinLocked || isLoading) return; + if (!token || isPinLocked || isLoading || !isApiReady) return; setLoading(true); try { const [list, count] = await Promise.all([fetchNotifications(token), fetchUnreadNotificationCount(token)]); @@ -48,7 +48,7 @@ export function NotificationBell() { } finally { setLoading(false); } - }, [isLoading, isPinLocked, setUnreadCount, showToast, token]); + }, [isApiReady, 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 30cbbf7..2e0c400 100644 --- a/apps/frontend/components/notifications/realtime-provider.tsx +++ b/apps/frontend/components/notifications/realtime-provider.tsx @@ -31,7 +31,7 @@ interface RealtimeContextValue { const RealtimeContext = React.createContext(null); export function RealtimeProvider({ children }: { children: React.ReactNode }) { - const { user, token, isPinLocked, isLoading } = useAuth(); + const { user, token, isPinLocked, isLoading, isApiReady } = 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 || isLoading) { + if (!user || !token || isPinLocked || isLoading || !isApiReady) { socketRef.current?.close(); socketRef.current = null; setConnected(false); @@ -121,7 +121,7 @@ export function RealtimeProvider({ children }: { children: React.ReactNode }) { socketRef.current = null; setConnected(false); }; - }, [emit, isLoading, isPinLocked, token, user]); + }, [emit, isApiReady, isLoading, isPinLocked, token, user]); return ( diff --git a/apps/frontend/hooks/use-avatar-url.ts b/apps/frontend/hooks/use-avatar-url.ts index 84d6b1b..e1a9582 100644 --- a/apps/frontend/hooks/use-avatar-url.ts +++ b/apps/frontend/hooks/use-avatar-url.ts @@ -12,12 +12,12 @@ interface AvatarAccessResponse { } export function useAvatarUrl(userId: string | undefined, hasAvatar: boolean | undefined, token: string | null) { - const { isPinLocked, isLoading } = useAuth(); + const { isPinLocked, isLoading, isApiReady } = useAuth(); const [accessUrl, setAccessUrl] = useState(null); const [resolvingAccessUrl, setResolvingAccessUrl] = useState(false); const refreshAccessUrl = useCallback(async () => { - if (!userId || !token || !hasAvatar || isPinLocked || isLoading) { + if (!userId || !token || !hasAvatar || isPinLocked || isLoading || !isApiReady) { setAccessUrl(null); setResolvingAccessUrl(false); return; @@ -31,7 +31,7 @@ export function useAvatarUrl(userId: string | undefined, hasAvatar: boolean | un } finally { setResolvingAccessUrl(false); } - }, [hasAvatar, isLoading, isPinLocked, token, userId]); + }, [hasAvatar, isApiReady, isLoading, isPinLocked, token, userId]); useEffect(() => { void refreshAccessUrl(); @@ -59,7 +59,7 @@ export function useAvatarUrl(userId: string | undefined, hasAvatar: boolean | un isLoading: isLoadingBlob, hasError } = useResilientBlobUrl(accessUrl, token, { - enabled: Boolean(hasAvatar && accessUrl && !isPinLocked && !isLoading), + enabled: Boolean(hasAvatar && accessUrl && !isPinLocked && !isLoading && isApiReady), label: `аватар ${userId ?? ''}`.trim() }); diff --git a/apps/frontend/hooks/use-e2e-chat.ts b/apps/frontend/hooks/use-e2e-chat.ts index becae88..6af361a 100644 --- a/apps/frontend/hooks/use-e2e-chat.ts +++ b/apps/frontend/hooks/use-e2e-chat.ts @@ -52,7 +52,7 @@ export function useE2EChat(options: { userId?: string; token?: string | null; }) { - const { isLoading } = useAuth(); + const { isLoading, isApiReady } = useAuth(); const isE2E = options.room?.type === 'E2E'; const peerUserId = useMemo( () => resolveChatPeerUserId(options.room, options.userId), @@ -65,7 +65,7 @@ export function useE2EChat(options: { const [e2eErrors, setE2eErrors] = useState>({}); useEffect(() => { - if (!options.userId || !options.token || isLoading) return; + if (!options.userId || !options.token || isLoading || !isApiReady) return; void (async () => { try { const { publicKey } = await ensureE2EKeyPair(options.userId!); @@ -74,7 +74,7 @@ export function useE2EChat(options: { // фоновая инициализация E2E } })(); - }, [isLoading, options.token, options.userId]); + }, [isApiReady, 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 248af49..88d471c 100644 --- a/apps/frontend/hooks/use-primary-family.ts +++ b/apps/frontend/hooks/use-primary-family.ts @@ -13,7 +13,7 @@ import { } from '@/lib/api'; export function useSelectedFamily(enabled = true) { - const { user, token, isPinLocked, isLoading } = useAuth(); + const { user, token, isPinLocked, isLoading, isApiReady } = useAuth(); const { selectedGroupId, setSelectedGroupId } = useFamilyOverlay(); const [groups, setGroups] = useState([]); const [group, setGroup] = useState(null); @@ -22,7 +22,7 @@ export function useSelectedFamily(enabled = true) { const refresh = useCallback(async () => { const accessToken = getAccessToken() ?? token?.trim() ?? null; - if (!enabled || !user || !accessToken || isPinLocked || isLoading) { + if (!enabled || !user || !accessToken || isPinLocked || isLoading || !isApiReady) { setGroups([]); setGroup(null); setPresenceMembers([]); @@ -62,7 +62,7 @@ export function useSelectedFamily(enabled = true) { } finally { setLoading(false); } - }, [enabled, isLoading, isPinLocked, selectedGroupId, setSelectedGroupId, token, user]); + }, [enabled, isApiReady, isLoading, isPinLocked, selectedGroupId, setSelectedGroupId, token, user]); useEffect(() => { setLoading(true); @@ -71,10 +71,10 @@ export function useSelectedFamily(enabled = true) { useEffect(() => { const accessToken = getAccessToken() ?? token?.trim() ?? null; - if (!enabled || !accessToken || isPinLocked || isLoading || !group) return; + if (!enabled || !accessToken || isPinLocked || isLoading || !isApiReady || !group) return; const timer = window.setInterval(() => void refresh(), 25_000); return () => window.clearInterval(timer); - }, [enabled, group, isLoading, isPinLocked, refresh, token]); + }, [enabled, group, isApiReady, isLoading, isPinLocked, refresh, token]); const presenceByUserId = useMemo( () => new Map(presenceMembers.map((member) => [member.userId, member])), diff --git a/apps/frontend/hooks/use-require-auth.ts b/apps/frontend/hooks/use-require-auth.ts index cf6d608..3dade7a 100644 --- a/apps/frontend/hooks/use-require-auth.ts +++ b/apps/frontend/hooks/use-require-auth.ts @@ -7,19 +7,20 @@ import { useAuth } from '@/components/id/auth-provider'; export function useRequireAuth() { const router = useRouter(); const pathname = usePathname(); - const { user, isLoading, isPinLocked, hasStoredSession } = useAuth(); + const { user, isLoading, isApiReady, isPinLocked, hasStoredSession } = useAuth(); useEffect(() => { - if (isLoading) return; + if (isLoading || !isApiReady) return; if (user || isPinLocked || hasStoredSession) return; if (pathname.startsWith('/auth/')) return; router.replace('/auth/login'); - }, [hasStoredSession, isLoading, isPinLocked, pathname, router, user]); + }, [hasStoredSession, isApiReady, isLoading, isPinLocked, pathname, router, user]); return { user, isLoading, isPinLocked, - isReady: !isLoading && Boolean(user || isPinLocked || hasStoredSession) + isApiReady, + isReady: !isLoading && isApiReady && Boolean(user || isPinLocked || hasStoredSession) }; } diff --git a/apps/frontend/lib/api.ts b/apps/frontend/lib/api.ts index 87eca43..c9010d7 100644 --- a/apps/frontend/lib/api.ts +++ b/apps/frontend/lib/api.ts @@ -677,9 +677,11 @@ export function buildSystemSettingPayload(setting: Pick { return new Promise((resolve) => setTimeout(resolve, ms)); @@ -687,6 +689,8 @@ function sleep(ms: number): Promise { let gatewayReadyResolved = false; let gatewayReadyPromise: Promise | null = null; +let activeApiRequests = 0; +const apiRequestWaiters: Array<() => void> = []; export function resetApiGatewayWarmup() { gatewayReadyResolved = false; @@ -702,10 +706,41 @@ export function isApiGatewayReady() { } export function markApiGatewayReady() { + if (gatewayReadyResolved) return; gatewayReadyResolved = true; + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent(API_READY_EVENT)); + } } -/** Liveness: nginx → api-gateway HTTP. Не /health/ready — gRPC 503 не должен блокировать SPA и засорять консоль. */ +export function subscribeApiReady(listener: () => void): () => void { + if (typeof window === 'undefined') return () => undefined; + if (gatewayReadyResolved) { + listener(); + return () => undefined; + } + window.addEventListener(API_READY_EVENT, listener); + return () => window.removeEventListener(API_READY_EVENT, listener); +} + +async function acquireApiSlot(): Promise { + if (activeApiRequests < API_MAX_CONCURRENT) { + activeApiRequests += 1; + return; + } + await new Promise((resolve) => { + apiRequestWaiters.push(resolve); + }); + activeApiRequests += 1; +} + +function releaseApiSlot(): void { + activeApiRequests = Math.max(0, activeApiRequests - 1); + const next = apiRequestWaiters.shift(); + if (next) next(); +} + +/** Liveness: nginx → api-gateway HTTP. */ async function probeGatewayAlive(): Promise { try { const response = await fetch(`${getApiUrl()}/health`, { @@ -720,18 +755,22 @@ async function probeGatewayAlive(): Promise { } async function waitForGatewayAlive(): Promise { + if (gatewayReadyResolved) return; + for (let attempt = 0; attempt < GATEWAY_PROBE_MAX_ATTEMPTS; attempt += 1) { if (await probeGatewayAlive()) { - gatewayReadyResolved = true; + markApiGatewayReady(); return; } if (attempt + 1 < GATEWAY_PROBE_MAX_ATTEMPTS) { - await sleep(GATEWAY_PROBE_DELAY_MS * (attempt + 1)); + await sleep(Math.min(GATEWAY_PROBE_BASE_DELAY_MS + attempt * 280, 2800)); } } + + throw new ApiError('Сервер API временно недоступен. Попробуйте обновить страницу.', 503, 'GATEWAY_UNAVAILABLE'); } -/** Один общий прогрев: до 4 проб /health, без бесконечного цикла. */ +/** Один общий прогрев: все apiFetch ждут успешный /health, без раннего «fail-open». */ export async function ensureApiGatewayReady(force = false): Promise { if (typeof window === 'undefined') return; if (gatewayReadyResolved && !force) return; @@ -749,7 +788,7 @@ export async function ensureApiGatewayReady(force = false): Promise { } function gatewayRetryDelay(attempt: number): number { - return 700 + attempt * 400; + return 800 + attempt * 700; } /** Повтор при кратковременном 502/503/504 (nginx не достучался до api-gateway после перезапуска). */ @@ -783,6 +822,8 @@ export async function apiFetch(path: string, options: RequestInit = {}, token await ensureApiGatewayReady(); } + await acquireApiSlot(); + const resolvedToken = resolveAccessToken(token); const method = (options.method ?? 'GET').toUpperCase(); const hasBody = options.body !== undefined && options.body !== null; @@ -796,40 +837,45 @@ export async function apiFetch(path: string, options: RequestInit = {}, token let response: Response; try { - response = await fetchWithGatewayRetry(`${getApiUrl()}${path}`, { - ...options, - headers - }); - } catch (error) { - if (error instanceof ApiError) { - throw error; - } - throw mapFetchError(error); - } - - if (!response.ok) { - const error = await parseErrorResponse(response); - - if (error.code === 'PIN_REQUIRED') { - notifyPinRequired(error.sessionId ?? window.localStorage.getItem(AUTH_SESSION_KEY) ?? ''); - throw error; - } - - if (allowRetry && resolvedToken && error.code === 'TOKEN_EXPIRED') { - const refreshed = await trySilentTokenRefresh(); - if (refreshed?.accessToken) { - return apiFetch(path, options, refreshed.accessToken, false); - } - if (refreshed?.requiresPin) { + try { + response = await fetchWithGatewayRetry(`${getApiUrl()}${path}`, { + ...options, + headers + }); + } catch (error) { + if (error instanceof ApiError) { throw error; } + throw mapFetchError(error); } - throw error; - } + if (!response.ok) { + const error = await parseErrorResponse(response); - markApiGatewayReady(); - return response.json() as Promise; + if (error.code === 'PIN_REQUIRED') { + notifyPinRequired(error.sessionId ?? window.localStorage.getItem(AUTH_SESSION_KEY) ?? ''); + throw error; + } + + if (allowRetry && resolvedToken && error.code === 'TOKEN_EXPIRED') { + const refreshed = await trySilentTokenRefresh(); + if (refreshed?.accessToken) { + releaseApiSlot(); + return apiFetch(path, options, refreshed.accessToken, false); + } + if (refreshed?.requiresPin) { + throw error; + } + } + + throw error; + } + + markApiGatewayReady(); + return response.json() as Promise; + } finally { + releaseApiSlot(); + } } export { getDeviceFingerprint, buildAuthDevicePayload, resolveDeviceLabel, formatUserAgentLabel } from '@/lib/device-client';