client.ts
1 // Copyright (c) 2026 VPL Solutions. All rights reserved. 2 // Licensed under the MIT License. See LICENSE for details. 3 4 import { config } from '../config'; 5 import { getAuthHeaders } from '../auth/getAuthHeaders'; 6 7 export class ApiError extends Error { 8 status: number; 9 requestId?: string; 10 11 constructor(message: string, status: number, requestId?: string) { 12 super(message); 13 this.name = 'ApiError'; 14 this.status = status; 15 this.requestId = requestId; 16 } 17 } 18 19 const DEFAULT_TIMEOUT_MS = 30_000; 20 21 interface RequestOptions extends RequestInit { 22 params?: Record<string, string>; 23 baseUrl?: string; 24 timeoutMs?: number; 25 } 26 27 async function request<T>( 28 endpoint: string, 29 options: RequestOptions = {} 30 ): Promise<T> { 31 const { params, baseUrl = config.apiBaseUrl, timeoutMs = DEFAULT_TIMEOUT_MS, ...init } = options; 32 33 let url = `${baseUrl}${endpoint}`; 34 35 if (params) { 36 const searchParams = new URLSearchParams(params); 37 url += `?${searchParams.toString()}`; 38 } 39 40 const controller = new AbortController(); 41 const timeout = setTimeout(() => controller.abort(), timeoutMs); 42 43 const authHeaders = await getAuthHeaders(); 44 45 let response: Response; 46 try { 47 response = await fetch(url, { 48 ...init, 49 signal: init.signal ?? controller.signal, 50 headers: { 51 ...(!(init.body instanceof FormData) && { 'Content-Type': 'application/json' }), 52 ...authHeaders, 53 ...init.headers, 54 }, 55 }); 56 } catch (err) { 57 if (err instanceof Error && err.name === 'AbortError') { 58 throw new ApiError(`Request timed out after ${timeoutMs}ms`, 0); 59 } 60 throw err; 61 } finally { 62 clearTimeout(timeout); 63 } 64 65 if (!response.ok) { 66 // 401 is handled by getAuthHeaders (acquireTokenSilent → acquireTokenRedirect). 67 // Do not trigger re-auth here — a 401 from the backend means the token 68 // was sent but rejected (e.g. not admin), not that the session expired. 69 const errorData = await response.json().catch(() => ({})); 70 throw new ApiError( 71 errorData.detail || `Request failed: ${response.statusText}`, 72 response.status, 73 errorData.request_id 74 ); 75 } 76 77 return response.json(); 78 } 79 80 export const api = { 81 get: <T>(endpoint: string, options?: RequestOptions) => 82 request<T>(endpoint, { ...options, method: 'GET' }), 83 84 post: <T>(endpoint: string, data?: unknown, options?: RequestOptions) => 85 request<T>(endpoint, { 86 ...options, 87 method: 'POST', 88 body: data ? JSON.stringify(data) : undefined, 89 }), 90 91 postForm: <T>(endpoint: string, formData: FormData, options?: RequestOptions) => 92 request<T>(endpoint, { ...options, method: 'POST', body: formData }), 93 94 postForBlob: async (endpoint: string, data?: unknown, options?: RequestOptions) => { 95 const { params, baseUrl = config.apiBaseUrl, timeoutMs = DEFAULT_TIMEOUT_MS, ...init } = options ?? {}; 96 let url = `${baseUrl}${endpoint}`; 97 if (params) url += `?${new URLSearchParams(params).toString()}`; 98 99 const controller = new AbortController(); 100 const timeout = setTimeout(() => controller.abort(), timeoutMs); 101 const blobAuthHeaders = await getAuthHeaders(); 102 103 let response: Response; 104 try { 105 response = await fetch(url, { 106 ...init, 107 signal: init.signal ?? controller.signal, 108 method: 'POST', 109 headers: { 'Content-Type': 'application/json', ...blobAuthHeaders, ...init.headers }, 110 body: data ? JSON.stringify(data) : undefined, 111 }); 112 } catch (err) { 113 if (err instanceof Error && err.name === 'AbortError') { 114 throw new ApiError(`Request timed out after ${timeoutMs}ms`, 0); 115 } 116 throw err; 117 } finally { 118 clearTimeout(timeout); 119 } 120 121 if (!response.ok) { 122 const errorData = await response.json().catch(() => ({})); 123 throw new ApiError( 124 errorData.detail || `Request failed: ${response.statusText}`, 125 response.status, 126 errorData.request_id, 127 ); 128 } 129 return response.blob(); 130 }, 131 };