1 2import { config } from '../config.js'; 3import { auth } from '../auth.js'; 4import { toCamelCase, toPascalCase } from '../utils/case-transform.js'; 5import { connectivity } from '../utils/connectivity.js'; 6import { offlineInterceptor } from '../offline/offline-interceptor.js'; 7 8/** 9 * Normalize an endpoint into the cache-key form used by offline-interceptor.js. 10 * Strips any leading slash so keys are consistent regardless of caller style 11 * (`smrtbuild/projects` and `/smrtbuild/projects` produce the same key). 12 */ 13function _cacheKey(endpoint) { 14 return (endpoint || '').replace(/^\/+/, ''); 15} 16 17function createAuthExpiredError() { 18 const error = new Error('Unauthorized - please login again'); 19 error.name = 'AuthExpiredError'; 20 error.status = 401; 21 error.lifecycleCancelled = true; 22 return error; 23} 24 25async function rejectExpiredAuth(options = {}) { 26 const error = createAuthExpiredError(); 27 if (connectivity.isOnline && !options.suppressAuthLogout) { 28 await auth.logout('/login', { preemptCurrentTransition: true }); 29 } 30 throw error; 31} 32 33export class ApiClient { 34 constructor(baseUrl = config.apiBaseUrl) { 35 this.baseUrl = baseUrl; 36 } 37 38 // Helper to build URL respecting apiMode (proxy vs direct) 39 buildUrl(endpoint) { 40 const cleanEndpoint = endpoint.replace(/^\/+/, ''); 41 if (config.apiMode === 'proxy') { 42 // proxy path on same origin 43 return `/api/${cleanEndpoint}`; 44 } 45 return `${this.baseUrl}/${cleanEndpoint}`; 46 } 47 48 async request(endpoint, options = {}) { 49 if (!config.apiBaseUrl) await config.load(); 50 await connectivity.whenSettled(); 51 52 const method = (options.method || 'GET').toUpperCase(); 53 const key = _cacheKey(endpoint); 54 const networkOnly = method === 'GET' && options.networkOnly === true; 55 const fetchOptions = { ...options }; 56 delete fetchOptions.networkOnly; 57 const skipOfflineQueue = options.skipOfflineQueue === true; 58 delete fetchOptions.skipOfflineQueue; 59 60 // ââ Pre-fetch interception âââââââââââââââââââââââââââââââââ 61 // GETs: if offline, serve from cache or throw OfflineCacheMissError. 62 // Mutations: if offline + queueable, enqueue + return optimistic envelope. 63 // if offline + not queueable, throw OfflineNotQueueableError. 64 try { 65 if (method === 'GET') { 66 const cached = await offlineInterceptor.interceptGet(key, { networkOnly }); 67 if (cached) return cached.data; // served from cache, no fetch 68 } else { 69 const queued = await offlineInterceptor.interceptMutation(key, method, options.body, { ...fetchOptions, skipOfflineQueue }); 70 if (queued) return queued; // { queued: true, _offline: true, ... } 71 } 72 } catch (interceptErr) { 73 // Interceptor threw (cache miss / not-queueable). Let the caller see it. 74 throw interceptErr; 75 } 76 77 const url = this.buildUrl(endpoint); 78 79 const headers = { 80 'Accept': 'application/json', 81 'Content-Type': 'application/json', 82 // Keep apiKey optional; avoid client-side secrets in production 83 ...(config.apiKey ? { 'X-API-Key': config.apiKey } : {}), 84 ...(options.headers || {}) 85 }; 86 87 const token = auth.getToken(); 88 if (token) headers['Authorization'] = `Bearer ${token}`; 89 90 // Transform outgoing body to PascalCase 91 let body = options.body; 92 if (body && typeof body === 'object' && !(body instanceof FormData)) { 93 body = JSON.stringify(toPascalCase(body)); 94 } 95 96 try { 97 const response = await fetch(url, { ...fetchOptions, headers, body, signal: fetchOptions.signal, credentials: 'same-origin', cache: 'no-store' }); 98 connectivity.noteFetchSuccess(); 99 100 if (response.status === 401) { 101 await rejectExpiredAuth(options); 102 } 103 104 if (!response.ok) { 105 let message = `HTTP ${response.status}`; 106 let errorCode = null; 107 try { 108 const error = await response.json(); 109 message = error.message || message; 110 errorCode = error.code || null; 111 } catch { /* ignore parse errors */ } 112 const err = new Error(message); 113 err.status = response.status; // callers check err.status, not the message text 114 err.code = errorCode; 115 const retryAfter = Number(response.headers.get('Retry-After')); 116 if (Number.isFinite(retryAfter) && retryAfter >
116 0) err.retryAfterMs = retryAfter * 1000; 117 throw err; 118 } 119 120 // 204 No Content has no body 121 if (response.status === 204) { 122 _touchOnlineAuth(); 123 // Mutation with no body still invalidates cache for related reads. 124 offlineInterceptor.cacheResponse(key, method, { _noContent: true }).catch(() => {}); 125 return null; 126 } 127 128 const data = await response.json(); 129 130 // Successful API call â keep the offline grace period sliding 131 _touchOnlineAuth(); 132 133 // Handle SB Result wrapper if present (Data/Success/Message) 134 let result; 135 if (data && data.hasOwnProperty('Data') && data.hasOwnProperty('Success')) { 136 if (!data.Success) throw new Error(data.Message || 'API Error'); 137 result = toCamelCase(data.Data); 138 } else { 139 result = toCamelCase(data); 140 } 141 142 // Post-fetch caching / invalidation. Fire-and-forget so a Dexie write 143 // problem doesn't fail the user-visible request. 144 offlineInterceptor.cacheResponse(key, method, result).catch(() => {}); 145 146 return result; 147 148 } catch (error) { 149 // Caller-owned teardown/navigation cancellation is expected control flow. 150 // Preserve it for the caller's lifecycle guard without invoking offline 151 // fallback or polluting the console with a fake API failure. 152 if (error?.name === 'AbortError' || fetchOptions.signal?.aborted) throw error; 153 if (offlineInterceptor.isNetworkError(error)) connectivity.noteFetchFailure(); 154 155 // Network error? Try the offline fallback path before giving up. 156 const fallback = await offlineInterceptor.handleNetworkError(key, method, options.body, error, { ...fetchOptions, networkOnly, skipOfflineQueue }); 157 if (fallback) { 158 // GET fallback returns { data, _offline, ... }; mutation fallback 159 // returns the queued envelope directly (already caller-ready). 160 return fallback.data !== undefined ? fallback.data : fallback; 161 } 162 163 console.error(`API request failed: ${endpoint}`, error); 164 throw error; 165 } 166 } 167 168 get(endpoint, options = {}) { 169 return this.request(endpoint, { ...options, method: 'GET' }); 170 } 171 post(endpoint, data, options = {}) { 172 return this.request(endpoint, { 173 ...options, 174 method: 'POST', 175 body: data 176 }); 177 } 178 put(endpoint, data, options = {}) { 179 return this.request(endpoint, { 180 ...options, 181 method: 'PUT', 182 body: data 183 }); 184 } 185 patch(endpoint, data, options = {}) { 186 return this.request(endpoint, { 187 ...options, 188 method: 'PATCH', 189 body: data 190 }); 191 } 192 delete(endpoint, options = {}) { 193 return this.request(endpoint, { ...options, method: 'DELETE' }); 194 } 195 196 /** 197 * GET that returns a Blob (for PDF downloads, etc.) 198 * Returns { blob, fileName } where fileName is extracted from Content-Disposition header. 199 * Accepts an AbortSignal so field-trip cancel aborts in-flight transfers. 200 * 201 * Not interceptor-cached â blob caching is per-feature (e.g., markups store 202 * PDF blobs inline in Dexie via offlineStore.saveMarkup). Generic blob caching 203 * would blow the quota fast, so callers opt in. 204 */ 205 async getBlob(endpoint, options = {}) { 206 if (!config.apiBaseUrl) await config.load(); 207 await connectivity.whenSettled(); 208 209 // Hard-fail blob GETs when offline â there's no generic cache for blobs, 210 // only feature-specific ones (markups etc.) which read from offlineStore 211 // directly before hitting getBlob. Fast fail > waiting on a doomed fetch. 212 if (!connectivity.isOnline) { 213 const err = new Error(`Not available offline: ${endpoint}`); 214 err.name = 'OfflineCacheMissError'; 215 err.endpoint = endpoint; 216 throw err; 217 } 218 219 const url = this.buildUrl(endpoint); 220 const headers = { 221 ...(config.apiKey ? { 'X-API-Key': config.apiKey } : {}), 222 ...(options.headers || {}) 223 }; 224 225 const token = auth.getToken(); 226 if (token) headers['Authorization'] = `Bearer ${token}`; 227 228 try { 229 const response = await fetch(url, { 230 method: 'GET', headers, credentials: 'same-origin', 231 signal: options.signal, 232 }); 233 connectivity.noteFetchSuccess(); 234 235 if (response.status === 401) { 236 await rejectExpiredAuth(options); 237 } 238 239 if (!response.ok) { 240 let message = `HTTP ${response.status}`; 241 try { 242 const errorText = await response.text(); 243 try { 244 const error = JSON.parse(errorText); 245 message = error.message || message; 246 } catch { message = errorText || message; } 247 } catch { /* ignore */ } 248 const err = new Error(message); 249 err.status = response.status; // callers check err.status, not the message text 250 throw err; 251 } 252 253 const blob = await response.blob(); 254 let fileName = 'download.pdf'; 255 const disposition = response.headers.get('Content-Disposition'); 256 if (disposition) { 257 const match = disposition.match(/filename[^;=\n]*=(?:(?:UTF-8'')|["']?)([^"';\n]+)/i); 258 if (match) fileName = decodeURIComponent(match[1].replace(/"/g, '')); 259 } 260 261 return { blob, fileName }; 262 } catch (error) { 263 if (offlineInterceptor.isNetworkError(error)) connectivity.noteFetchFailure(); 264 console.error(`API blob request failed: ${endpoint}`, error); 265 throw error; 266 } 267 } 268 269 /** 270 * POST with a JSON body that returns a Blob (for PDF downloads generated 271 * from a request payload). Returns { blob, fileName }. 272 */ 273 async postBlob(endpoint, body = {}, options = {}) { 274 if (!config.apiBaseUrl) await config.load(); 275 await connectivity.whenSettled(); 276 277 if (!connectivity.isOnline) { 278 const err = new Error(`Not available offline: ${endpoint}`); 279 err.name = 'OfflineCacheMissError'; 280 err.endpoint = endpoint; 281 throw err; 282 } 283 284 const url = this.buildUrl(endpoint); 285 const headers = { 286 'Content-Type': 'application/json', 287 ...(config.apiKey ? { 'X-API-Key': config.apiKey } : {}), 288 ...(options.headers || {}) 289 }; 290 291 const token = auth.getToken(); 292 if (token) headers['Authorization'] = `Bearer ${token}`; 293 294 try { 295 const response = await fetch(url, { 296 method: 'POST', headers, credentials: 'same-origin', 297 body: JSON.stringify(body), 298 signal: options.signal, 299 }); 300 connectivity.noteFetchSuccess(); 301 302 if (response.status === 401) { 303 await rejectExpiredAuth(options); 304 } 305 306 if (!response.ok) { 307 let message = `HTTP ${response.status}`; 308 try { 309 const errorText = await response.text(); 310 try { 311 const error = JSON.parse(errorText); 312 message = error.message || message; 313 } catch { message = errorText || message; } 314 } catch { /* ignore */ } 315 const err = new Error(message); 316 err.status = response.status; // callers check err.status, not the message text 317 throw err; 318 } 319 320 const blob = await response.blob(); 321 let fileName = 'download.pdf'; 322 const disposition = response.headers.get('Content-Disposition'); 323 if (disposition) { 324 const match = disposition.match(/filename[^;=\n]*=(?:(?:UTF-8'')|["']?)([^"';\n]+)/i); 325 if (match) fileName = decodeURIComponent(match[1].replace(/"/g, '')); 326 } 327 328 return { blob, fileName }; 329 } catch (error) { 330 if (offlineInterceptor.isNetworkError(error)) connectivity.noteFetchFailure(); 331 console.error(`API blob POST failed: ${endpoint}`, error); 332 throw error; 333 } 334 } 335 336 /** 337 * POST with FormData (for file uploads) 338 * Does NOT set Content-Type header - browser will set it with boundary 339 * 340 * FormData mutations are NOT interceptor-queueable â FormData (with File 341 * blobs) doesn't serialize into IndexedDB cleanly, and file uploads are 342 * usually too big to queue optimistically. Callers that need offline 343 * file queueing should handle it explicitly (e.g., markup-viewer pattern). 344 */
345 async postFormData(endpoint, formData, options = {}) { 346 if (!config.apiBaseUrl) await config.load(); 347 await connectivity.whenSettled(); 348 349 if (!connectivity.isOnline) { 350 const err = new Error(`Offline: file upload isn't available without a connection.`); 351 err.name = 'OfflineNotQueueableError'; 352 err.endpoint = endpoint; 353 throw err; 354 } 355 356 const url = this.buildUrl(endpoint); 357 358 const headers = { 359 'Accept': 'application/json', 360 // Don't set Content-Type - browser sets it with boundary for FormData 361 ...(config.apiKey ? { 'X-API-Key': config.apiKey } : {}), 362 ...(options.headers || {}) 363 }; 364 365 const token = auth.getToken(); 366 if (token) headers['Authorization'] = `Bearer ${token}`; 367 368 try { 369 const response = await fetch(url, { 370 method: 'POST', 371 headers, 372 body: formData, 373 credentials: 'same-origin', 374 cache: 'no-store', 375 signal: options.signal, 376 }); 377 connectivity.noteFetchSuccess(); 378 379 if (response.status === 401) { 380 await rejectExpiredAuth(options); 381 } 382 383 if (!response.ok) { 384 let message = `HTTP ${response.status}`; 385 let errorCode = null; 386 try { 387 const error = await response.json(); 388 message = error.message || message; 389 errorCode = error.code || null; 390 } catch { /* ignore parse errors */ } 391 const err = new Error(message); 392 err.status = response.status; // callers check err.status, not the message text 393 err.code = errorCode; 394 const retryAfter = Number(response.headers.get('Retry-After')); 395 if (Number.isFinite(retryAfter) && retryAfter > 0) err.retryAfterMs = retryAfter * 1000; 396 throw err; 397 } 398 399 if (response.status === 204) { 400 // Invalidate related cache so the next GET fetches fresh. 401 offlineInterceptor.cacheResponse(_cacheKey(endpoint), 'POST', { _noContent: true }).catch(() => {}); 402 return null; 403 } 404 405 const data = await response.json(); 406 407 // Unwrap ServiceResult envelope + camelCase transform (same as request()) 408 let result; 409 if (data && data.hasOwnProperty('Data') && data.hasOwnProperty('Success')) { 410 if (!data.Success) throw new Error(data.Message || 'API Error'); 411 result = toCamelCase(data.Data); 412 } else { 413 result = toCamelCase(data); 414 } 415 416 // Invalidate related GETs after successful upload 417 offlineInterceptor.cacheResponse(_cacheKey(endpoint), 'POST', result).catch(() => {}); 418 419 return result; 420 } catch (error) { 421 if (offlineInterceptor.isNetworkError(error)) connectivity.noteFetchFailure(); 422 console.error(`API FormData request failed: ${endpoint}`, error); 423 throw error; 424 } 425 } 426} 427 428// Keep the offline grace period sliding on successful API calls. 429// Uses dynamic import to avoid circular dependency with offline modules. 430let _touchThrottle = 0; 431function _touchOnlineAuth() { 432 const now = Date.now(); 433 if (now - _touchThrottle < 60_000) return; // At most once per minute 434 _touchThrottle = now; 435 import('../offline/offline-auth.js') 436 .then(({ offlineAuth }) => offlineAuth.touchOnlineAuth()) 437 .catch(() => {}); 438}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.