UNPKG

@flavoai/fastfold

Version:

Flavo frontend package

659 lines 27.3 kB
import { useQuery as useReactQuery, useMutation as useReactMutation, useQueryClient } from '@tanstack/react-query'; import { observability } from './observability'; import { postMessageToAllowedParents } from './bridgeOrigins'; let clientConfig = { baseUrl: '/api', logging: { enabled: false, logEndpoint: '/internal-logs' } }; /** * Configure the Fastfold client */ export function configureFastfold(config) { clientConfig = { ...clientConfig, ...config }; } // ============================================================================ // ERROR FORWARDING UTILITIES // ============================================================================ /** * Forward errors to parent window for development debugging */ export function forwardErrorToParent(category, error, errorInfo) { try { if (window.parent && window.parent !== window) { // Create flattened message structure for better accessibility const baseMessage = { type: 'error', category, timestamp: Date.now(), url: window.location.href, // Core error details at top level for easy access errorName: error.name, errorMessage: error.message, stackTrace: error.stack, // Detailed network info (flattened to top level for network errors) ...(category === 'network' && errorInfo ? { networkErrorType: errorInfo.type, httpStatus: errorInfo.status, httpStatusText: errorInfo.statusText, requestMethod: errorInfo.method, requestUrl: errorInfo.url, requestData: errorInfo.requestData, requestHeaders: errorInfo.requestHeaders, responsePayload: errorInfo.responsePayload, responseText: errorInfo.responseText, responseHeaders: errorInfo.responseHeaders, contentType: errorInfo.contentType, apiError: errorInfo.apiError, apiResponse: errorInfo.apiResponse, possibleCauses: errorInfo.possibleCauses, errorType: errorInfo.errorType, note: errorInfo.note, baseUrl: errorInfo.baseUrl, requestPath: errorInfo.requestPath, errorDetails: errorInfo.errorDetails, troubleshooting: errorInfo.troubleshooting } : {}), // React-specific fields componentStack: errorInfo?.componentStack || 'No component stack available.' }; // Error payloads can include request URLs, bodies, and headers — // send only to Flavo parent origins, never to a wildcard. A // malicious page that iframed the app would otherwise receive // the full forwarded error and could exfiltrate tokens or // backend-shape information. postMessageToAllowedParents(baseMessage); } } catch (e) { // Silently fail if postMessage doesn't work console.warn('Failed to forward error to parent:', e); } } // ============================================================================ // SINGLE-FLIGHT REFRESH (P1-2) // ============================================================================ // // On any 401 while in cookie mode, we hit `/api/auth/refresh` once and // replay the original request. If multiple requests 401 simultaneously // (common on page load as stale access tokens hit several endpoints at // once), they all wait on the same in-flight refresh promise. // // On refresh failure, we dispatch a `flavo:auth-expired` CustomEvent on // `window` so the app can route to login. The original 401 is surfaced to // the caller. let refreshInflight = null; function dispatchAuthExpired(detail) { if (typeof window === 'undefined') return; try { window.dispatchEvent(new CustomEvent('flavo:auth-expired', { detail })); } catch { // Older browsers / SSR — no-op. } } async function runRefresh() { try { const response = await fetch(`${clientConfig.baseUrl}/auth/refresh`, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'fetch', }, }); return response.ok; } catch { return false; } } async function refreshSession() { if (refreshInflight) return refreshInflight; refreshInflight = runRefresh(); try { return await refreshInflight; } finally { // Clear the slot immediately; next 401 gets a fresh attempt. refreshInflight = null; } } // ============================================================================ // HTTP CLIENT // ============================================================================ class HttpClient { /** * Log outgoing request to /internal-logs endpoint */ async logRequest(method, url, headers, body) { if (!clientConfig.logging?.enabled) return; const logEndpoint = clientConfig.logging.logEndpoint || '/internal-logs'; const fullLogUrl = `${clientConfig.baseUrl.replace('/api', '')}${logEndpoint}`; try { // Don't log the /internal-logs endpoint itself to avoid infinite loop if (url.includes('/internal-logs')) return; // Send log data to backend (fire and forget, don't await to avoid blocking) fetch(fullLogUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ method, url, headers: { ...headers, // Redact authorization header for security 'authorization': headers['Authorization'] ? 'Bearer ***' : undefined }, body, timestamp: new Date().toISOString() }) }).catch(() => { // Silently fail if logging endpoint is not available }); } catch (error) { // Silently fail - logging should not break the app } } async request(method, url, data, isRetry = false) { const fullUrl = `${clientConfig.baseUrl}${url}`; // Build headers including observability session/visitor IDs. P1-14 // requires `X-Requested-With` on cookie-authed writes; we send it on // every request because it's harmless for GETs. const headers = { 'Content-Type': 'application/json', 'X-Requested-With': 'fetch', ...clientConfig.headers, }; // Add observability headers if available if (observability.sessionId) { headers['x-session-id'] = observability.sessionId; } if (observability.visitorId) { headers['x-visitor-id'] = observability.visitorId; } const options = { method, headers, // P1-3: send the httpOnly session cookie with every request. credentials: 'include', }; try { if (data && method !== 'GET') { options.body = JSON.stringify(data); } // Log the outgoing request this.logRequest(method, fullUrl, options.headers, data); const response = await fetch(fullUrl, options); // P1-2: 401 in cookie mode → try a single-flight refresh + replay // the original request exactly once. On replay failure, fall // through to the normal error handling below. if (response.status === 401 && !isRetry && !url.startsWith('/auth/')) { const refreshed = await refreshSession(); if (refreshed) { return this.request(method, url, data, true); } dispatchAuthExpired({ endpoint: url }); } if (!response.ok) { const errorText = await response.text(); let errorPayload = errorText; // Try to parse JSON error response try { errorPayload = JSON.parse(errorText); } catch (e) { // Keep as text if not valid JSON } // Extract response headers for debugging const responseHeaders = {}; response.headers.forEach((value, key) => { responseHeaders[key] = value; }); const error = new Error(`HTTP ${response.status}: ${errorText}`); // Forward detailed network error to parent window forwardErrorToParent('network', error, { type: 'http_error', method, url: fullUrl, requestHeaders: options.headers, requestData: data, status: response.status, statusText: response.statusText, responseHeaders, responsePayload: errorPayload, responseText: errorText, contentType: response.headers.get('content-type') || 'unknown' }); // Track error via observability observability.trackError(error, { source: 'backend', endpoint: url, status_code: response.status, }); throw error; } const result = await response.json(); if (!result.success) { const error = new Error(result.error || 'Request failed'); // Forward detailed API error to parent window forwardErrorToParent('network', error, { type: 'api_error', method, url: fullUrl, requestHeaders: options.headers, requestData: data, status: response.status, statusText: response.statusText, apiResponse: result, apiError: result.error }); // Track error via observability observability.trackError(error, { source: 'backend', endpoint: url, status_code: response.status, }); throw error; } return result.data; } catch (error) { // Forward any other network-related errors (connection failures, timeouts, etc.) if (error instanceof TypeError && error.message.includes('fetch')) { forwardErrorToParent('network', error, { type: 'fetch_error', method, url: fullUrl, baseUrl: clientConfig.baseUrl, requestPath: url, requestHeaders: options.headers, requestData: data, errorType: 'NetworkConnectionError', errorMessage: error.message, errorDetails: 'Network request failed - this is typically a connectivity issue', possibleCauses: [ 'Network connection lost', 'Server not responding', 'Request timeout', 'CORS policy error', 'DNS resolution failed', 'Server completely down', 'Firewall blocking request' ], troubleshooting: [ 'Check network connectivity', 'Verify server is running', 'Check for CORS configuration', 'Inspect browser network tab for details' ] }); } else if (error instanceof SyntaxError && error.message.includes('JSON')) { // JSON parsing error for successful responses forwardErrorToParent('network', error, { type: 'json_parse_error', method, url: fullUrl, baseUrl: clientConfig.baseUrl, requestPath: url, requestHeaders: options.headers, requestData: data, errorType: 'JSONParseError', errorMessage: error.message, note: 'Server returned invalid JSON response', troubleshooting: [ 'Check server response format', 'Verify Content-Type header', 'Inspect raw response in network tab' ] }); } else { // Any other error type forwardErrorToParent('network', error, { type: 'unknown_error', method, url: fullUrl, baseUrl: clientConfig.baseUrl, requestPath: url, requestHeaders: options.headers, requestData: data, errorType: error instanceof Error ? error.constructor.name : 'UnknownError', errorMessage: error instanceof Error ? error.message : String(error), note: 'Unexpected error during network request' }); } throw error; } } async get(url) { return this.request('GET', url); } async post(url, data) { return this.request('POST', url, data); } async put(url, data) { return this.request('PUT', url, data); } async delete(url) { return this.request('DELETE', url); } } const httpClient = new HttpClient(); // ============================================================================ // QUERY KEY FACTORIES // ============================================================================ /** * Generate consistent query keys for React Query */ export const queryKeys = { all: (tableName) => [tableName], lists: (tableName) => [tableName, 'list'], list: (tableName, params) => [tableName, 'list', params], details: (tableName) => [tableName, 'detail'], detail: (tableName, id) => [tableName, 'detail', id], }; /** * 📋 QUERY HOOK - Fetch multiple records with React Query * * @param tableName The table to query * @param params Query parameters (where, orderBy, limit, with, etc.) * @param options React Query options * * @example * const { data: posts, isLoading, error } = useQuery('posts', { * where: { published: true }, * with: { author: true } * }); */ export function useFastfoldQuery(tableName, params = {}, options = {}) { return useReactQuery({ queryKey: queryKeys.list(tableName, params), queryFn: async () => { const queryString = new URLSearchParams(); if (Object.keys(params).length > 0) { queryString.append('params', JSON.stringify(params)); } const url = `/${tableName}${queryString.toString() ? '?' + queryString.toString() : ''}`; return httpClient.get(url); }, staleTime: options.staleTime ?? 5 * 60 * 1000, // 5 minutes gcTime: options.cacheTime ?? 10 * 60 * 1000, // 10 minutes refetchOnWindowFocus: options.refetchOnWindowFocus ?? false, refetchInterval: options.refetchInterval, enabled: options.enabled, }); } /** * 🎯 QUERY ONE HOOK - Fetch single record by ID with React Query * * @param tableName The table to query * @param id The record ID * @param params Additional query parameters (with, etc.) * @param options React Query options * * @example * const { data: post } = useQueryOne('posts', '123', { * with: { author: true, comments: true } * }); */ export function useFastfoldQueryOne(tableName, id, params = {}, options = {}) { return useReactQuery({ queryKey: queryKeys.detail(tableName, id), queryFn: async () => { const queryString = new URLSearchParams(); if (Object.keys(params).length > 0) { queryString.append('params', JSON.stringify(params)); } const url = `/${tableName}/${id}${queryString.toString() ? '?' + queryString.toString() : ''}`; return httpClient.get(url); }, staleTime: options.staleTime ?? 5 * 60 * 1000, gcTime: options.cacheTime ?? 10 * 60 * 1000, refetchOnWindowFocus: options.refetchOnWindowFocus ?? false, refetchInterval: options.refetchInterval, enabled: options.enabled && !!id, }); } /** * ✏️ CREATE HOOK - Create new records with automatic cache invalidation * * @param tableName The table to create records in * @param options Mutation options * * @example * const createPost = useCreate('posts', { * onSuccess: (newPost) => console.log('Created:', newPost) * }); * * await createPost.mutateAsync({ title: 'Hello', content: 'World' }); */ export function useFastfoldCreate(tableName, options = {}) { const queryClient = useQueryClient(); return useReactMutation({ mutationFn: async (variables) => { // Validate data before sending if (!variables || (typeof variables === 'object' && Object.keys(variables).length === 0)) { throw new Error(`Cannot create ${tableName} record with empty data. Provide at least one field.`); } return httpClient.post(`/${tableName}`, variables); }, onSuccess: (data, variables) => { // Automatic cache invalidation if (options.invalidateQueries !== false) { queryClient.invalidateQueries({ queryKey: queryKeys.lists(tableName) }); } // Custom cache update if (options.updateCache) { options.updateCache(data, variables, queryClient); } // User callback options.onSuccess?.(data, variables); }, onError: options.onError, onSettled: options.onSettled, }); } /** * 🔄 UPDATE HOOK - Update existing records with automatic cache invalidation * * @param tableName The table to update records in * @param options Mutation options * * @example * const updatePost = useUpdate('posts', { * onSuccess: (updatedPost) => console.log('Updated:', updatedPost) * }); * * await updatePost.mutateAsync({ id: '123', data: { title: 'New Title' } }); */ export function useFastfoldUpdate(tableName, options = {}) { const queryClient = useQueryClient(); const optimistic = options.optimistic !== false; return useReactMutation({ mutationFn: async (variables) => { // Validate data before sending if (!variables.data || (typeof variables.data === 'object' && Object.keys(variables.data).length === 0)) { throw new Error(`Cannot update ${tableName} record with empty data. ` + `Use format: updateItem.mutate({ id: ${variables.id}, data: { field: value } })`); } return httpClient.put(`/${tableName}/${variables.id}`, variables.data); }, onMutate: optimistic ? async (variables) => { await queryClient.cancelQueries({ queryKey: queryKeys.lists(tableName) }); await queryClient.cancelQueries({ queryKey: queryKeys.details(tableName) }); const listSnapshots = queryClient.getQueriesData({ queryKey: queryKeys.lists(tableName) }); const detailSnapshot = queryClient.getQueryData(queryKeys.detail(tableName, variables.id)); queryClient.setQueriesData({ queryKey: queryKeys.lists(tableName) }, (old) => old?.map((item) => item?.id === variables.id ? { ...item, ...variables.data } : item) ?? old); queryClient.setQueryData(queryKeys.detail(tableName, variables.id), (prev) => prev ? { ...prev, ...variables.data } : variables.data); return { listSnapshots, detailSnapshot }; } : undefined, onError: optimistic ? (err, variables, context) => { if (context?.listSnapshots) { context.listSnapshots.forEach(([queryKey, data]) => { queryClient.setQueryData(queryKey, data); }); } if (context?.detailSnapshot !== undefined) { queryClient.setQueryData(queryKeys.detail(tableName, variables.id), context.detailSnapshot); } options.onError?.(err, variables); } : options.onError, onSuccess: (data, variables) => { if (options.invalidateQueries !== false) { queryClient.invalidateQueries({ queryKey: queryKeys.lists(tableName) }); queryClient.invalidateQueries({ queryKey: queryKeys.detail(tableName, variables.id) }); } if (options.updateCache) { options.updateCache(data, variables, queryClient); } options.onSuccess?.(data, variables); }, onSettled: options.onSettled, }); } /** * 🗑️ DELETE HOOK - Delete records with automatic cache invalidation * * @param tableName The table to delete records from * @param options Mutation options * * @example * const deletePost = useDelete('posts', { * onSuccess: () => console.log('Deleted successfully') * }); * * await deletePost.mutateAsync('123'); */ export function useFastfoldDelete(tableName, options = {}) { const queryClient = useQueryClient(); const optimistic = options.optimistic !== false; return useReactMutation({ mutationFn: async (id) => { return httpClient.delete(`/${tableName}/${id}`); }, onMutate: optimistic ? async (id) => { await queryClient.cancelQueries({ queryKey: queryKeys.lists(tableName) }); await queryClient.cancelQueries({ queryKey: queryKeys.details(tableName) }); const listSnapshots = queryClient.getQueriesData({ queryKey: queryKeys.lists(tableName) }); const detailSnapshot = queryClient.getQueryData(queryKeys.detail(tableName, id)); queryClient.setQueriesData({ queryKey: queryKeys.lists(tableName) }, (old) => (old?.filter((item) => item?.id !== id) ?? old)); queryClient.removeQueries({ queryKey: queryKeys.detail(tableName, id) }); return { listSnapshots, detailSnapshot }; } : undefined, onError: optimistic ? (err, id, context) => { if (context?.listSnapshots) { context.listSnapshots.forEach(([queryKey, data]) => { queryClient.setQueryData(queryKey, data); }); } if (context?.detailSnapshot !== undefined) { queryClient.setQueryData(queryKeys.detail(tableName, id), context.detailSnapshot); } options.onError?.(err, id); } : options.onError, onSuccess: (data, variables) => { if (options.invalidateQueries !== false) { queryClient.invalidateQueries({ queryKey: queryKeys.lists(tableName) }); queryClient.invalidateQueries({ queryKey: queryKeys.detail(tableName, variables) }); } if (options.updateCache) { options.updateCache(data, variables, queryClient); } options.onSuccess?.(data, variables); }, onSettled: options.onSettled, }); } // ============================================================================ // CONVENIENCE ALIASES (for backward compatibility) // ============================================================================ export const useQuery = useFastfoldQuery; export const useQueryOne = useFastfoldQueryOne; export const useCreate = useFastfoldCreate; export const useUpdate = useFastfoldUpdate; export const useDelete = useFastfoldDelete; // ============================================================================ // CACHE UTILITIES // ============================================================================ /** * 🔄 INVALIDATE CACHE - Manually invalidate queries * * @example * const invalidate = useInvalidateCache(); * invalidate.table('posts'); // Invalidate all posts queries * invalidate.record('posts', '123'); // Invalidate specific post */ export function useInvalidateCache() { const queryClient = useQueryClient(); return { table: (tableName) => { queryClient.invalidateQueries({ queryKey: queryKeys.all(tableName) }); }, lists: (tableName) => { queryClient.invalidateQueries({ queryKey: queryKeys.lists(tableName) }); }, record: (tableName, id) => { queryClient.invalidateQueries({ queryKey: queryKeys.detail(tableName, id) }); }, all: () => { queryClient.invalidateQueries(); } }; } /** * 📝 UPDATE CACHE - Manually update cached data * * @example * const updateCache = useUpdateCache(); * updateCache.record('posts', '123', updatedPost); * updateCache.addToList('posts', {}, newPost); */ export function useUpdateCache() { const queryClient = useQueryClient(); return { record: (tableName, id, data) => { queryClient.setQueryData(queryKeys.detail(tableName, id), data); }, addToList: (tableName, params, newItem) => { queryClient.setQueryData(queryKeys.list(tableName, params), (old) => { return old ? [newItem, ...old] : [newItem]; }); }, removeFromList: (tableName, params, id) => { queryClient.setQueryData(queryKeys.list(tableName, params), (old) => { return old ? old.filter((item) => item.id !== id) : []; }); }, updateInList: (tableName, params, id, data) => { queryClient.setQueryData(queryKeys.list(tableName, params), (old) => { return old ? old.map((item) => item.id === id ? { ...item, ...data } : item) : []; }); } }; } // Export provider export { FastfoldProvider, createFastfoldQueryClient } from './provider'; // Export all types export * from '../types'; //# sourceMappingURL=react-query.js.map