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