UNPKG

@pkme/widget-bridge

Version:

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

590 lines (492 loc) 16.1 kB
/** * @pkme/widget-bridge - Main Module (ESM) */ // Inlined bridge implementation (converted from CommonJS to ESM) function createPostBridge(config = {}) { const { debug = false, requireAck = true, targetOrigin = '*' } = config; function log(...args) { if (debug) console.log('[Bridge]', ...args); } let isReady = false; let pendingMessages = []; let handlers = {}; let globalHandlers = []; let changeIdCounter = 0; let localChangeIds = new Set(); let lastUpdateVersion = 0; // Simple version tracking function generateChangeId() { return '_scg_' + Date.now() + '_' + (changeIdCounter++); } function isLocalChange(changeId) { return changeId && localChangeIds.has(changeId); } function getNextVersion() { return ++lastUpdateVersion; } function markAsLocalChange(changeId) { if (changeId) { localChangeIds.add(changeId); setTimeout(() => localChangeIds.delete(changeId), 5000); } } // Validation function to check for nested data structures function validateFlatData(data, path) { path = path || ''; var nestedKeys = []; if (!data || typeof data !== 'object') { return { isFlat: true, nestedKeys: [] }; } for (var key in data) { if (data.hasOwnProperty(key)) { var value = data[key]; var currentPath = path ? path + '.' + key : key; // Check if value is an object (but not null, Date, or other allowed types) if (value !== null && typeof value === 'object') { // Allow Date objects and other safe object types if (value instanceof Date) { continue; } // Check if it's an array or plain object if (Array.isArray(value) || value.constructor === Object) { nestedKeys.push(currentPath); // Recursively check nested objects for more detail if (value.constructor === Object) { var nestedResult = validateFlatData(value, currentPath); if (nestedResult.nestedKeys.length > 0) { nestedKeys = nestedKeys.concat(nestedResult.nestedKeys); } } } } } } return { isFlat: nestedKeys.length === 0, nestedKeys: nestedKeys }; } // Function to show alert for nested data function alertNestedData(nestedKeys, context) { var message = 'Error: Nested data structures are not allowed in widget-bridge.\n\n'; message += 'The following keys contain nested objects or arrays:\n'; message += nestedKeys.map(function(key) { return '• ' + key; }).join('\n'); message += '\n\nPlease flatten your data structure. '; message += 'All values should be primitives (string, number, boolean, null, Date).'; if (context) { message += '\n\nContext: ' + context; } console.error('[Widget Bridge] ' + message); // Show alert in browser environments if (typeof window !== 'undefined' && window.alert) { alert(message); } } function send(type, data = {}) { const changeId = generateChangeId(); const version = getNextVersion(); const message = { type, data, version, _changeId: changeId, timestamp: Date.now() }; markAsLocalChange(changeId); log('Sending', message); // Detect React Native WebView environment const isReactNative = typeof window !== 'undefined' && window.ReactNativeWebView; if (isReactNative) { // React Native WebView requires stringified JSON window.ReactNativeWebView.postMessage(JSON.stringify(message)); } else if (typeof window !== 'undefined' && window.parent && window.parent !== window) { // Regular iframe/web environment window.parent.postMessage(message, targetOrigin); } return Promise.resolve(); } function on(type, handler) { if (!handlers[type]) handlers[type] = []; handlers[type].push(handler); log('Added handler for', type); return function() { if (handlers[type]) { const index = handlers[type].indexOf(handler); if (index > -1) handlers[type].splice(index, 1); if (handlers[type].length === 0) delete handlers[type]; } }; } function onAny(handler) { globalHandlers.push(handler); return function() { const index = globalHandlers.indexOf(handler); if (index > -1) globalHandlers.splice(index, 1); }; } 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; } } if (msg.type && handlers[msg.type]) { handlers[msg.type].forEach(function(h) { try { h(msg); } catch (e) { console.error('Error in handler:', e); } }); } globalHandlers.forEach(function(h) { try { h(msg); } catch (e) { console.error('Error in global handler:', e); } }); } if (typeof window !== 'undefined') { window.addEventListener('message', function(event) { if (event.data && typeof event.data === 'object' && event.data.type) { dispatch(event.data); } }); } const bridgeAPI = { get isReady() { return isReady; }, get environment() { return typeof window !== 'undefined' && window.parent !== window ? 'iframe' : 'standalone'; }, get context() { return null; }, setGlobal: (data) => { return send('data/saveGlobal', data); }, setItem: (data) => { return send('data/saveItem', data); }, setItemById: (itemId, data) => { return send('data/saveItem', { ...data, _itemId: itemId }); }, subscribeGlobal: (handler) => { const unsubscribe = on('data/globalUpdated', (msg) => { if (msg.data) handler(msg.data); }); send('data/subscribe', { scope: 'global' }); return unsubscribe; }, subscribeItem: (handler) => { const unsubscribe = on('data/itemUpdated', (msg) => { if (msg.data) handler(msg.data); }); send('data/subscribe', { scope: 'item' }); return unsubscribe; }, subscribeItemById: (itemId, handler) => { const unsubscribe = on('data/itemUpdated', (msg) => { if (msg.data && (msg.data._itemId === itemId || !msg.data._itemId)) { handler(msg.data); } }); send('data/subscribe', { scope: 'item', itemId }); return unsubscribe; }, navigate: (to, params, method = 'push') => send('navigation/navigate', { to, params, method }), back: (steps = 1) => send('navigation/back', { steps }), complete: (data) => send('app/cardCompleted', data), // Expose validation functions for React hooks validateFlatData: validateFlatData, alertNestedData: alertNestedData, send, on, onAny, ready: () => { isReady = true; return Promise.resolve(); } }; isReady = true; return bridgeAPI; } // Create bridge instance const bridge = createPostBridge({ debug: true, requireAck: false, targetOrigin: '*' }); export { bridge }; export class BridgeError extends Error { constructor(message, code) { super(message); this.name = 'BridgeError'; this.code = code; } } export const MESSAGE_TYPES = { SAVE_GLOBAL: 'data/saveGlobal', SAVE_ITEM: 'data/saveItem', SUBSCRIBE: 'data/subscribe', UNSUBSCRIBE: 'data/unsubscribe', GLOBAL_UPDATED: 'data/globalUpdated', ITEM_UPDATED: 'data/itemUpdated', ITEM_DELETED: 'data/itemDeleted', NAVIGATE: 'navigation/navigate', BACK: 'navigation/back', GET_CURRENT: 'navigation/getCurrent', READY: 'app/ready', CARD_COMPLETED: 'app/cardCompleted', NAVIGATE_TO_NEXT_CARD: 'app/navigateToNextCard' }; // Utility functions export async function navigateToNextCard(params = {}) { await bridge.complete({ completed: true, ...params }); if (params.nextCardId) { await bridge.navigate('/games/' + bridge.context?.gameId + '/cards/' + params.nextCardId, params); } } export 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.nextCardId) { await navigateToNextCard({ completed: true, ...completionData }); } } export async function navigateToCard(cardId, cardData = {}) { await bridge.navigate('/games/' + bridge.context?.gameId + '/cards/' + cardId, { nextCardId: cardId, ...cardData }); } export async function savePuzzleProgress(progress, additionalData = {}) { await bridge.setItem({ progress, timestamp: Date.now(), ...additionalData }); } export async function updateGlobalGameState(updates) { await bridge.setGlobal(updates); } // Inlined React hooks for Framer compatibility (single-file solution) import { useState, useEffect, useCallback, useRef } from 'react'; // Core Bridge Hook export function useBridge() { const [isReady, setIsReady] = useState(bridge.isReady); const [context, setContext] = useState(bridge.context); const [environment, setEnvironment] = useState(bridge.environment); useEffect(() => { if (bridge.isReady) { setIsReady(true); setContext(bridge.context); setEnvironment(bridge.environment); return; } 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 export function useBridgeData() { const { bridge: bridgeInstance, isReady, context, environment } = useBridge(); const [globalData, setGlobalData] = useState(null); const [itemData, setItemData] = useState(null); const isUpdatingGlobal = useRef(false); const isUpdatingItem = useRef(false); useEffect(() => { if (!bridgeInstance || !isReady) return; const unsubscribe = bridgeInstance.subscribeGlobal((data) => { if (!isUpdatingGlobal.current) { setGlobalData(data); } }); return unsubscribe; }, [bridgeInstance, isReady]); useEffect(() => { if (!bridgeInstance || !isReady) return; const unsubscribe = bridgeInstance.subscribeItem((data) => { if (!isUpdatingItem.current) { setItemData(data); } }); return unsubscribe; }, [bridgeInstance, isReady]); const setGlobal = useCallback(async (data) => { if (!bridgeInstance || !isReady) return; // Validate data is flat at React hook level var validation = bridgeInstance.validateFlatData(data); if (!validation.isFlat) { bridgeInstance.alertNestedData(validation.nestedKeys, 'React Hook setGlobal()'); throw new Error('Nested data structures are not allowed. Found nested keys: ' + validation.nestedKeys.join(', ')); } isUpdatingGlobal.current = true; setGlobalData(data); try { await bridgeInstance.setGlobal(data); } catch (error) { console.error('Failed to set global data:', error); throw error; } finally { setTimeout(() => { isUpdatingGlobal.current = false; }, 100); } }, [bridgeInstance, isReady]); const setItem = useCallback(async (data) => { if (!bridgeInstance || !isReady) return; // Validate data is flat at React hook level var validation = bridgeInstance.validateFlatData(data); if (!validation.isFlat) { bridgeInstance.alertNestedData(validation.nestedKeys, 'React Hook setItem()'); throw new Error('Nested data structures are not allowed. Found nested keys: ' + validation.nestedKeys.join(', ')); } isUpdatingItem.current = true; setItemData(data); try { await bridgeInstance.setItem(data); } catch (error) { console.error('Failed to set item data:', error); throw error; } finally { setTimeout(() => { isUpdatingItem.current = false; }, 100); } }, [bridgeInstance, isReady]); 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; // Validate data is flat before completing if (data && typeof data === 'object') { var validation = bridgeInstance.validateFlatData(data); if (!validation.isFlat) { bridgeInstance.alertNestedData(validation.nestedKeys, 'React Hook complete()'); throw new Error('Nested data structures are not allowed. Found nested keys: ' + validation.nestedKeys.join(', ')); } } return bridgeInstance.complete(data); }, [bridgeInstance, isReady]); return { globalData, itemData, setGlobal, setItem, navigate, back, complete, isReady, context, environment }; } // Individual Data Hooks export function useGlobalData(initialData) { const { bridge: bridgeInstance, isReady } = useBridge(); const [data, setData] = useState(initialData || null); const isUpdating = useRef(false); useEffect(() => { if (!bridgeInstance || !isReady) return; const unsubscribe = bridgeInstance.subscribeGlobal((receivedData) => { if (!isUpdating.current) { setData(receivedData); } }); return unsubscribe; }, [bridgeInstance, isReady]); const updateData = useCallback(async (newData) => { if (!bridgeInstance || !isReady) return; isUpdating.current = true; setData(newData); try { await bridgeInstance.setGlobal(newData); } catch (error) { console.error('Failed to update global data:', error); } finally { setTimeout(() => { isUpdating.current = false; }, 100); } }, [bridgeInstance, isReady]); return [data, updateData]; } export function useItemData(initialData) { const { bridge: bridgeInstance, isReady } = useBridge(); const [data, setData] = useState(initialData || null); const isUpdating = useRef(false); useEffect(() => { if (!bridgeInstance || !isReady) return; const unsubscribe = bridgeInstance.subscribeItem((receivedData) => { if (!isUpdating.current) { setData(receivedData); } }); return unsubscribe; }, [bridgeInstance, isReady]); const updateData = useCallback(async (newData) => { if (!bridgeInstance || !isReady) return; isUpdating.current = true; setData(newData); try { await bridgeInstance.setItem(newData); } catch (error) { console.error('Failed to update item data:', error); } finally { setTimeout(() => { isUpdating.current = false; }, 100); } }, [bridgeInstance, isReady]); return [data, updateData]; } // Navigation Hook export 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 }; }