503 lines
18 KiB
JavaScript
503 lines
18 KiB
JavaScript
/**
|
||
* postMessage bridge: optional request/response, validation hooks, heartbeats.
|
||
*/
|
||
export class EnhancedBridge {
|
||
constructor(config = {}) {
|
||
this.listeners = new Map();
|
||
this.pendingRequests = new Map();
|
||
this._isConnected = false;
|
||
this.lastHeartbeat = 0;
|
||
this.config = {
|
||
timeout: 10000,
|
||
retryAttempts: 3,
|
||
retryDelay: 1000,
|
||
heartbeatInterval: 30000,
|
||
debug: false,
|
||
...config
|
||
};
|
||
this.initializeMessageHandling();
|
||
this.initializeHeartbeat();
|
||
this.initializeConnectionCheck();
|
||
}
|
||
/** Listen for `message` and mark connected after a short delay (iframe vs top). */
|
||
initializeMessageHandling() {
|
||
if (typeof window === 'undefined')
|
||
return;
|
||
window.addEventListener('message', this.handleMessage.bind(this));
|
||
if (this.isIframeEnvironment()) {
|
||
setTimeout(() => {
|
||
this._isConnected = true;
|
||
this.log('Bridge initialized and connected (iframe mode)');
|
||
this.sendReadyMessage();
|
||
}, 200);
|
||
}
|
||
else {
|
||
setTimeout(() => {
|
||
this._isConnected = true;
|
||
this.log('Bridge initialized and connected (main window mode)');
|
||
}, 100);
|
||
}
|
||
}
|
||
/** Notify parent that the child iframe bridge is ready. */
|
||
sendReadyMessage() {
|
||
try {
|
||
const readyMessage = {
|
||
type: 'BRIDGE_READY',
|
||
payload: {
|
||
timestamp: this.createTimestamp(),
|
||
environment: 'iframe'
|
||
},
|
||
timestamp: this.createTimestamp()
|
||
};
|
||
this.getTargetWindow().postMessage(readyMessage, '*');
|
||
this.log('Sent ready message to parent');
|
||
}
|
||
catch (error) {
|
||
this.log('Failed to send ready message:', error);
|
||
}
|
||
}
|
||
/**
|
||
* Periodic HEARTBEAT posts when running inside an iframe.
|
||
*/
|
||
initializeHeartbeat() {
|
||
if (typeof window === 'undefined')
|
||
return;
|
||
if (this.isIframeEnvironment()) {
|
||
this.heartbeatTimer = setInterval(() => {
|
||
this.sendHeartbeat();
|
||
}, this.config.heartbeatInterval);
|
||
}
|
||
}
|
||
/** Periodically infer disconnect from stale heartbeats. */
|
||
initializeConnectionCheck() {
|
||
if (typeof window === 'undefined')
|
||
return;
|
||
this.connectionCheckTimer = setInterval(() => {
|
||
this.checkConnection();
|
||
}, this.config.heartbeatInterval * 2);
|
||
}
|
||
/** True when `window.self !== window.top`. */
|
||
isIframeEnvironment() {
|
||
return typeof window !== 'undefined' && window.self !== window.top;
|
||
}
|
||
/** Snapshot for diagnostics / health. */
|
||
getEnvironmentInfo() {
|
||
if (typeof window === 'undefined') {
|
||
return {
|
||
isIframe: false,
|
||
isConnected: false,
|
||
origin: '',
|
||
userAgent: '',
|
||
location: ''
|
||
};
|
||
}
|
||
return {
|
||
isIframe: this.isIframeEnvironment(),
|
||
isConnected: this._isConnected,
|
||
origin: window.location.origin,
|
||
userAgent: navigator.userAgent.substring(0, 100),
|
||
location: window.location.href
|
||
};
|
||
}
|
||
/** Log bridge state to the console (dev aid). */
|
||
diagnose() {
|
||
const env = this.getEnvironmentInfo();
|
||
console.group('[EnhancedBridge] Bridge Diagnosis');
|
||
console.log('Environment:', env);
|
||
console.log('Connection Status:', this._isConnected);
|
||
console.log('Pending Requests:', this.pendingRequests.size);
|
||
console.log('Message Listeners:', Array.from(this.listeners.keys()));
|
||
console.log('Config:', this.config);
|
||
console.groupEnd();
|
||
}
|
||
/** Parent when iframe; `window` when top-level. */
|
||
getTargetWindow() {
|
||
return this.isIframeEnvironment() ? window.parent : window;
|
||
}
|
||
/** Route incoming postMessage payloads. */
|
||
handleMessage(event) {
|
||
// Optional origin filter (disabled by default):
|
||
// if (event.origin !== window.location.origin && window.location.origin !== 'null') {
|
||
// if (!event.origin.startsWith('http') && !event.origin.startsWith('https')) {
|
||
// this.log('Received message from different origin, allowing:', event.origin);
|
||
// } else {
|
||
// this.log('Skipping message from different origin:', event.origin);
|
||
// return;
|
||
// }
|
||
// }
|
||
const message = event.data;
|
||
// Drop malformed payloads
|
||
if (!this.isValidMessage(message)) {
|
||
this.log('Invalid message received:', message);
|
||
return;
|
||
}
|
||
// Child handshake
|
||
if (message.type === 'BRIDGE_READY') {
|
||
this.log('Received ready message from child');
|
||
this._isConnected = true;
|
||
return;
|
||
}
|
||
// Completes a pending sendWithResponse
|
||
if (this.isResponseMessage(message)) {
|
||
this.handleResponseMessage(message);
|
||
return;
|
||
}
|
||
// Fan-out to on(type) handlers
|
||
this.dispatchMessage(message);
|
||
}
|
||
/** Resolve or reject pendingRequests entry. */
|
||
handleResponseMessage(message) {
|
||
const { requestId } = message;
|
||
if (this.pendingRequests.has(requestId)) {
|
||
const request = this.pendingRequests.get(requestId);
|
||
clearTimeout(request.timeout);
|
||
this.pendingRequests.delete(requestId);
|
||
if (message.type === 'ACKNOWLEDGEMENT') {
|
||
request.resolve({ success: true, acknowledged: true });
|
||
}
|
||
else {
|
||
request.resolve(message);
|
||
}
|
||
}
|
||
}
|
||
/** Shape check before dispatch. */
|
||
isValidMessage(message) {
|
||
return (message &&
|
||
typeof message.type === 'string' &&
|
||
this.isSupportedMessageType(message.type));
|
||
}
|
||
/** Known DesignModeMessage union tags. */
|
||
isSupportedMessageType(type) {
|
||
const supportedTypes = [
|
||
// Iframe to Parent
|
||
'ELEMENT_SELECTED', 'ELEMENT_DESELECTED', 'CONTENT_UPDATED', 'STYLE_UPDATED',
|
||
'DESIGN_MODE_CHANGED', 'ELEMENT_STATE_RESPONSE', 'ERROR', 'ACKNOWLEDGEMENT',
|
||
'HEARTBEAT', 'HEALTH_CHECK_RESPONSE', 'BRIDGE_READY', 'ADD_TO_CHAT',
|
||
// Parent to Iframe
|
||
'TOGGLE_DESIGN_MODE', 'UPDATE_STYLE', 'UPDATE_CONTENT', 'BATCH_UPDATE',
|
||
'HEALTH_CHECK'
|
||
];
|
||
return supportedTypes.includes(type);
|
||
}
|
||
/** ACK or carries requestId. */
|
||
isResponseMessage(message) {
|
||
return message &&
|
||
(message.type === 'ACKNOWLEDGEMENT' ||
|
||
message.requestId !== undefined) &&
|
||
(message.timestamp !== undefined);
|
||
}
|
||
/**
|
||
* Fire-and-forget postMessage (may no-op if not connected).
|
||
*/
|
||
async send(message) {
|
||
if (!this._isConnected && this.isIframeEnvironment()) {
|
||
this.log('Bridge not connected, attempting to reconnect...');
|
||
await new Promise(resolve => setTimeout(resolve, 100));
|
||
if (!this._isConnected) {
|
||
console.warn('[EnhancedBridge] Bridge still not connected, message will be queued');
|
||
return;
|
||
}
|
||
}
|
||
if (!this._isConnected && !this.isIframeEnvironment()) {
|
||
this.log('Running in main window, bridge connection not applicable');
|
||
return;
|
||
}
|
||
const enhancedMessage = this.enhanceMessage(message);
|
||
try {
|
||
this.log('Sending message:', enhancedMessage);
|
||
this.getTargetWindow().postMessage(enhancedMessage, '*');
|
||
if (this.isIframeEnvironment() && enhancedMessage.requestId) {
|
||
this.sendAcknowledgement(enhancedMessage.requestId);
|
||
}
|
||
}
|
||
catch (error) {
|
||
this.log('Error sending message:', error);
|
||
// Dev: surface send failures
|
||
if (process.env.NODE_ENV === 'development') {
|
||
throw error;
|
||
}
|
||
}
|
||
}
|
||
/** RPC-style: wait until matching response or timeout. */
|
||
async sendWithResponse(message, responseType) {
|
||
if (!this._isConnected) {
|
||
throw new Error('Bridge is not connected');
|
||
}
|
||
const enhancedMessage = this.enhanceMessage(message);
|
||
const requestId = enhancedMessage.requestId;
|
||
return new Promise((resolve, reject) => {
|
||
// Timeout
|
||
const timeout = setTimeout(() => {
|
||
this.pendingRequests.delete(requestId);
|
||
reject(new Error(`Request timeout after ${this.config.timeout}ms`));
|
||
}, this.config.timeout);
|
||
// Pending request map
|
||
this.pendingRequests.set(requestId, {
|
||
resolve,
|
||
reject,
|
||
timeout,
|
||
responseType
|
||
});
|
||
try {
|
||
this.log('Sending request with response:', enhancedMessage);
|
||
this.getTargetWindow().postMessage(enhancedMessage, '*');
|
||
}
|
||
catch (error) {
|
||
this.pendingRequests.delete(requestId);
|
||
clearTimeout(timeout);
|
||
reject(error);
|
||
}
|
||
});
|
||
}
|
||
/** Ensure requestId + timestamp exist. */
|
||
enhanceMessage(message) {
|
||
const requestId = message.requestId || this.generateRequestId();
|
||
const timestamp = message.timestamp || this.createTimestamp();
|
||
return {
|
||
...message,
|
||
requestId,
|
||
timestamp
|
||
};
|
||
}
|
||
/** Unique id for correlating responses. */
|
||
generateRequestId() {
|
||
return `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||
}
|
||
/** ms since epoch */
|
||
createTimestamp() {
|
||
return Date.now();
|
||
}
|
||
/** Best-effort ACK (messageType not wired through yet). */
|
||
sendAcknowledgement(requestId) {
|
||
const acknowledgement = {
|
||
type: 'ACKNOWLEDGEMENT',
|
||
payload: {
|
||
messageType: 'UNKNOWN', // TODO: propagate originating type
|
||
},
|
||
requestId,
|
||
timestamp: this.createTimestamp()
|
||
};
|
||
this.getTargetWindow().postMessage(acknowledgement, '*');
|
||
}
|
||
/** HEARTBEAT post to parent. */
|
||
sendHeartbeat() {
|
||
const heartbeat = {
|
||
type: 'HEARTBEAT',
|
||
payload: {
|
||
timestamp: this.createTimestamp()
|
||
},
|
||
timestamp: this.createTimestamp()
|
||
};
|
||
this.getTargetWindow().postMessage(heartbeat, '*');
|
||
this.lastHeartbeat = Date.now();
|
||
}
|
||
/** Mark disconnected if no heartbeats for 3× interval. */
|
||
checkConnection() {
|
||
const now = Date.now();
|
||
const timeSinceLastHeartbeat = now - this.lastHeartbeat;
|
||
if (timeSinceLastHeartbeat > this.config.heartbeatInterval * 3) {
|
||
this.log('Connection appears to be lost');
|
||
this._isConnected = false;
|
||
// Optimistic reconnect
|
||
setTimeout(() => {
|
||
this._isConnected = true;
|
||
this.log('Connection restored');
|
||
}, 1000);
|
||
}
|
||
}
|
||
/** Subscribe; returns unsubscribe. */
|
||
on(type, handler) {
|
||
if (!this.listeners.has(type)) {
|
||
this.listeners.set(type, new Set());
|
||
}
|
||
this.listeners.get(type).add(handler);
|
||
// Unsubscribe
|
||
return () => {
|
||
this.listeners.get(type)?.delete(handler);
|
||
};
|
||
}
|
||
/** Remove one handler for `type`. */
|
||
off(type, handler) {
|
||
this.listeners.get(type)?.delete(handler);
|
||
}
|
||
/** Invoke all listeners for message.type */
|
||
dispatchMessage(message) {
|
||
const handlers = this.listeners.get(message.type);
|
||
if (handlers) {
|
||
handlers.forEach(handler => {
|
||
try {
|
||
handler(message);
|
||
}
|
||
catch (error) {
|
||
this.log('Error in message handler:', error);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
/** `_isConnected` flag */
|
||
isConnected() {
|
||
return this._isConnected;
|
||
}
|
||
/** Same as `isConnected` (legacy name). */
|
||
isConnectedToTarget() {
|
||
return this._isConnected;
|
||
}
|
||
/** Clear timers, reject pendings, remove listener. */
|
||
disconnect() {
|
||
this._isConnected = false;
|
||
if (this.heartbeatTimer) {
|
||
clearInterval(this.heartbeatTimer);
|
||
}
|
||
if (this.connectionCheckTimer) {
|
||
clearInterval(this.connectionCheckTimer);
|
||
}
|
||
// Reject pending RPCs
|
||
this.pendingRequests.forEach(request => {
|
||
clearTimeout(request.timeout);
|
||
request.reject(new Error('Connection disconnected'));
|
||
});
|
||
this.pendingRequests.clear();
|
||
// Remove global listener
|
||
if (typeof window !== 'undefined') {
|
||
window.removeEventListener('message', this.handleMessage.bind(this));
|
||
}
|
||
this.listeners.clear();
|
||
this.log('Bridge disconnected');
|
||
}
|
||
/** Summarize connectivity; may issue HEALTH_CHECK RPC in iframe. */
|
||
async healthCheck() {
|
||
try {
|
||
const env = this.getEnvironmentInfo();
|
||
// Top-level window
|
||
if (!env.isIframe && this._isConnected) {
|
||
return {
|
||
status: 'healthy',
|
||
details: {
|
||
...env,
|
||
message: 'Bridge is healthy and running in main window'
|
||
}
|
||
};
|
||
}
|
||
if (env.isIframe && this._isConnected) {
|
||
// Iframe: ping parent
|
||
try {
|
||
const response = await this.sendWithResponse({
|
||
type: 'HEALTH_CHECK',
|
||
requestId: '',
|
||
timestamp: this.createTimestamp()
|
||
}, 'HEALTH_CHECK_RESPONSE');
|
||
return {
|
||
status: 'healthy',
|
||
details: {
|
||
...env,
|
||
response: response.payload,
|
||
message: 'Bridge is healthy and responsive'
|
||
}
|
||
};
|
||
}
|
||
catch (error) {
|
||
return {
|
||
status: 'degraded',
|
||
details: {
|
||
...env,
|
||
error: error instanceof Error ? error.message : 'Unknown error',
|
||
message: 'Bridge is connected but not responding to health checks'
|
||
}
|
||
};
|
||
}
|
||
}
|
||
return {
|
||
status: env.isIframe ? 'connecting' : 'unnecessary',
|
||
details: {
|
||
...env,
|
||
message: env.isIframe ? 'Bridge is initializing' : 'Bridge not needed in main window'
|
||
}
|
||
};
|
||
}
|
||
catch (error) {
|
||
return {
|
||
status: 'unhealthy',
|
||
details: {
|
||
error: error instanceof Error ? error.message : 'Unknown error',
|
||
environment: this.getEnvironmentInfo()
|
||
}
|
||
};
|
||
}
|
||
}
|
||
/** Guarded by config.debug */
|
||
log(...args) {
|
||
if (this.config.debug) {
|
||
const timestamp = new Date().toISOString();
|
||
console.log(`[${timestamp}] [EnhancedBridge]`, ...args);
|
||
}
|
||
}
|
||
/** Alias for disconnect(). */
|
||
destroy() {
|
||
this.disconnect();
|
||
}
|
||
}
|
||
/** Small helpers for request ids / timestamps. */
|
||
export const MessageUtils = {
|
||
generateRequestId: () => {
|
||
return `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||
},
|
||
createTimestamp: () => {
|
||
return Date.now();
|
||
},
|
||
isValidMessage: (message) => {
|
||
return message &&
|
||
typeof message.type === 'string' &&
|
||
typeof message.timestamp === 'number';
|
||
},
|
||
createResponse: function (originalMessage, payload, success = true, error) {
|
||
return {
|
||
payload,
|
||
type: payload?.type,
|
||
requestId: originalMessage.requestId || '',
|
||
timestamp: this.createTimestamp()
|
||
};
|
||
}
|
||
};
|
||
/** Singleton used by the design-mode client bundle. */
|
||
export const bridge = new EnhancedBridge();
|
||
/** Structural validation for a subset of message types. */
|
||
export class MessageValidatorImpl {
|
||
validate(message) {
|
||
const errors = [];
|
||
if (!message) {
|
||
errors.push('Message is null or undefined');
|
||
return { isValid: false, errors };
|
||
}
|
||
if (typeof message.type !== 'string') {
|
||
errors.push('Message type must be a string');
|
||
}
|
||
if (typeof message.timestamp !== 'number') {
|
||
errors.push('Message timestamp must be a number');
|
||
}
|
||
// Per-type payload checks
|
||
switch (message.type) {
|
||
case 'ELEMENT_SELECTED':
|
||
if (!message.payload?.elementInfo) {
|
||
errors.push('ELEMENT_SELECTED must have elementInfo in payload');
|
||
}
|
||
break;
|
||
case 'UPDATE_STYLE':
|
||
case 'UPDATE_CONTENT':
|
||
if (!message.payload?.sourceInfo) {
|
||
errors.push(`${message.type} must have sourceInfo in payload`);
|
||
}
|
||
break;
|
||
case 'BATCH_UPDATE':
|
||
if (!Array.isArray(message.payload?.updates)) {
|
||
errors.push('BATCH_UPDATE must have updates array in payload');
|
||
}
|
||
break;
|
||
}
|
||
return {
|
||
isValid: errors.length === 0,
|
||
errors: errors.length > 0 ? errors : undefined
|
||
};
|
||
}
|
||
}
|
||
export const messageValidator = new MessageValidatorImpl();
|
||
//# sourceMappingURL=bridge.js.map
|