import { DesignModeMessage, IframeToParentMessage, ParentToIframeMessage, RequestResponseMessage, MessageValidator, BridgeInterface, BridgeConfig, MessageUtilsInterface, MessageValidationResult, AcknowledgementMessage, HealthCheckMessage, HealthCheckResponseMessage, HeartbeatMessage } from '@xagi/design-mode-shared/messages'; /** * postMessage bridge: optional request/response, validation hooks, heartbeats. */ export class EnhancedBridge implements BridgeInterface { private listeners: Map> = new Map(); private pendingRequests: Map = new Map(); private config: BridgeConfig; private _isConnected = false; private lastHeartbeat = 0; private heartbeatTimer?: NodeJS.Timeout; private connectionCheckTimer?: NodeJS.Timeout; constructor(config: Partial = {}) { 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). */ private 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. */ private 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. */ private initializeHeartbeat() { if (typeof window === 'undefined') return; if (this.isIframeEnvironment()) { this.heartbeatTimer = setInterval(() => { this.sendHeartbeat(); }, this.config.heartbeatInterval); } } /** Periodically infer disconnect from stale heartbeats. */ private initializeConnectionCheck() { if (typeof window === 'undefined') return; this.connectionCheckTimer = setInterval(() => { this.checkConnection(); }, this.config.heartbeatInterval * 2); } /** True when `window.self !== window.top`. */ private isIframeEnvironment(): boolean { return typeof window !== 'undefined' && window.self !== window.top; } /** Snapshot for diagnostics / health. */ public getEnvironmentInfo(): { isIframe: boolean; isConnected: boolean; origin: string; userAgent: string; location: string; } { 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). */ public diagnose(): void { // Diagnose output is explicitly gated by debug mode to avoid noisy runtime logs. if (!this.config.debug) return; 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. */ private getTargetWindow(): Window { return this.isIframeEnvironment() ? window.parent : window; } /** Route incoming postMessage payloads. */ private handleMessage(event: MessageEvent) { // 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; } // 父窗口 -> iframe 的控制类消息:必须优先走业务分发,不能误判为 RPC response。 // 否则像 TOGGLE_DESIGN_MODE 这类同样带 requestId/timestamp 的消息会被 isResponseMessage // 吞掉,导致 bridge.on('TOGGLE_DESIGN_MODE') 永远不触发,父页面一直等不到 DESIGN_MODE_CHANGED。 if (this.isIframeEnvironment() && this.isParentToIframeCommand(message)) { this.dispatchMessage(message); 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. */ private handleResponseMessage(message: RequestResponseMessage) { 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. */ private isValidMessage(message: any): message is DesignModeMessage { return ( message && typeof message.type === 'string' && this.isSupportedMessageType(message.type) ); } /** Known DesignModeMessage union tags. */ private isSupportedMessageType(type: string): boolean { 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); } /** * 是否为 sendWithResponse / ACK 通道的响应消息。 * 注意:不能仅凭 requestId + timestamp 判断,否则父页面发来的 TOGGLE_DESIGN_MODE *(同样带 requestId)会被误判为 response,从而跳过 dispatchMessage。 */ private isResponseMessage(message: any): message is RequestResponseMessage { if (!message || typeof message.type !== 'string') { return false; } if (message.requestId === undefined || message.timestamp === undefined) { return false; } return ( message.type === 'ACKNOWLEDGEMENT' || message.type === 'HEALTH_CHECK_RESPONSE' ); } /** 父窗口发往 iframe 的控制命令(在 iframe 内必须由业务 handler 处理) */ private isParentToIframeCommand(message: DesignModeMessage): boolean { const t = (message as { type?: string }).type; return ( t === 'TOGGLE_DESIGN_MODE' || t === 'UPDATE_STYLE' || t === 'UPDATE_CONTENT' || t === 'BATCH_UPDATE' || t === 'HEALTH_CHECK' ); } /** * Fire-and-forget postMessage (may no-op if not connected). */ public async send(message: T): Promise { if (!this._isConnected && this.isIframeEnvironment()) { this.log('Bridge not connected, attempting to reconnect...'); await new Promise(resolve => setTimeout(resolve, 100)); if (!this._isConnected) 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. */ public async sendWithResponse( message: T, responseType: R['type'] ): Promise { 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. */ private enhanceMessage(message: T): T & { requestId?: string; timestamp: number } { const requestId = (message as any).requestId || this.generateRequestId(); const timestamp = (message as any).timestamp || this.createTimestamp(); return { ...message, requestId, timestamp }; } /** Unique id for correlating responses. */ private generateRequestId(): string { return `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; } /** ms since epoch */ private createTimestamp(): number { return Date.now(); } /** Best-effort ACK (messageType not wired through yet). */ private sendAcknowledgement(requestId: string) { const acknowledgement: AcknowledgementMessage = { type: 'ACKNOWLEDGEMENT', payload: { messageType: 'UNKNOWN', // TODO: propagate originating type }, requestId, timestamp: this.createTimestamp() }; this.getTargetWindow().postMessage(acknowledgement, '*'); } /** HEARTBEAT post to parent. */ private sendHeartbeat() { const heartbeat: HeartbeatMessage = { 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. */ private 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. */ public on(type: T['type'], handler: (message: T) => void): () => void { 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`. */ public off(type: string, handler: Function): void { this.listeners.get(type)?.delete(handler); } /** Invoke all listeners for message.type */ private dispatchMessage(message: DesignModeMessage) { 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 */ public isConnected(): boolean { return this._isConnected; } /** Same as `isConnected` (legacy name). */ public isConnectedToTarget(): boolean { return this._isConnected; } /** Clear timers, reject pendings, remove listener. */ public disconnect(): void { 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. */ public async healthCheck(): Promise<{ status: string; details: any }> { 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 */ private log(...args: any[]) { // Keep debug logging capability for explicit troubleshooting sessions. if (this.config.debug) { const timestamp = new Date().toISOString(); console.log(`[${timestamp}] [EnhancedBridge]`, ...args); } } /** Alias for disconnect(). */ public destroy(): void { this.disconnect(); } } /** Small helpers for request ids / timestamps. */ export const MessageUtils: MessageUtilsInterface = { generateRequestId: (): string => { return `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; }, createTimestamp: (): number => { return Date.now(); }, isValidMessage: (message: any): boolean => { return message && typeof message.type === 'string' && typeof message.timestamp === 'number'; }, createResponse: function( originalMessage: ParentToIframeMessage, payload: T extends { payload: infer P } ? P : never, success: boolean = true, error?: string ): T { return { payload, type: (payload as any)?.type, requestId: (originalMessage as any).requestId || '', timestamp: this.createTimestamp() } as unknown as T; } }; /** Singleton used by the design-mode client bundle. */ export const bridge = new EnhancedBridge(); /** Structural validation for a subset of message types. */ export class MessageValidatorImpl implements MessageValidator { validate(message: any): MessageValidationResult { const errors: string[] = []; 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();