import axios, { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from 'axios'; import { getApiUrl, isDevelopment } from './utils/env'; export interface ApiClientConfig { /** * Base URL for API requests * @default process.env.VITE_API_URL || 'http://localhost:4000/api' */ baseURL?: string; /** * Request timeout in milliseconds * @default 10000 */ timeout?: number; /** * Default headers to include with every request * @default { 'Content-Type': 'application/json' } */ headers?: Record; /** * Local storage key for access token * @default 'auth_token' */ tokenStorageKey?: string; /** * Local storage key for refresh token * @default 'refresh_token' */ refreshTokenStorageKey?: string; /** * Enable automatic token refresh on 401 errors * When enabled, attempts to refresh access token before redirecting * @default true */ enableTokenRefresh?: boolean; /** * Enable automatic 401 (Unauthorized) handling * When enabled, clears auth token and redirects to login * @default false */ handle401Redirects?: boolean; /** * Login route for 401 redirects * @default '/login' */ loginRoute?: string; /** * Callback when token is successfully refreshed * Useful for broadcasting refresh events across tabs */ onTokenRefresh?: (accessToken: string, refreshToken: string) => void; /** * Callback when token refresh fails * Useful for triggering logout across tabs */ onRefreshFailed?: () => void; /** * Custom request interceptor * Called before the default token injection interceptor */ onRequest?: (config: InternalAxiosRequestConfig) => InternalAxiosRequestConfig | Promise; /** * Custom response error interceptor * Called before the default 401 handler (if enabled) */ onResponseError?: (error: AxiosError) => Promise; /** * Enable request/response logging in console (useful for debugging) * Only works in development mode * @default false */ enableLogging?: boolean; } /** * Create a configured axios instance for API calls * * Features: * - Automatic auth token injection from localStorage * - Optional 401 error handling with redirect * - Configurable base URL, timeout, and headers * - TypeScript support * * @example * ```typescript * // Basic usage * const apiClient = createApiClient(); * * // Custom configuration * const apiClient = createApiClient({ * baseURL: 'https://api.example.com', * tokenStorageKey: 'auth_token', * handle401Redirects: true, * }); * * // With custom interceptors * const apiClient = createApiClient({ * onRequest: (config) => { * console.log('Making request:', config.url); * return config; * }, * onResponseError: async (error) => { * console.error('API error:', error); * throw error; * }, * }); * ``` */ export function createApiClient(config: ApiClientConfig = {}): AxiosInstance { const { baseURL = getApiUrl(), timeout = 10000, headers = { 'Content-Type': 'application/json' }, tokenStorageKey = 'auth_token', refreshTokenStorageKey = 'refresh_token', enableTokenRefresh = true, handle401Redirects = false, loginRoute = '/login', onTokenRefresh, onRefreshFailed, onRequest, onResponseError, enableLogging = false, } = config; // Track refresh token promise to prevent multiple simultaneous refreshes let isRefreshing = false; let refreshSubscribers: ((token: string) => void)[] = []; /** * Add request to queue while refreshing token */ const subscribeTokenRefresh = (callback: (token: string) => void) => { refreshSubscribers.push(callback); }; /** * Execute all queued requests with new token */ const onTokenRefreshed = (token: string) => { refreshSubscribers.forEach((callback) => callback(token)); refreshSubscribers = []; }; /** * Attempt to refresh the access token */ const refreshAccessToken = async (): Promise => { if (typeof localStorage === 'undefined') { return null; } const refreshToken = localStorage.getItem(refreshTokenStorageKey); if (!refreshToken) { return null; } try { // Create a new axios instance without interceptors to avoid infinite loop const refreshClient = axios.create({ baseURL, timeout, headers, }); const response = await refreshClient.post('/auth/refresh', { refreshToken }); const { accessToken, refreshToken: newRefreshToken } = response.data; // Update tokens in localStorage localStorage.setItem(tokenStorageKey, accessToken); if (newRefreshToken) { localStorage.setItem(refreshTokenStorageKey, newRefreshToken); } // Notify callbacks if (onTokenRefresh) { onTokenRefresh(accessToken, newRefreshToken || refreshToken); } return accessToken; } catch (error) { // Refresh failed - clear tokens localStorage.removeItem(tokenStorageKey); localStorage.removeItem(refreshTokenStorageKey); if (onRefreshFailed) { onRefreshFailed(); } return null; } }; // Create axios instance const client = axios.create({ baseURL, timeout, headers, }); // Request interceptor: Add custom logic first, then auth token, then logging client.interceptors.request.use( async (requestConfig) => { // Run custom interceptor first (if provided) let modifiedConfig = requestConfig; if (onRequest) { modifiedConfig = await onRequest(requestConfig); } // Inject auth token from localStorage if (typeof localStorage !== 'undefined') { const token = localStorage.getItem(tokenStorageKey); if (token) { modifiedConfig.headers.Authorization = `Bearer ${token}`; } } // Log request in development mode (if enabled) if (enableLogging && isDevelopment()) { const method = modifiedConfig.method?.toUpperCase() || 'GET'; const url = modifiedConfig.url || ''; console.log(`[API Request] ${method} ${url}`, { params: modifiedConfig.params, data: modifiedConfig.data, }); } return modifiedConfig; }, (error) => Promise.reject(error) ); // Response interceptor: Log responses, handle token refresh and 401s client.interceptors.response.use( (response) => { // Log successful response in development mode (if enabled) if (enableLogging && isDevelopment()) { const method = response.config.method?.toUpperCase() || 'GET'; const url = response.config.url || ''; const {status} = response; console.log(`[API Response] ${method} ${url} - ${status}`, { data: response.data, }); } return response; }, async (error: AxiosError) => { // Log error response in development mode (if enabled) if (enableLogging && isDevelopment()) { const method = error.config?.method?.toUpperCase() || 'GET'; const url = error.config?.url || ''; const status = error.response?.status || 'Network Error'; console.error(`[API Error] ${method} ${url} - ${status}`, { error: error.response?.data || error.message, }); } const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }; // Run custom error interceptor first (if provided) if (onResponseError) { return onResponseError(error); } // Handle 401 errors if (error.response?.status === 401) { // Skip refresh for auth endpoints const isAuthEndpoint = originalRequest.url?.includes('/auth/login') || originalRequest.url?.includes('/auth/register') || originalRequest.url?.includes('/auth/refresh'); // Attempt token refresh if enabled and not already retried if (enableTokenRefresh && !isAuthEndpoint && !originalRequest._retry) { if (isRefreshing) { // Token refresh already in progress - queue this request return new Promise((resolve) => { subscribeTokenRefresh((token: string) => { originalRequest.headers.Authorization = `Bearer ${token}`; resolve(client(originalRequest)); }); }); } originalRequest._retry = true; isRefreshing = true; try { const newToken = await refreshAccessToken(); if (newToken) { // Token refreshed successfully - retry original request isRefreshing = false; onTokenRefreshed(newToken); originalRequest.headers.Authorization = `Bearer ${newToken}`; return client(originalRequest); } } catch (refreshError) { isRefreshing = false; refreshSubscribers = []; } } // Redirect to login if 401 handling is enabled if (handle401Redirects && typeof window !== 'undefined') { const isAuthPage = window.location.pathname.includes(loginRoute) || window.location.pathname.includes('/register'); if (!isAuthPage) { if (typeof localStorage !== 'undefined') { localStorage.removeItem(tokenStorageKey); localStorage.removeItem(refreshTokenStorageKey); } window.location.href = loginRoute; } } } return Promise.reject(error); } ); return client; }