@flavoai/fastfold
Version:
Flavo frontend package
659 lines • 27.3 kB
JavaScript
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