@flavoai/fastfold
Version:
Flavo frontend package
350 lines • 15.9 kB
JavaScript
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import React from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { forwardErrorToParent, configureFastfold } from './react-query';
import { initializeObservability, getObservabilityInstance } from './observability';
import { FlavoAuthProvider } from './auth';
import { DEFAULT_ALLOWED_PARENT_ORIGINS, isAllowedParentOrigin, postMessageToAllowedParents, setAllowedParentOrigins, } from './bridgeOrigins';
// Optional devtools import
let ReactQueryDevtools = null;
try {
ReactQueryDevtools = require('@tanstack/react-query-devtools').ReactQueryDevtools;
}
catch (e) {
// Devtools not installed, that's okay
}
/**
* DevTools Bridge Host Component
* Listens for requests from parent window and responds with Fastfold API data.
*
* Security: messages are only accepted from an allowlist of Flavo parent
* origins (hardcoded Flavo domains + localhost for dev). Responses are
* targeted at the specific requester's origin (no wildcard `*`), so a
* third-party page that iframes the deployed app can neither send commands
* nor receive responses.
*/
function DevToolsBridgeHost({ baseUrl, allowedParentOrigins = DEFAULT_ALLOWED_PARENT_ORIGINS, }) {
React.useEffect(() => {
// Only enable bridge if we're in an iframe
if (window.parent === window) {
return;
}
const handleRequest = async (event) => {
const data = event.data;
if (data?.type !== 'fastfold-request') {
return;
}
// Reject requests from non-Flavo parents. Silent drop — attackers
// shouldn't get signal that the bridge exists.
if (!isAllowedParentOrigin(event.origin, allowedParentOrigins)) {
return;
}
const { requestId, action, payload } = data;
const replyOrigin = event.origin;
const sendResponse = (response) => {
window.parent.postMessage({
type: 'fastfold-response',
requestId,
...response,
}, replyOrigin);
};
try {
const headers = {
'Content-Type': 'application/json',
};
let result;
switch (action) {
case 'getTables': {
const res = await fetch(`${baseUrl}/studio/api/tables`, { headers });
const json = await res.json();
result = json.data || json;
break;
}
case 'getRelationships': {
const res = await fetch(`${baseUrl}/studio/api/relationships`, { headers });
const json = await res.json();
result = json.data || json;
break;
}
case 'getMetadata': {
const res = await fetch(`${baseUrl}/studio/api/metadata`, { headers });
const json = await res.json();
result = json.data || json;
break;
}
case 'query': {
const { table, params } = payload || {};
if (!table)
throw new Error('Table name required');
const queryString = params
? `?params=${encodeURIComponent(JSON.stringify(params))}`
: '';
const res = await fetch(`${baseUrl}/api/${table}${queryString}`, { headers });
const json = await res.json();
result = json.data || json;
break;
}
case 'queryOne': {
const { table, id, params } = payload || {};
if (!table || id === undefined)
throw new Error('Table and ID required');
const queryString = params
? `?params=${encodeURIComponent(JSON.stringify(params))}`
: '';
const res = await fetch(`${baseUrl}/api/${table}/${id}${queryString}`, { headers });
const json = await res.json();
result = json.data || json;
break;
}
case 'create': {
const { table, data: createData } = payload || {};
if (!table)
throw new Error('Table name required');
const res = await fetch(`${baseUrl}/api/${table}`, {
method: 'POST',
headers,
body: JSON.stringify(createData || {}),
});
const json = await res.json();
if (!res.ok)
throw new Error(json.error || 'Create failed');
result = json.data || json;
break;
}
case 'update': {
const { table, id, data: updateData } = payload || {};
if (!table || id === undefined)
throw new Error('Table and ID required');
const res = await fetch(`${baseUrl}/api/${table}/${id}`, {
method: 'PUT',
headers,
body: JSON.stringify(updateData || {}),
});
const json = await res.json();
if (!res.ok)
throw new Error(json.error || 'Update failed');
result = json.data || json;
break;
}
case 'delete': {
const { table, id } = payload || {};
if (!table || id === undefined)
throw new Error('Table and ID required');
const res = await fetch(`${baseUrl}/api/${table}/${id}`, {
method: 'DELETE',
headers,
});
const json = await res.json();
if (!res.ok)
throw new Error(json.error || 'Delete failed');
result = json.data || { success: true };
break;
}
default:
throw new Error(`Unknown action: ${action}`);
}
sendResponse({ success: true, data: result });
}
catch (error) {
console.error('[DevToolsBridge] Error handling request:', error);
sendResponse({
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
});
}
};
window.addEventListener('message', handleRequest);
// Notify parent that bridge is ready. We don't know which specific
// allowed origin embedded us, so broadcast to each allowlisted
// origin — browsers drop postMessages whose target origin doesn't
// match the actual parent, so only the real Flavo parent receives
// it. No wildcard.
postMessageToAllowedParents({ type: 'fastfold-bridge-ready' }, allowedParentOrigins);
return () => {
window.removeEventListener('message', handleRequest);
};
}, [baseUrl, allowedParentOrigins]);
return null;
}
class FastfoldErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
componentDidCatch(error, errorInfo) {
// Forward React error to parent window (for DevTools)
forwardErrorToParent('react', error, {
componentStack: errorInfo.componentStack,
errorBoundary: 'FastfoldErrorBoundary'
});
// Also track via observability system
const obs = getObservabilityInstance();
if (obs) {
const componentName = this.extractComponentName(errorInfo.componentStack);
obs.trackError(error, {
source: 'frontend',
component: componentName || undefined,
});
}
this.setState({ error, errorInfo });
}
extractComponentName(componentStack) {
if (!componentStack)
return undefined;
// Extract first component name from stack
const match = componentStack.match(/^\s*at\s+(\w+)/);
return match ? match[1] : undefined;
}
render() {
if (this.state.hasError) {
// Fallback UI - you can customize this
return (_jsxs("div", { style: {
padding: '20px',
border: '1px solid #ff6b6b',
borderRadius: '4px',
backgroundColor: '#fff5f5',
color: '#c92a2a'
}, children: [_jsx("h2", { children: "\u26A0\uFE0F Something went wrong" }), _jsx("p", { children: "An error occurred in the React component tree." }), _jsxs("details", { style: { marginTop: '10px' }, children: [_jsx("summary", { children: "Error details" }), _jsx("pre", { style: { marginTop: '10px', fontSize: '12px', overflow: 'auto' }, children: this.state.error?.stack })] })] }));
}
return this.props.children;
}
}
// ============================================================================
// FASTFOLD QUERY CLIENT
// ============================================================================
/**
* Create a pre-configured QueryClient optimized for Fastfold
*/
export function createFastfoldQueryClient(options = {}) {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: options.staleTime ?? 5 * 60 * 1000, // 5 minutes
gcTime: options.gcTime ?? 10 * 60 * 1000, // 10 minutes
refetchOnWindowFocus: options.refetchOnWindowFocus ?? false,
retry: options.retry ?? 3,
retryDelay: options.retryDelay ?? ((attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000)),
},
mutations: {
retry: options.retry ?? 1,
retryDelay: options.retryDelay ?? 1000,
},
},
});
}
/**
* 🚀 FASTFOLD PROVIDER - Wrap your app with this to enable Fastfold hooks
*
* @example
* function App() {
* return (
* <FastfoldProvider>
* <YourApp />
* </FastfoldProvider>
* );
* }
*
* @example With custom configuration and devtools
* const queryClient = createFastfoldQueryClient({
* staleTime: 10 * 60 * 1000, // 10 minutes
* refetchOnWindowFocus: true
* });
*
* function App() {
* return (
* <FastfoldProvider
* queryClient={queryClient}
* showDevtools={true} // Opt-in to show devtools
* >
* <YourApp />
* </FastfoldProvider>
* );
* }
*/
export function FastfoldProvider({ children, queryClient, showDevtools = false, // Disabled by default
devtoolsOptions = {}, enableBridge, allowedParentOrigins, baseUrl, observability: observabilityConfig, flavoAuth, }) {
const client = queryClient || createFastfoldQueryClient();
// Publish the allowed-origins allowlist to the module-level helpers so
// that error forwarders (forwardErrorToParent, global error handlers)
// also target them and ignore third-party parents.
React.useEffect(() => {
if (allowedParentOrigins && allowedParentOrigins.length > 0) {
setAllowedParentOrigins(allowedParentOrigins);
}
}, [allowedParentOrigins]);
// Auto-detect if we're in an iframe (enable bridge by default when embedded)
const isInIframe = typeof window !== 'undefined' && window.parent !== window;
const shouldEnableBridge = enableBridge ?? isInIframe;
// Determine base URL for API calls
const apiBaseUrl = React.useMemo(() => {
if (baseUrl)
return baseUrl;
if (typeof window === 'undefined')
return '';
// Default to current origin (works for same-origin iframes)
return window.location.origin;
}, [baseUrl]);
// Configure Fastfold client with base URL
React.useEffect(() => {
if (apiBaseUrl) {
configureFastfold({ baseUrl: `${apiBaseUrl}/api` });
}
}, [apiBaseUrl]);
// Initialize observability if configured
React.useEffect(() => {
if (observabilityConfig && observabilityConfig.appId && observabilityConfig.endpoint) {
const config = {
...observabilityConfig,
enabled: observabilityConfig.enabled !== false, // Default to true if config provided
appId: observabilityConfig.appId,
endpoint: observabilityConfig.endpoint,
};
const instance = initializeObservability(config);
// Cleanup on unmount
return () => {
instance.destroy();
};
}
}, [observabilityConfig]);
// Set up global JavaScript error handlers
React.useEffect(() => {
const handleError = (event) => {
forwardErrorToParent('js', {
name: 'JavaScriptError',
message: event.message,
stack: `${event.filename}:${event.lineno}:${event.colno}`,
filename: event.filename,
lineno: event.lineno,
colno: event.colno
});
};
const handleUnhandledRejection = (event) => {
forwardErrorToParent('js', {
name: 'UnhandledPromiseRejection',
message: event.reason?.message || 'Unhandled promise rejection',
stack: event.reason?.stack,
reason: event.reason
});
};
// Add global error listeners
window.addEventListener('error', handleError);
window.addEventListener('unhandledrejection', handleUnhandledRejection);
// Cleanup listeners on unmount
return () => {
window.removeEventListener('error', handleError);
window.removeEventListener('unhandledrejection', handleUnhandledRejection);
};
}, []);
const content = flavoAuth?.enabled
? _jsx(FlavoAuthProvider, { config: flavoAuth, children: children })
: children;
return (_jsx(FastfoldErrorBoundary, { children: _jsxs(QueryClientProvider, { client: client, children: [content, shouldEnableBridge && (_jsx(DevToolsBridgeHost, { baseUrl: apiBaseUrl, allowedParentOrigins: allowedParentOrigins })), showDevtools && ReactQueryDevtools && (_jsx(ReactQueryDevtools, { initialIsOpen: devtoolsOptions.initialIsOpen ?? false, position: devtoolsOptions.position ?? 'bottom-right' })), showDevtools && !ReactQueryDevtools && (console.warn('FastfoldProvider: showDevtools=true but @tanstack/react-query-devtools is not installed. Install it with: npm install @tanstack/react-query-devtools'))] }) }));
}
// ============================================================================
// HOOKS FOR QUERY CLIENT ACCESS
// ============================================================================
export { useQueryClient } from '@tanstack/react-query';
//# sourceMappingURL=provider.js.map