'use client' import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios' import { expireSession, getAccessToken, getRefreshToken, getSessionIdentity, saveTokens, validTokens } from '@/lib/auth' type RetryRequest = InternalAxiosRequestConfig & { _retry?: boolean; _sessionIdentity?: string } class InvalidSession extends Error {} let pendingRefresh: { identity: string; promise: Promise } | null = null const leaseKey = 'customerRefreshLease' type RefreshLease = { owner: string; identity: string; expires: number } function readLease(): RefreshLease | null { try { const value = JSON.parse(window.localStorage.getItem(leaseKey) || 'null') return value && typeof value.owner === 'string' && typeof value.identity === 'string' && typeof value.expires === 'number' && Number.isFinite(value.expires) ? value : null } catch { return null } } async function withRefreshLease(identity: string, action: () => Promise): Promise { const owner = `${Date.now()}-${Math.random()}` const deadline = Date.now() + 20000 while (Date.now() < deadline) { if (identity !== getSessionIdentity()) throw new Error('نشست تغییر کرده است.') const lease = readLease() if (!lease || lease.identity !== identity || lease.expires <= Date.now()) { try { window.localStorage.setItem(leaseKey, JSON.stringify({ owner, identity, expires: Date.now() + 15000 })) } catch { return action() } // Yield before checking ownership so simultaneous contenders see // the same winner rather than both sending a rotating JWT. await new Promise(resolve => setTimeout(resolve, 50)) if (readLease()?.owner === owner) { try { return await action() } finally { try { if (readLease()?.owner === owner) window.localStorage.removeItem(leaseKey) } catch { /* An abandoned lease expires, including after a closed tab. */ } } } } await new Promise(resolve => setTimeout(resolve, 50)) } throw new Error('تمدید نشست موقتاً انجام نشد. دوباره تلاش کنید.') } const axiosInstance = axios.create({ baseURL: process.env.NEXT_PUBLIC_API_URL + '/', timeout: 10000, headers: { 'Content-Type': 'application/json' }, }) axiosInstance.interceptors.request.use((request: RetryRequest) => { const access = getAccessToken() if (access) request.headers.Authorization = 'Bearer ' + access else delete request.headers.Authorization request._sessionIdentity ??= getSessionIdentity() return request }) async function refreshAccess(identity: string): Promise { const originalRefresh = getRefreshToken() async function rotate(): Promise { if (identity !== getSessionIdentity()) throw new Error('نشست تغییر کرده است.') const refresh = getRefreshToken() if (!refresh) throw new InvalidSession() // A different tab may have rotated while this tab waited for the lock. if (refresh !== originalRefresh && getAccessToken()) return getAccessToken()! try { const response = await axios.post(process.env.NEXT_PUBLIC_API_URL + '/auth/token/refresh/', { refresh }, { timeout: 10000 }) if (identity !== getSessionIdentity()) throw new Error('نشست تغییر کرده است.') if (getRefreshToken() !== refresh) { if (getAccessToken()) return getAccessToken()! throw new Error('نشست تغییر کرده است.') } if (!validTokens(response.data)) throw new InvalidSession() saveTokens(response.data, refresh) return response.data.access } catch (error) { if (identity !== getSessionIdentity()) throw new Error('نشست تغییر کرده است.') if (getRefreshToken() !== refresh && getAccessToken()) return getAccessToken()! if (axios.isAxiosError(error) && [400, 401, 403].includes(error.response?.status || 0)) throw new InvalidSession() throw error } } // Web Locks serialize refresh rotation across tabs in supporting browsers. if (typeof navigator !== 'undefined' && navigator.locks) { return navigator.locks.request('crm-customer-jwt-refresh', rotate) } return withRefreshLease(identity, rotate) } axiosInstance.interceptors.response.use(response => { const identity = (response.config as RetryRequest)._sessionIdentity if (identity && identity !== getSessionIdentity()) return Promise.reject(new Error('نشست تغییر کرده است.')) return response }, async (error: AxiosError) => { const request = error.config as RetryRequest | undefined if (error.response?.status !== 401 || !request || /\/auth\//.test(request.url || '')) return Promise.reject(error) const identity = request._sessionIdentity! // Never replay a previous person's request under a newly logged-in account. if (identity !== getSessionIdentity()) return Promise.reject(error) if (request._retry) { const latest = getAccessToken() if (!latest || request.headers.Authorization === 'Bearer ' + latest) expireSession() return Promise.reject(error) } request._retry = true const currentAccess = getAccessToken() if (currentAccess && request.headers.Authorization !== 'Bearer ' + currentAccess) return axiosInstance(request) const refresh = getRefreshToken() try { if (!pendingRefresh || pendingRefresh.identity !== identity) { const pending = { identity, promise: refreshAccess(identity) } pendingRefresh = pending void pending.promise.finally(() => { if (pendingRefresh === pending) pendingRefresh = null }).catch(() => {}) } const access = await pendingRefresh.promise if (identity !== getSessionIdentity()) return Promise.reject(error) request.headers.Authorization = 'Bearer ' + access return axiosInstance(request) } catch (refreshError) { if (refreshError instanceof InvalidSession && identity === getSessionIdentity() && getRefreshToken() === refresh) expireSession() return Promise.reject(refreshError) } }) export default axiosInstance