UNPKG

@pkme/widget-bridge

Version:

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

404 lines (337 loc) 12.6 kB
/** * @pkme/widget-bridge - Browser Bundle (IIFE) * * Usage: * <script src="https://unpkg.com/@pkme/widget-bridge/dist/browser.js"></script> * <script> * const { bridge } = window.SecretCityBridge; * bridge.ready().then(() => { * console.log('Bridge ready!'); * }); * </script> */ (function(global) { 'use strict'; // Include the core bridge code here /** * 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; })(); // Create the global object global.SecretCityBridge = { bridge: global.__SCG_BRIDGE || null, BridgeError: function BridgeError(message, code) { var error = new Error(message); error.name = 'BridgeError'; error.code = code; return error; }, 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' } }; console.log('[SecretCityBridge] Browser bundle loaded'); })(typeof window !== 'undefined' ? window : typeof global !== 'undefined' ? global : this);