162 lines
4.3 KiB
TypeScript
162 lines
4.3 KiB
TypeScript
/**
|
|
* Core fetch wrapper - reusable across modules
|
|
* Does not depend on stores to avoid bundling issues in library builds
|
|
*/
|
|
|
|
export interface FetchWrapperOptions {
|
|
/**
|
|
* Optional callback to handle logout on auth failure
|
|
* If not provided, only logs error without redirecting
|
|
*/
|
|
onLogout?: () => void | Promise<void>;
|
|
/**
|
|
* Enable automatic retry of failed requests after token refresh
|
|
* @default true
|
|
*/
|
|
autoRetry?: boolean;
|
|
}
|
|
|
|
// Mutex to prevent multiple simultaneous refresh attempts
|
|
class RefreshMutex {
|
|
private promise: Promise<boolean> | null = null;
|
|
|
|
async acquire(): Promise<boolean> {
|
|
if (this.promise) {
|
|
return this.promise;
|
|
}
|
|
|
|
this.promise = this.performRefresh();
|
|
const result = await this.promise;
|
|
this.promise = null;
|
|
return result;
|
|
}
|
|
|
|
private async performRefresh(): Promise<boolean> {
|
|
try {
|
|
const response = await fetch('/security/refresh', {
|
|
method: 'POST',
|
|
credentials: 'include'
|
|
});
|
|
|
|
return response.ok;
|
|
} catch (error) {
|
|
console.error('Token refresh failed:', error);
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
const tokenRefreshMutex = new RefreshMutex();
|
|
|
|
export interface RequestCallOptions {
|
|
/**
|
|
* Override autoRetry for this specific request
|
|
* @default true
|
|
*/
|
|
autoRetry?: boolean;
|
|
/**
|
|
* Skip calling onLogout callback on 401/403 errors
|
|
* Useful for authentication endpoints where 401 means invalid credentials, not session expiry
|
|
* @default false
|
|
*/
|
|
skipLogoutOnError?: boolean;
|
|
}
|
|
|
|
export function createFetchWrapper(options: FetchWrapperOptions = {}) {
|
|
const { autoRetry: defaultAutoRetry = true } = options;
|
|
|
|
return {
|
|
get: request('GET', options, defaultAutoRetry),
|
|
post: request('POST', options, defaultAutoRetry),
|
|
put: request('PUT', options, defaultAutoRetry),
|
|
delete: request('DELETE', options, defaultAutoRetry)
|
|
};
|
|
}
|
|
|
|
interface RequestOptions {
|
|
method: string;
|
|
headers: Record<string, string>;
|
|
body?: string;
|
|
credentials: 'include';
|
|
}
|
|
|
|
function request(method: string, options: FetchWrapperOptions, defaultAutoRetry: boolean) {
|
|
return async (url: string, body?: object, callOptions?: RequestCallOptions): Promise<any> => {
|
|
const autoRetry = callOptions?.autoRetry ?? defaultAutoRetry;
|
|
|
|
const requestOptions: RequestOptions = {
|
|
method,
|
|
headers: getHeaders(url),
|
|
credentials: 'include'
|
|
};
|
|
|
|
if (body) {
|
|
requestOptions.headers['Content-Type'] = 'application/json';
|
|
requestOptions.body = JSON.stringify(body);
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(url, requestOptions);
|
|
|
|
if (response.status === 401 && autoRetry) {
|
|
// Try to refresh the token
|
|
const refreshSuccess = await tokenRefreshMutex.acquire();
|
|
|
|
if (refreshSuccess) {
|
|
// Retry the original request with the new token
|
|
const retryResponse = await fetch(url, requestOptions);
|
|
return handleResponse(retryResponse, options, callOptions?.skipLogoutOnError);
|
|
}
|
|
}
|
|
|
|
return handleResponse(response, options, callOptions?.skipLogoutOnError);
|
|
} catch (error) {
|
|
console.error('API error:', error);
|
|
throw error;
|
|
}
|
|
};
|
|
}
|
|
|
|
function getHeaders(_url: string): Record<string, string> {
|
|
const headers: Record<string, string> = {};
|
|
|
|
// Add CSRF token if available
|
|
const csrfToken = getCsrfTokenFromCookie();
|
|
if (csrfToken) {
|
|
headers['X-CSRF-TOKEN'] = csrfToken;
|
|
}
|
|
|
|
return headers;
|
|
}
|
|
|
|
function getCsrfTokenFromCookie(): string | null {
|
|
if (typeof document === 'undefined') return null;
|
|
|
|
const csrfCookie = document.cookie
|
|
.split('; ')
|
|
.find(row => row.startsWith('X-CSRF-TOKEN='));
|
|
|
|
return csrfCookie ? csrfCookie.split('=')[1] : null;
|
|
}
|
|
|
|
async function handleResponse(response: Response, options: FetchWrapperOptions, skipLogoutOnError?: boolean): Promise<any> {
|
|
const text = await response.text();
|
|
const data = text && JSON.parse(text);
|
|
|
|
if (!response.ok) {
|
|
if ([401, 403].includes(response.status) && !skipLogoutOnError) {
|
|
// Call logout callback if provided
|
|
if (options.onLogout) {
|
|
await options.onLogout();
|
|
} else {
|
|
console.error('Authentication failed. Please log in again.');
|
|
}
|
|
}
|
|
|
|
const error: string = (data && data.message) || response.statusText;
|
|
throw new Error(error);
|
|
}
|
|
|
|
return data;
|
|
}
|