import { reportServerVersion } from "./appVersion"; class ApiState { showSessionExpiredAlert = false; showLogoutAlert = false; getTokenFn: (() => string | null) | null = null; refreshFn: (() => Promise) | null = null; refreshPromise: Promise | null = null; reset() { this.showSessionExpiredAlert = false; this.showLogoutAlert = false; this.getTokenFn = null; this.refreshFn = null; this.refreshPromise = null; } } const state = new ApiState(); export const resetApiState = (): void => { state.reset(); }; export const shouldShowSessionExpiredAlert = (): boolean => { if (state.showSessionExpiredAlert) { state.showSessionExpiredAlert = false; return true; } return false; }; export const setSessionExpired = (): void => { state.showSessionExpiredAlert = true; }; export const shouldShowLogoutAlert = (): boolean => { if (state.showLogoutAlert) { state.showLogoutAlert = false; return true; } return false; }; export const setLogoutAlert = (): void => { state.showLogoutAlert = true; }; export const setTokenGetter = (fn: () => string | null): void => { state.getTokenFn = fn; }; export const setRefreshFn = (fn: () => Promise): void => { state.refreshFn = fn; }; export const apiFetch = async ( url: string, options: RequestInit = {}, ): Promise => { // Honour abort signals up-front — TanStack Query may cancel a stale // request before we've done any work. if (options.signal?.aborted) { return new Response(null, { status: 499 }); } let token: string | null = null; try { token = state.getTokenFn ? state.getTokenFn() : null; } catch { // token retrieval failed } // No usable token yet (e.g. fresh page load where the silent refresh // kicked off in useEffect hasn't populated the in-memory access token). // Refresh up front, BEFORE firing the request, so the caller never sees // a 401 in the network log on the first attempt. Concurrent callers join // the same in-flight refresh via state.refreshPromise. if (!token && state.refreshFn) { try { if (!state.refreshPromise) { state.refreshPromise = state.refreshFn().finally(() => { state.refreshPromise = null; }); } const refreshed = await state.refreshPromise; if (options.signal?.aborted) { return new Response(null, { status: 499 }); } if (refreshed) { try { token = state.getTokenFn ? state.getTokenFn() : null; } catch { token = null; } } } catch { // Refresh itself failed — fall through and let the request fire // without a token. The 401 handler below will set a session-expired // flag so the UI can react. } } const headers: Record = { ...(options.headers as Record), }; if ( !headers["Content-Type"] && options.body && !(options.body instanceof FormData) ) { headers["Content-Type"] = "application/json"; } if (token) { headers["Authorization"] = `Bearer ${token}`; } let response = await fetch(url, { ...options, headers, credentials: "include", }); // Detect a new deploy: compare the server's build version to ours. reportServerVersion(response.headers.get("X-App-Version")); if (response.status === 401 && state.refreshFn) { // If the caller already unmounted (TanStack Query aborted the request), // do not refresh + retry. Doing so would fire a global "session expired" // toast on a page the user has already left and leak a fetch+refresh pair. if (options.signal?.aborted) { return response; } try { if (!state.refreshPromise) { state.refreshPromise = state.refreshFn().finally(() => { state.refreshPromise = null; }); } const refreshed = await state.refreshPromise; // Re-check after the await: the caller may have unmounted while the // refresh was in flight (refresh + abort race). if (options.signal?.aborted) { return response; } if (refreshed) { token = state.getTokenFn ? state.getTokenFn() : null; if (token) { headers["Authorization"] = `Bearer ${token}`; } const { signal: _signal, ...retryOptions } = options; response = await fetch(url, { ...retryOptions, headers, credentials: "include", }); } else { setSessionExpired(); } } catch { setSessionExpired(); } } return response; }; export const getAccessToken = (): string | null => { try { return state.getTokenFn ? state.getTokenFn() : null; } catch { return null; } }; export default apiFetch;