UNPKG

@pkme/widget-bridge

Version:

TypeScript React bridge client for Secret City Games widget integration across Framer, Figma, and web platforms

730 lines (595 loc) 23.4 kB
/** * @pkme/widget-bridge - Framer Standalone Bundle (ESM) * * Self-contained module with all dependencies included. * Optimized for CDN delivery and browser compatibility. * * Usage in Framer: * import { useBridgeData } from "https://unpkg.com/@pkme/widget-bridge@1.0.4/dist/framer.esm.js" */ // React imports (must be provided by environment) import { useState, useEffect, useCallback, useRef } from 'react'; /** * Secret City Games Bridge Core * Auto-generated from injector.ts */ (function() { 'use strict'; console.log('[Bridge] Initializing Secret City Games bridge...'); // Bridge client implementation function createPostBridge(config) { config = config || {}; // Check if we're inside a React Native WebView var isReactNative = typeof window !== 'undefined' && typeof window.ReactNativeWebView !== 'undefined'; var targetWindow = isReactNative ? undefined : (config.targetWindow || (typeof window !== 'undefined' ? window.parent : undefined)); var targetOrigin = config.targetOrigin || '*'; var debug = !!config.debug; // Handler storage var handlers = {}; var globalHandlers = []; // Loop prevention var localChangeIds = new Set(); // Debug logging utility function log() { if (debug) { var args = Array.prototype.slice.call(arguments); args.unshift('[Bridge]'); console.log.apply(console, args); } } // Generate unique change ID function generateChangeId() { return Date.now() + '-' + Math.random().toString(36).substring(2, 11); } // Check if change is local function isLocalChange(changeId) { if (!changeId) return false; var isLocal = localChangeIds.has(changeId); if (isLocal) { localChangeIds.delete(changeId); } return isLocal; } // Register a handler for a specific message type function on(type, handler) { if (!handlers[type]) handlers[type] = []; handlers[type].push(handler); log('Added handler for', type); return function() { if (handlers[type]) { var index = handlers[type].indexOf(handler); if (index > -1) handlers[type].splice(index, 1); if (handlers[type].length === 0) delete handlers[type]; } }; } // Register a handler for all messages function onAny(handler) { globalHandlers.push(handler); return function() { var index = globalHandlers.indexOf(handler); if (index > -1) globalHandlers.splice(index, 1); }; } // Dispatch a message to all relevant handlers function dispatch(msg) { log('Dispatching', msg); // Simple loop prevention - only check for local changes if (msg.type === 'data/globalUpdated' || msg.type === 'data/itemUpdated') { if (isLocalChange(msg._changeId)) { log('Skipping local change via changeId:', msg._changeId); return; } } // Call type-specific handlers if (msg.type && handlers[msg.type]) { handlers[msg.type].forEach(function(h) { try { h(msg); } catch (e) { console.error('Error in handler:', e); } }); } // Call global handlers globalHandlers.forEach(function(h) { try { h(msg); } catch (e) { console.error('Error in global handler:', e); } }); } // Send a message to the parent (React Native or parent window) function send(type, payload, extra) { var changeId = generateChangeId(); localChangeIds.add(changeId); var message = { type: type, payload: payload, _t: Date.now(), _changeId: changeId }; // Merge extra properties if (extra && typeof extra === 'object') { Object.keys(extra).forEach(function(key) { message[key] = extra[key]; }); } log('Sending', message); try { if (isReactNative) { // Must stringify for ReactNativeWebView window.ReactNativeWebView.postMessage(JSON.stringify(message)); } else if (targetWindow && targetWindow !== window) { targetWindow.postMessage(message, targetOrigin); } else { console.warn('[Bridge] No target available for message'); return Promise.reject(new Error('No target available')); } return Promise.resolve(); } catch (e) { console.error('Error posting message:', e); return Promise.reject(e); } } // Handle incoming messages from parent function handleMessage(event) { // Basic validation of message if (!event.data || typeof event.data !== 'object') return; var msg = event.data; log('Received', msg); // Dispatch to handlers dispatch(msg); } // Register message handler if (typeof window !== 'undefined') { window.addEventListener('message', handleMessage); } log('Bridge initialized, isReactNative:', isReactNative); // Return the bridge instance return { // Data operations setGlobal: function(data) { log('setGlobal', data); // Validate data is flat var validation = validateFlatData(data); if (!validation.isFlat) { alertNestedData(validation.nestedKeys, 'setGlobal()'); return Promise.reject(new Error('Nested data structures are not allowed. Found nested keys: ' + validation.nestedKeys.join(', '))); } return send('data/saveGlobal', { data: data }, { scope: 'global' }); }, setItem: function(data) { var itemId = context && context.viewId ? context.viewId : 'default-card'; log('setItem', itemId, data); // Validate data is flat var validation = validateFlatData(data); if (!validation.isFlat) { alertNestedData(validation.nestedKeys, 'setItem()'); return Promise.reject(new Error('Nested data structures are not allowed. Found nested keys: ' + validation.nestedKeys.join(', '))); } return send('data/saveItem', { data: data }, { scope: 'item', key: itemId }); }, setItemById: function(itemId, data) { log('setItemById', itemId, data); // Validate data is flat var validation = validateFlatData(data); if (!validation.isFlat) { alertNestedData(validation.nestedKeys, 'setItemById()'); return Promise.reject(new Error('Nested data structures are not allowed. Found nested keys: ' + validation.nestedKeys.join(', '))); } return send('data/saveItem', { data: data }, { scope: 'item', key: itemId }); }, subscribeGlobal: function(handler) { log('subscribeGlobal registered'); var off = on('data/globalUpdated', function(msg) { if (msg.data) { handler(msg.data); } }); send('data/subscribe', { scope: 'global' }).catch(function(err) { console.warn('[Bridge] Failed to send data/subscribe for global:', err); }); return off; }, subscribeItem: function(handler) { var itemId = context && context.viewId ? context.viewId : 'default-card'; return this.subscribeItemById(itemId, handler); }, subscribeItemById: function(itemId, handler) { log('subscribeItemById registered for:', itemId); var itemHandler = function(msg) { if (msg.data && (msg.data._itemId === itemId || !msg.data._itemId)) { handler(msg.data); } }; var off1 = on('data/itemUpdated', itemHandler); var off2 = on('data/itemDeleted', itemHandler); send('data/subscribe', { scope: 'item', key: itemId }).catch(function(err) { console.warn('[Bridge] Failed to send data/subscribe for item:', err); }); return function() { off1(); off2(); send('data/unsubscribe', { scope: 'item', key: itemId }).catch(function() {}); }; }, // Navigation methods navigate: function(to, params, method) { method = method || 'push'; log('navigate', to, params, method); return send('navigation/navigate', { to: to, params: params, method: method }, { scope: 'screen' }); }, back: function(steps) { steps = steps || 1; log('back', steps); return send('navigation/back', { steps: steps }, { scope: 'screen' }); }, complete: function(data) { log('complete', data); var completionData = { completed: true, completedAt: Date.now() }; if (data && typeof data === 'object') { // Validate input data is flat before processing var validation = validateFlatData(data); if (!validation.isFlat) { alertNestedData(validation.nestedKeys, 'complete()'); return Promise.reject(new Error('Nested data structures are not allowed in completion data. Found nested keys: ' + validation.nestedKeys.join(', '))); } Object.keys(data).forEach(function(key) { completionData[key] = data[key]; }); } // Save to current item (this will trigger validation again) this.setItem(completionData); // Update global data with completion tracking (using flat structure) var globalUpdate = { lastCompletedCardId: this.context ? this.context.viewId : 'unknown', lastCompletedAt: completionData.completedAt, lastCompletedScore: completionData.finalScore || null, lastCompletedTimeSpent: completionData.timeSpent || null }; this.setGlobal(globalUpdate); // Send completion message return send('app/cardCompleted', completionData); }, getCurrentRoute: function() { log('getCurrentRoute'); send('navigation/getCurrent', {}); return Promise.resolve({ name: 'unknown' }); }, // Properties get isReady() { return true; }, get environment() { if (isReactNative) return 'expo'; if (typeof window !== 'undefined' && window.parent !== window) return 'iframe'; return 'standalone'; }, get context() { return context; }, ready: function() { return Promise.resolve(); }, // Low-level messaging send: send, on: on, onAny: onAny }; } // Initialize the bridge var config = {"debug":true,"requireAck":false,"targetOrigin":"*"}; var context = null; if (typeof window !== 'undefined') { if (context) { console.log('[Bridge] Initializing with context:', context); window.__SCG_CONTEXT = context; } // Create bridge instance var bridge = createPostBridge(config); // Make bridge globally available window.__SCG_BRIDGE = bridge; window.createPostBridge = createPostBridge; console.log('[Bridge] Bridge initialized and available at window.__SCG_BRIDGE'); // Announce readiness bridge.send('app/ready', { time: Date.now() }).catch(function() {}); } // Export for module systems if (typeof module !== 'undefined' && module.exports) { var bridgeInstance = typeof window !== 'undefined' ? window.__SCG_BRIDGE : createPostBridge({"debug":true,"requireAck":false,"targetOrigin":"*"}); module.exports = { createPostBridge: createPostBridge, bridge: bridgeInstance }; } // Return bridge for direct usage return typeof window !== 'undefined' && window.__SCG_BRIDGE ? window.__SCG_BRIDGE : null; })(); // React Hooks Implementation (converted from CommonJS) /** * @pkme/widget-bridge/react - CommonJS Version * * React hooks for Secret City Games widget integration */ // React imported above const { useState, useEffect, useCallback, useRef } = React; const { bridge } = require('./index.js'); // ═══════════════════════════════════════════════════════════════════════════════ // CORE BRIDGE HOOK // ═══════════════════════════════════════════════════════════════════════════════ function useBridge() { const [isReady, setIsReady] = useState(bridge.isReady); const [context, setContext] = useState(bridge.context); const [environment, setEnvironment] = useState(bridge.environment); useEffect(() => { // If already ready, update state if (bridge.isReady) { setIsReady(true); setContext(bridge.context); setEnvironment(bridge.environment); return; } // Wait for bridge to be ready let mounted = true; bridge.ready().then(() => { if (mounted) { setIsReady(true); setContext(bridge.context); setEnvironment(bridge.environment); } }); return () => { mounted = false; }; }, []); return { bridge, isReady, context, environment }; } // ═══════════════════════════════════════════════════════════════════════════════ // COMBINED DATA HOOK // ═══════════════════════════════════════════════════════════════════════════════ function useBridgeData() { const { bridge: bridgeInstance, isReady, context, environment } = useBridge(); // State const [globalData, setGlobalData] = useState(null); const [itemData, setItemData] = useState(null); // Refs to prevent loops during optimistic updates const isUpdatingGlobal = useRef(false); const isUpdatingItem = useRef(false); // Subscribe to global data useEffect(() => { if (!bridgeInstance || !isReady) return; console.log('[useBridgeData] Setting up global subscription'); const unsubscribe = bridgeInstance.subscribeGlobal((data) => { if (!isUpdatingGlobal.current) { console.log('[useBridgeData] Received global data:', data); setGlobalData(data); } }); return unsubscribe; }, [bridgeInstance, isReady]); // Subscribe to item data useEffect(() => { if (!bridgeInstance || !isReady) return; console.log('[useBridgeData] Setting up item subscription'); const unsubscribe = bridgeInstance.subscribeItem((data) => { if (!isUpdatingItem.current) { console.log('[useBridgeData] Received item data:', data); setItemData(data); } }); return unsubscribe; }, [bridgeInstance, isReady]); // Update global data with optimistic updates const setGlobal = useCallback(async (data) => { if (!bridgeInstance || !isReady) return; console.log('[useBridgeData] Setting global data:', data); // Optimistic update isUpdatingGlobal.current = true; setGlobalData(data); try { await bridgeInstance.setGlobal(data); } catch (error) { console.error('[useBridgeData] Failed to set global data:', error); } finally { // Reset flag after a short delay to allow for any incoming updates setTimeout(() => { isUpdatingGlobal.current = false; }, 100); } }, [bridgeInstance, isReady]); // Update item data with optimistic updates const setItem = useCallback(async (data) => { if (!bridgeInstance || !isReady) return; console.log('[useBridgeData] Setting item data:', data); // Optimistic update isUpdatingItem.current = true; setItemData(data); try { await bridgeInstance.setItem(data); } catch (error) { console.error('[useBridgeData] Failed to set item data:', error); } finally { // Reset flag after a short delay to allow for any incoming updates setTimeout(() => { isUpdatingItem.current = false; }, 100); } }, [bridgeInstance, isReady]); // Navigation methods const navigate = useCallback(async (to, params, method) => { if (!bridgeInstance || !isReady) return; return bridgeInstance.navigate(to, params, method); }, [bridgeInstance, isReady]); const back = useCallback(async (steps) => { if (!bridgeInstance || !isReady) return; return bridgeInstance.back(steps); }, [bridgeInstance, isReady]); const complete = useCallback(async (data) => { if (!bridgeInstance || !isReady) return; return bridgeInstance.complete(data); }, [bridgeInstance, isReady]); return { globalData, itemData, setGlobal, setItem, navigate, back, complete, isReady, context, environment }; } // ═══════════════════════════════════════════════════════════════════════════════ // INDIVIDUAL DATA HOOKS // ═══════════════════════════════════════════════════════════════════════════════ function useGlobalData(initialData) { const { bridge: bridgeInstance, isReady } = useBridge(); const [data, setData] = useState(initialData || null); const isUpdating = useRef(false); useEffect(() => { if (!bridgeInstance || !isReady) return; console.log('[useGlobalData] Setting up subscription'); const unsubscribe = bridgeInstance.subscribeGlobal((receivedData) => { if (!isUpdating.current) { console.log('[useGlobalData] Received data:', receivedData); setData(receivedData); } }); return unsubscribe; }, [bridgeInstance, isReady]); const updateData = useCallback(async (newData) => { if (!bridgeInstance || !isReady) return; console.log('[useGlobalData] Updating data:', newData); // Optimistic update isUpdating.current = true; setData(newData); try { await bridgeInstance.setGlobal(newData); } catch (error) { console.error('[useGlobalData] Failed to update data:', error); } finally { setTimeout(() => { isUpdating.current = false; }, 100); } }, [bridgeInstance, isReady]); return [data, updateData]; } function useItemData(initialData) { const { bridge: bridgeInstance, isReady } = useBridge(); const [data, setData] = useState(initialData || null); const isUpdating = useRef(false); useEffect(() => { if (!bridgeInstance || !isReady) return; console.log('[useItemData] Setting up subscription'); const unsubscribe = bridgeInstance.subscribeItem((receivedData) => { if (!isUpdating.current) { console.log('[useItemData] Received data:', receivedData); setData(receivedData); } }); return unsubscribe; }, [bridgeInstance, isReady]); const updateData = useCallback(async (newData) => { if (!bridgeInstance || !isReady) return; console.log('[useItemData] Updating data:', newData); // Optimistic update isUpdating.current = true; setData(newData); try { await bridgeInstance.setItem(newData); } catch (error) { console.error('[useItemData] Failed to update data:', error); } finally { setTimeout(() => { isUpdating.current = false; }, 100); } }, [bridgeInstance, isReady]); return [data, updateData]; } // ═══════════════════════════════════════════════════════════════════════════════ // NAVIGATION HOOKS // ═══════════════════════════════════════════════════════════════════════════════ function useNavigation() { const { bridge: bridgeInstance, isReady } = useBridge(); const navigate = useCallback(async (to, params, method) => { if (!bridgeInstance || !isReady) return; return bridgeInstance.navigate(to, params, method); }, [bridgeInstance, isReady]); const back = useCallback(async (steps) => { if (!bridgeInstance || !isReady) return; return bridgeInstance.back(steps); }, [bridgeInstance, isReady]); const complete = useCallback(async (data) => { if (!bridgeInstance || !isReady) return; return bridgeInstance.complete(data); }, [bridgeInstance, isReady]); return { navigate, back, complete, isReady }; } // ═══════════════════════════════════════════════════════════════════════════════ // UTILITY FUNCTIONS // ═══════════════════════════════════════════════════════════════════════════════ async function navigateToNextCard(params) { await bridge.complete({ completed: true, ...params }); if (params && params.nextCardId) { await bridge.navigate('/games/' + bridge.context?.gameId + '/cards/' + params.nextCardId, params); } } async function completeCard(completionData) { const dataToSave = { completed: true, completedAt: Date.now(), ...completionData }; await bridge.setItem(dataToSave); await bridge.setGlobal({ cardCompletion: dataToSave }); await bridge.send('app/cardCompleted', dataToSave); if (completionData && completionData.nextCardId) { await navigateToNextCard({ completed: true, ...completionData }); } } async function navigateToCard(cardId, cardData) { await bridge.navigate('/games/' + bridge.context?.gameId + '/cards/' + cardId, { nextCardId: cardId, ...cardData }); } async function savePuzzleProgress(progress, additionalData) { await bridge.setItem({ progress, timestamp: Date.now(), ...additionalData }); } async function updateGlobalGameState(updates) { await bridge.setGlobal(updates); } // ═══════════════════════════════════════════════════════════════════════════════ // MODULE EXPORTS // ═══════════════════════════════════════════════════════════════════════════════ export { useBridge, useBridgeData, useGlobalData, useItemData, useNavigation, navigateToNextCard, completeCard, navigateToCard, savePuzzleProgress, updateGlobalGameState }; // Re-export bridge for convenience export { bridge };