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