@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
JavaScript
/**
* @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 };