PageSourceSearch

https://smrtbld.ai/js/api/base.js

js smrtbld.ai collected 2026-10-05 15:08:45 UTC 17,593 bytes, 438 lines download raw bytes

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.