UNPKG

@flavoai/fastfold

Version:

Flavo frontend package

350 lines 15.9 kB
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