UNPKG

@oxyhq/services

Version:

OxyHQ Expo/React Native SDK — UI components, screens, and native features

289 lines (284 loc) 10.3 kB
"use strict"; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { mergeSessions, normalizeAndSortSessions, sessionsArraysEqual } from '@oxyhq/core'; import { fetchSessionsWithFallback, validateSessionBatch } from "../utils/sessionHelpers.js"; import { getStorageKeys } from "../utils/storageHelpers.js"; import { handleAuthError, isInvalidSessionError } from "../utils/errorHandlers.js"; import { clearQueryCache } from "./queryClient.js"; const DEFAULT_SAVE_ERROR_MESSAGE = 'Failed to save session data'; const CLEAR_STORAGE_ERROR = 'Failed to clear storage'; /** * Manage session state, persistence, and high-level multi-session operations. * * @param options - Session management configuration */ export const useSessionManagement = ({ oxyServices, storage, storageKeyPrefix, loginSuccess, logoutStore, applyLanguagePreference, onAuthStateChange, onError, setAuthError, logger, setTokenReady, queryClient }) => { const [sessions, setSessions] = useState([]); const [activeSessionId, setActiveSessionId] = useState(null); // Refs to avoid recreating callbacks when sessions/activeSessionId change const sessionsRef = useRef(sessions); sessionsRef.current = sessions; const activeSessionIdRef = useRef(activeSessionId); activeSessionIdRef.current = activeSessionId; const refreshInFlightRef = useRef(null); const removedSessionsRef = useRef(new Set()); const lastRefreshRef = useRef(0); const storageKeys = useMemo(() => getStorageKeys(storageKeyPrefix), [storageKeyPrefix]); const saveSessionIds = useCallback(async sessionIds => { if (!storage) return; try { const uniqueIds = Array.from(new Set(sessionIds)); await storage.setItem(storageKeys.sessionIds, JSON.stringify(uniqueIds)); } catch (error) { if (logger) { logger(DEFAULT_SAVE_ERROR_MESSAGE, error); } else if (__DEV__) { console.warn('Failed to save session IDs:', error); } } }, [logger, storage, storageKeys.sessionIds]); const updateSessions = useCallback((incoming, options = {}) => { setSessions(prevSessions => { const processed = options.merge ? mergeSessions(prevSessions, incoming, activeSessionIdRef.current, false) : normalizeAndSortSessions(incoming, activeSessionIdRef.current, false); if (storage) { void saveSessionIds(processed.map(session => session.sessionId)); } if (sessionsArraysEqual(prevSessions, processed)) { return prevSessions; } return processed; }); }, [saveSessionIds, storage]); const saveActiveSessionId = useCallback(async sessionId => { if (!storage) return; try { await storage.setItem(storageKeys.activeSessionId, sessionId); } catch (error) { handleAuthError(error, { defaultMessage: DEFAULT_SAVE_ERROR_MESSAGE, code: 'SESSION_PERSISTENCE_ERROR', onError, setAuthError, logger }); } }, [logger, onError, setAuthError, storage, storageKeys.activeSessionId]); const removeActiveSessionId = useCallback(async () => { if (!storage) return; try { await storage.removeItem(storageKeys.activeSessionId); } catch (error) { handleAuthError(error, { defaultMessage: DEFAULT_SAVE_ERROR_MESSAGE, code: 'SESSION_PERSISTENCE_ERROR', onError, setAuthError, logger }); } }, [logger, onError, setAuthError, storage, storageKeys.activeSessionId]); const clearSessionStorage = useCallback(async () => { if (!storage) return; try { await storage.removeItem(storageKeys.activeSessionId); await storage.removeItem(storageKeys.sessionIds); // Note: Identity sync state ('oxy_identity_synced') is managed by accounts app } catch (error) { handleAuthError(error, { defaultMessage: CLEAR_STORAGE_ERROR, code: 'STORAGE_ERROR', onError, setAuthError, logger }); } }, [logger, onError, setAuthError, storage, storageKeys.activeSessionId, storageKeys.sessionIds]); const clearSessionState = useCallback(async () => { setSessions([]); setActiveSessionId(null); logoutStore(); // Clear TanStack Query cache (in-memory) if (queryClient) { queryClient.clear(); } // Clear persisted query cache if (storage) { try { await clearQueryCache(storage); } catch (error) { if (logger) { logger('Failed to clear persisted query cache', error); } } } await clearSessionStorage(); onAuthStateChange?.(null); }, [clearSessionStorage, logoutStore, onAuthStateChange, queryClient, storage, logger]); const activateSession = useCallback(async (sessionId, user) => { await oxyServices.getTokenBySession(sessionId); setTokenReady?.(true); setActiveSessionId(sessionId); loginSuccess(user); await saveActiveSessionId(sessionId); await applyLanguagePreference(user); onAuthStateChange?.(user); }, [applyLanguagePreference, loginSuccess, onAuthStateChange, oxyServices, saveActiveSessionId, setTokenReady]); const removalTimerIdsRef = useRef(new Set()); const trackRemovedSession = useCallback(sessionId => { removedSessionsRef.current.add(sessionId); const timerId = setTimeout(() => { removedSessionsRef.current.delete(sessionId); removalTimerIdsRef.current.delete(timerId); }, 5000); removalTimerIdsRef.current.add(timerId); }, []); useEffect(() => { return () => { removalTimerIdsRef.current.forEach(clearTimeout); }; }, []); const findReplacementSession = useCallback(async sessionIds => { if (!sessionIds.length) { return null; } const validationResults = await validateSessionBatch(oxyServices, sessionIds, { maxConcurrency: 3 }); const validSession = validationResults.find(result => result.valid); if (!validSession) { return null; } const validation = await oxyServices.validateSession(validSession.sessionId, { useHeaderValidation: true }); if (!validation?.valid || !validation.user) { return null; } const user = validation.user; await activateSession(validSession.sessionId, user); return user; }, [activateSession, oxyServices]); const switchSession = useCallback(async sessionId => { try { const validation = await oxyServices.validateSession(sessionId, { useHeaderValidation: true }); if (!validation?.valid) { throw new Error('Session is invalid or expired'); } if (!validation.user) { throw new Error('User data not available from session validation'); } const user = validation.user; await activateSession(sessionId, user); try { const deviceSessions = await fetchSessionsWithFallback(oxyServices, sessionId, { fallbackUserId: user.id, logger }); updateSessions(deviceSessions, { merge: true }); } catch (error) { if (__DEV__) { console.warn('Failed to synchronize sessions after switch:', error); } } return user; } catch (error) { const invalidSession = isInvalidSessionError(error); if (invalidSession) { updateSessions(sessionsRef.current.filter(session => session.sessionId !== sessionId), { merge: false }); if (sessionId === activeSessionIdRef.current) { const otherSessionIds = sessionsRef.current.filter(session => session.sessionId !== sessionId && !removedSessionsRef.current.has(session.sessionId)).map(session => session.sessionId); const replacementUser = await findReplacementSession(otherSessionIds); if (replacementUser) { return replacementUser; } } } handleAuthError(error, { defaultMessage: 'Failed to switch session', code: invalidSession ? 'INVALID_SESSION' : 'SESSION_SWITCH_ERROR', onError, setAuthError, logger }); throw error instanceof Error ? error : new Error('Failed to switch session'); } }, [activateSession, findReplacementSession, logger, onError, oxyServices, setAuthError, updateSessions]); const refreshSessions = useCallback(async activeUserId => { if (!activeSessionIdRef.current) return; if (refreshInFlightRef.current) { await refreshInFlightRef.current; return; } const now = Date.now(); if (now - lastRefreshRef.current < 500) { return; } lastRefreshRef.current = now; const refreshPromise = (async () => { try { const deviceSessions = await fetchSessionsWithFallback(oxyServices, activeSessionIdRef.current, { fallbackUserId: activeUserId, logger }); updateSessions(deviceSessions, { merge: true }); } catch (error) { if (isInvalidSessionError(error)) { const otherSessions = sessionsRef.current.filter(session => session.sessionId !== activeSessionIdRef.current && !removedSessionsRef.current.has(session.sessionId)).map(session => session.sessionId); const replacementUser = await findReplacementSession(otherSessions); if (!replacementUser) { await clearSessionState(); } return; } handleAuthError(error, { defaultMessage: 'Failed to refresh sessions', code: 'SESSION_REFRESH_ERROR', onError, setAuthError, logger }); } finally { refreshInFlightRef.current = null; lastRefreshRef.current = Date.now(); } })(); refreshInFlightRef.current = refreshPromise; await refreshPromise; }, [clearSessionState, findReplacementSession, logger, onError, oxyServices, setAuthError, updateSessions]); const isRefreshInFlight = Boolean(refreshInFlightRef.current); return { sessions, activeSessionId, setActiveSessionId, updateSessions, switchSession, refreshSessions, clearSessionState, saveActiveSessionId, trackRemovedSession, storageKeys, isRefreshInFlight }; }; //# sourceMappingURL=useSessionManagement.js.map