Files
crm-customer-ui/src/axios/axiosInstance.ts
T
2026-10-09 10:06:34 +03:30

124 lines
6.3 KiB
TypeScript

'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<string> } | 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<string>): Promise<string> {
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<string> {
const originalRefresh = getRefreshToken()
async function rotate(): Promise<string> {
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