Files
qiming/qiming-vite-plugin-design-mode/packages/client-shared/src/bridge.js
2026-06-01 13:43:09 +08:00

503 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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