UNPKG

@quartal/bridge-client

Version:

Universal client library for embedding applications with URL-configurable transport support (iframe, postMessage) and framework adapters for Angular and Vue

1,407 lines (1,397 loc) 83.6 kB
'use strict'; var bellhopIframe = require('bellhop-iframe'); class QuartalLogger { constructor(config = {}) { this.config = { debug: false, trace: false, appPrefix: 'QC', instanceId: `inst_${++QuartalLogger.instanceCounter}`, ...config }; } formatMessage(level, message, ..._args) { const prefix = `(${this.config.appPrefix})`; const instance = `[${this.config.instanceId}]`; const levelStr = `[${level.toUpperCase()}]`; return `${prefix} ${instance} ${levelStr} ${message}`; } shouldLog(level) { if (level === 'debug' && !this.config.debug) return false; if (level === 'trace' && !this.config.trace) return false; return true; } debug(message, ...args) { if (!this.shouldLog('debug')) return; console.log(this.formatMessage('debug', message), ...args); } trace(message, ...args) { if (!this.shouldLog('trace')) return; console.log(this.formatMessage('trace', message), ...args); } info(message, ...args) { console.info(this.formatMessage('info', message), ...args); } warn(message, ...args) { console.warn(this.formatMessage('warn', message), ...args); } error(message, error, ...args) { console.error(this.formatMessage('error', message), error, ...args); } log(message, ...args) { console.log(this.formatMessage('log', message), ...args); } // Method to update config after initialization updateConfig(newConfig) { this.config = { ...this.config, ...newConfig }; } // Get current config getConfig() { return { ...this.config }; } } QuartalLogger.instanceCounter = 0; // Global logger instance for static access let globalLogger = null; function getGlobalLogger() { if (!globalLogger) { globalLogger = new QuartalLogger(); } return globalLogger; } function setGlobalLogger(logger) { globalLogger = logger; } // Core event constants to avoid string conflicts // Naming convention: RECIPIENT_SENDER_ACTION (whoListens_whoSends_whatHappens) const QUARTAL_EVENTS = { // Child listens to Parent events CHILD_PARENT_READY: 'quartal:child:parent:ready', CHILD_PARENT_FAST_ACTIONS: 'quartal:child:parent:fast-actions', CHILD_PARENT_FAST_ACTION_CALLBACK: 'quartal:child:parent:fast-action-callback', CHILD_PARENT_LOGOUT: 'quartal:child:parent:logout', CHILD_PARENT_REDIRECT: 'quartal:child:parent:redirect', CHILD_PARENT_RELOAD: 'quartal:child:parent:reload', CHILD_PARENT_CLEAR_CACHE: 'quartal:child:parent:clear-cache', CHILD_PARENT_CLEAR_STORAGE: 'quartal:child:parent:clear-storage', CHILD_PARENT_OPEN_URL: 'quartal:child:parent:open-url', CHILD_PARENT_OPEN_ACTION: 'quartal:child:parent:open-action', CHILD_PARENT_FETCH_DATA_RESPONSE: 'quartal:child:parent:fetch-data-response', // Parent listens to Child events PARENT_CHILD_READY: 'quartal:parent:child:ready', PARENT_CHILD_INIT: 'quartal:parent:child:init', PARENT_CHILD_FAST_ACTIONS: 'quartal:parent:child:fast-actions', PARENT_CHILD_OPEN_FAST_ACTION: 'quartal:parent:child:open-fast-action', PARENT_CHILD_FAST_ACTION_CALLBACK: 'quartal:parent:child:fast-action-callback', PARENT_CHILD_URL_CHANGE: 'quartal:parent:child:url-change', PARENT_CHILD_HEIGHT_CHANGE: 'quartal:parent:child:height-change', PARENT_CHILD_TITLE_CHANGE: 'quartal:parent:child:title-change', PARENT_CHILD_ALERT: 'quartal:parent:child:alert', PARENT_CHILD_ERROR: 'quartal:parent:child:error', PARENT_CHILD_MOUSE_CLICK: 'quartal:parent:child:mouse-click', PARENT_CHILD_FETCH_DATA: 'quartal:parent:child:fetch-data', PARENT_CHILD_MAKE_PAYMENT: 'quartal:parent:child:make-payment', PARENT_CHILD_PENDING_REQUEST: 'quartal:parent:child:pending-request', PARENT_CHILD_DIALOG_CHANGED: 'quartal:parent:child:dialog-changed', PARENT_CHILD_DATA_CHANGED: 'quartal:parent:child:data-changed', PARENT_CHILD_USER_NOTIFICATIONS: 'quartal:parent:child:user-notifications', PARENT_CHILD_CUSTOM_NOTIFICATIONS: 'quartal:parent:child:custom-notifications', PARENT_CHILD_RELOAD_PARENT: 'quartal:parent:child:reload-parent', PARENT_CHILD_CLEAR_CACHE: 'quartal:parent:child:clear-cache', }; // Internal Event Bridge constants for app-internal communication // These are used within a single app (e.g. iframe.component ↔ app.service) // Different from QUARTAL_EVENTS which are for parent ↔ child communication const INTERNAL_EVENTS = { // Events from iframe components to app service for forwarding to parent PARENT_CHILD_ALERT: 'PARENT_CHILD_ALERT', PARENT_CHILD_DIALOG_CHANGED: 'PARENT_CHILD_DIALOG_CHANGED', PARENT_CHILD_PENDING_REQUEST: 'PARENT_CHILD_PENDING_REQUEST', PARENT_CHILD_TITLE_CHANGE: 'PARENT_CHILD_TITLE_CHANGE', PARENT_CHILD_MOUSE_CLICK: 'PARENT_CHILD_MOUSE_CLICK', PARENT_CHILD_HEIGHT_CHANGE: 'PARENT_CHILD_HEIGHT_CHANGE', PARENT_CHILD_RELOAD_PARENT: 'PARENT_CHILD_RELOAD_PARENT', }; class BaseClient { constructor(config, clientType, transport) { this.transport = null; this.eventHandlers = new Map(); this.errorHandlers = new Set(); this.isDestroyed = false; this.connectionStatus = { connected: false, lastConnected: null }; this.transport = transport; this.config = { debug: false, trace: false, appPrefix: 'QC', autoConnect: true, autoNotifyUrlChanges: true, ...config }; this.logger = new QuartalLogger({ debug: this.config.debug, trace: this.config.trace, appPrefix: this.config.appPrefix, instanceId: `${clientType}_${Date.now()}` }); this.logger.info(`Created new ${clientType} client instance`); } /** * Initialize the client and establish connection */ async initialize() { if (this.isDestroyed) { throw new Error('Client has been destroyed'); } try { this.logger.debug('Initializing client...'); // Set up event listeners before initializing transport this.setupEventListeners(); // Initialize Transport await this.initializeTransport(); // Auto-connect if enabled if (this.config.autoConnect) { await this.connect(); } this.logger.info('Client initialized successfully'); } catch (error) { this.logger.error('Failed to initialize client', error); throw error; } } /** * Set up event listeners for transport events */ setupEventListeners() { if (!this.transport) { this.logger.warn('Transport not initialized, skipping event listener setup'); return; } this.logger.debug('Setting up event listeners'); // Listen for all Quartal events Object.values(QUARTAL_EVENTS).forEach(eventType => { this.transport.on(eventType, (event) => { this.handleEvent(eventType, event.data); }); }); // Listen for transport connection events this.transport.on('connect', () => { this.connectionStatus = { connected: true, lastConnected: new Date() }; this.logger.info('Transport connected'); this.onConnect(); }); this.transport.on('disconnect', () => { this.connectionStatus = { connected: false, lastConnected: this.connectionStatus.lastConnected }; this.logger.info('Transport disconnected'); this.onDisconnect(); }); this.transport.on('error', (error) => { this.connectionStatus = { connected: false, lastConnected: this.connectionStatus.lastConnected }; this.logger.error('Transport error', error); this.handleError(error); }); } /** * Handle incoming events */ handleEvent(eventType, data) { this.logger.trace(`Received event: ${eventType}`, data); const handlers = this.eventHandlers.get(eventType); if (handlers) { handlers.forEach(handler => { try { handler(data); } catch (error) { this.logger.error(`Error in event handler for ${eventType}`, error); this.handleError(error); } }); } } /** * Handle errors */ handleError(error) { this.logger.error('Client error occurred', error); this.errorHandlers.forEach(handler => { try { handler(error); } catch (handlerError) { this.logger.error('Error in error handler', handlerError); } }); } /** * Connect to the other side */ async connect() { if (!this.transport) { throw new Error('Transport not initialized'); } try { this.logger.debug('Connecting...'); await this.transport.connect(); this.logger.info('Connected successfully'); } catch (error) { this.logger.error('Failed to connect', error); throw error; } } /** * Disconnect from the other side */ async disconnect() { if (!this.transport) { return; } try { this.logger.debug('Disconnecting...'); await this.transport.disconnect(); this.logger.info('Disconnected successfully'); } catch (error) { this.logger.error('Error during disconnect', error); } } /** * Send an event to the other side */ sendEvent(eventType, data) { if (!this.transport) { this.logger.warn('Transport not initialized, cannot send event'); return; } // Allow certain initialization events even when not ready const initializationEvents = [ QUARTAL_EVENTS.CHILD_PARENT_READY, // Parent sending initial data to child QUARTAL_EVENTS.PARENT_CHILD_READY, // Child confirming it's ready QUARTAL_EVENTS.PARENT_CHILD_INIT // Child sending initial data to parent ]; // Check connection status before sending (except for initialization events) if (!initializationEvents.includes(eventType) && (!this.isReady() || !this.isConnected())) { this.logger.debug(`Client not ready/connected, skipping event: ${eventType}`); return; } try { this.logger.trace(`Sending event: ${eventType}`, data); this.transport.send(eventType, data); } catch (error) { this.logger.debug(`Failed to send event ${eventType}:`, error); } } /** * Register an event handler */ on(eventType, handler) { if (!this.eventHandlers.has(eventType)) { this.eventHandlers.set(eventType, new Set()); } this.eventHandlers.get(eventType).add(handler); this.logger.debug(`Registered handler for event: ${eventType}`); // Return unsubscribe function return () => { const handlers = this.eventHandlers.get(eventType); if (handlers) { handlers.delete(handler); if (handlers.size === 0) { this.eventHandlers.delete(eventType); } } this.logger.debug(`Unregistered handler for event: ${eventType}`); }; } /** * Register an error handler */ onError(handler) { this.errorHandlers.add(handler); this.logger.debug('Registered error handler'); return () => { this.errorHandlers.delete(handler); this.logger.debug('Unregistered error handler'); }; } /** * Check if client is connected */ isConnected() { return this.connectionStatus.connected; } /** * Destroy the client and clean up resources */ async destroy() { if (this.isDestroyed) { return; } this.logger.info('Destroying client...'); this.isDestroyed = true; try { // Disconnect if connected if (this.isConnected()) { await this.disconnect(); } // Clean up event handlers this.eventHandlers.clear(); this.errorHandlers.clear(); // Clean up transport if (this.transport) { this.transport.destroy?.(); this.transport = null; } // Reset connection status this.connectionStatus = { connected: false, lastConnected: null }; this.logger.info('Client destroyed successfully'); } catch (error) { this.logger.error('Error during destroy', error); } } /** * Get the logger instance for sharing with other components */ getLogger() { return this.logger; } /** * Check if client has been destroyed */ isClientDestroyed() { return this.isDestroyed; } /** * Update configuration */ updateConfig(newConfig) { this.config = { ...this.config, ...newConfig }; this.logger.updateConfig({ debug: this.config.debug, trace: this.config.trace, appPrefix: this.config.appPrefix }); this.logger.debug('Configuration updated', newConfig); } } /** * Transport layer abstraction for different embedding technologies * Allows the bridge client to work with iframe, postMessage, WebWorker, etc. */ /** * Base transport adapter with common functionality */ class BaseTransportAdapter { constructor() { this.eventListeners = new Map(); this.connected = false; this.destroyed = false; } on(event, handler) { if (!this.eventListeners.has(event)) { this.eventListeners.set(event, new Set()); } this.eventListeners.get(event).add(handler); } off(event, handler) { const handlers = this.eventListeners.get(event); if (!handlers) return; if (handler) { handlers.delete(handler); } else { handlers.clear(); } } emit(event, data) { const handlers = this.eventListeners.get(event); if (handlers) { handlers.forEach(handler => handler(data)); } } isConnected() { return this.connected && !this.destroyed; } } /** * Iframe transport adapter using Bellhop * Wraps existing Bellhop functionality in the new transport interface */ class IframeTransport extends BaseTransportAdapter { constructor(config = {}) { super(); this.bellhop = null; this.quartalEventListenersSetup = false; this.config = config; } async connect(target) { if (this.connected) return; // Prevent multiple simultaneous connection attempts if (this.bellhop) return; const iframeElement = target || this.config.iframeElement; try { this.bellhop = new bellhopIframe.Bellhop(); // Set debug options if (this.config.debug || this.config.trace) { this.bellhop.debug = true; } // Set up event forwarding this.bellhop.on('connected', () => { this.connected = true; this.emit('connect'); }); this.bellhop.on('disconnected', () => { this.connected = false; this.emit('disconnect'); }); this.bellhop.on('error', (error) => { this.emit('error', error); }); // Set up Quartal event forwarding this.setupQuartalEventForwarding(); // Connect to iframe (parent) or to parent window (child) if (iframeElement) { this.bellhop.connect(iframeElement); } else { this.bellhop.connect(); } // Wait for the actual connection to be established // Check bellhop's connected property directly with timeout const timeout = 5000; // 5 seconds should be enough for most cases const startTime = Date.now(); let attempts = 0; while (!this.bellhop.connected && (Date.now() - startTime) < timeout) { await new Promise(resolve => setTimeout(resolve, 50)); // Check every 50ms attempts++; } if (!this.bellhop.connected) { throw new Error(`Connection timeout: Failed to establish iframe connection after ${timeout}ms (${attempts} attempts)`); } // Update our internal state to match bellhop this.connected = this.bellhop.connected; } catch (error) { // Clean up on error this.bellhop = null; throw new Error(`Failed to initialize iframe transport: ${error}`); } } /** * Set up forwarding of all Quartal events from Bellhop */ setupQuartalEventForwarding() { if (!this.bellhop || this.quartalEventListenersSetup) return; // Forward all Quartal events Object.values(QUARTAL_EVENTS).forEach(eventType => { this.bellhop.on(eventType, (data) => { this.emit(eventType, data); }); }); this.quartalEventListenersSetup = true; } async disconnect() { if (!this.bellhop || !this.connected) return; try { await this.bellhop.disconnect(); this.connected = false; this.bellhop = null; this.quartalEventListenersSetup = false; } catch (error) { this.connected = false; this.bellhop = null; this.quartalEventListenersSetup = false; throw new Error(`Failed to disconnect iframe transport: ${error}`); } } send(type, data) { if (!this.bellhop) { throw new Error('Iframe transport not initialized'); } // Check both our internal state and bellhop's state const isConnected = this.connected || this.bellhop.connected; if (!isConnected) { throw new Error('Iframe transport not connected'); } try { this.bellhop.send(type, data); } catch (error) { throw new Error(`Failed to send message via iframe transport: ${error}`); } } destroy() { if (this.destroyed) return; try { if (this.bellhop) { this.bellhop.destroy?.(); this.bellhop = null; } this.connected = false; this.destroyed = true; this.quartalEventListenersSetup = false; this.eventListeners.clear(); } catch (error) { // Log error but don't throw during cleanup console.error('Error during iframe transport cleanup:', error); } } getType() { return 'iframe'; } /** * Get the underlying Bellhop instance (for legacy compatibility) * @deprecated Use the transport interface methods instead */ getBellhop() { return this.bellhop; } } class ParentClient extends BaseClient { constructor(config, callbacks = {}, transport) { const actualTransport = transport || new IframeTransport(); super(config, 'Parent', actualTransport); this.callbacks = {}; this.config = config; this.callbacks = callbacks; this.iframeElement = config.iframeElement; this.state = { isConnected: false, isReady: false, fastActions: [], fastActionCallback: null, quartalVersion: null, customNotificationGroups: [], lastError: null }; // Initialize the client this.initialize().catch(error => { this.logger.error('Failed to initialize parent client', error); }); } /** * Initialize transport for parent-child communication */ async initializeTransport() { try { if (this.transport instanceof IframeTransport) { await this.transport.connect(this.iframeElement); } else { await this.transport.connect(); } this.logger.debug('Transport initialized for parent'); } catch (error) { this.logger.error('Failed to initialize transport', error); throw error; } } /** * Set up parent-specific event handlers */ setupEventListeners() { super.setupEventListeners(); this.logger.debug('Setting up parent-specific event listeners'); // Parent-specific event handlers this.on(QUARTAL_EVENTS.PARENT_CHILD_READY, (data) => { this.logger.info('Child is ready', data); this.state.isReady = true; this.callbacks.onInited?.(data); }); this.on(QUARTAL_EVENTS.PARENT_CHILD_OPEN_FAST_ACTION, (data) => { this.logger.debug('Request to open fast action received from child', data); this.handleFastAction(data); }); // Handle fast actions list from child (when child sends available actions) this.on(QUARTAL_EVENTS.PARENT_CHILD_FAST_ACTIONS, (actions) => { this.logger.debug('Fast actions list received from child', actions); if (Array.isArray(actions)) { this.state.fastActions = actions; this.callbacks.onFastActions?.(actions); } }); this.on(QUARTAL_EVENTS.PARENT_CHILD_FAST_ACTION_CALLBACK, (data) => { this.logger.debug('Fast action callback received from child', data); this.state.fastActionCallback = data; this.callbacks.onFastActionCallback?.(data); }); this.on(QUARTAL_EVENTS.PARENT_CHILD_URL_CHANGE, (data) => { this.logger.debug('URL change from child', data); this.callbacks.onUrlChange?.(data.url); }); this.on(QUARTAL_EVENTS.PARENT_CHILD_HEIGHT_CHANGE, (data) => { this.logger.debug('Height change from child', data); this.callbacks.onHeightChange?.(data.height); }); this.on(QUARTAL_EVENTS.PARENT_CHILD_ALERT, (data) => { this.logger.debug('Alert from child', data); this.callbacks.onAlert?.(data); }); this.on(QUARTAL_EVENTS.PARENT_CHILD_TITLE_CHANGE, (data) => { this.logger.debug('Title change from child', data); this.callbacks.onTitleChange?.(data.title); }); this.on(QUARTAL_EVENTS.PARENT_CHILD_MOUSE_CLICK, (data) => { this.logger.debug('Mouse click from child', data); this.callbacks.onMouseClick?.(data); }); this.on(QUARTAL_EVENTS.PARENT_CHILD_ERROR, (data) => { this.logger.error('Error from child', data); this.state.lastError = data; this.callbacks.onError?.(data); }); this.on(QUARTAL_EVENTS.PARENT_CHILD_FETCH_DATA, (data) => { this.logger.debug('Fetch data from child', data); this.callbacks.onFetchData?.(data); }); this.on(QUARTAL_EVENTS.PARENT_CHILD_PENDING_REQUEST, (data) => { this.logger.debug('Pending request from child', data); this.callbacks.onPendingRequest?.(data.pending); }); this.on(QUARTAL_EVENTS.PARENT_CHILD_DIALOG_CHANGED, (data) => { this.logger.debug('Dialog changed from child', data); this.callbacks.onDialogChanged?.(data); }); this.on(QUARTAL_EVENTS.PARENT_CHILD_DATA_CHANGED, (data) => { this.logger.debug('Data changed from child', data); this.callbacks.onDataChanged?.(data); }); this.on(QUARTAL_EVENTS.PARENT_CHILD_USER_NOTIFICATIONS, (data) => { this.logger.debug('User notifications from child', data); this.callbacks.onUserNotifications?.(data.notifications || []); }); this.on(QUARTAL_EVENTS.PARENT_CHILD_CUSTOM_NOTIFICATIONS, (data) => { this.logger.debug('Custom notifications from child', data); this.callbacks.onCustomNotifications?.(data.customNotificationGroups || []); }); this.on(QUARTAL_EVENTS.PARENT_CHILD_MAKE_PAYMENT, (data) => { this.logger.debug('Make payment request from child', data); this.callbacks.onMakePayment?.(data); }); this.on(QUARTAL_EVENTS.PARENT_CHILD_RELOAD_PARENT, (data) => { this.logger.debug('Reload parent request from child', data); this.callbacks.onReloadParent?.(); }); this.on(QUARTAL_EVENTS.PARENT_CHILD_CLEAR_CACHE, (data) => { this.logger.debug('Clear cache request from child', data); this.callbacks.onClearCache?.(data); }); } /** * Handle connection established */ onConnect() { this.state.isConnected = true; this.logger.info('Parent connected to child'); // Send initial data to child this.sendInitialData(); } /** * Handle disconnection */ onDisconnect() { this.state.isConnected = false; this.state.isReady = false; this.logger.info('Parent disconnected from child'); } /** * Send initial data to child */ sendInitialData() { const initialData = { user: this.config.user, customActions: this.config.customActions, customEntities: this.config.customEntities, customTabs: this.config.customTabs, customTables: this.config.customTables, appSettings: { navigation: this.config.showNavigation, topnavbar: this.config.showTopNavbar, footer: this.config.showFooter, showLoading: this.config.showLoading, showMessages: this.config.showMessages, showVersionUpdate: this.config.showVersionUpdate } }; this.logger.debug('Sending initial data to child', initialData); this.sendEvent(QUARTAL_EVENTS.CHILD_PARENT_READY, initialData); } /** * Handle fast action from child */ handleFastAction(action) { this.logger.debug('Handling fast action', action); // Find the action in our custom actions const customAction = this.findCustomAction(action.action); if (customAction) { this.logger.debug('Found custom action', customAction); // Execute the custom action this.executeCustomAction(customAction, action); } else { this.logger.warn('No custom action found for key', action.action); } } /** * Find custom action by key */ findCustomAction(actionName) { this.logger.debug('Finding custom action for key', actionName, this.config.customActions); if (!actionName || !this.config.customActions) return null; for (const category of Object.values(this.config.customActions)) { if (Array.isArray(category)) { const action = category.find(a => a.action === actionName); if (action) return action; } } return null; } /** * Execute custom action */ executeCustomAction(customAction, action) { if (customAction.action) { this.logger.debug('Executing custom action', customAction.action); // Delegate to the parent component's action handler if available if (this.callbacks.onCustomAction) { this.callbacks.onCustomAction(customAction.action, action.data); } else { // Fallback to logging if no handler is provided this.logger.info('Custom action executed (no handler)', { customAction, action }); } } else if (customAction.link) { this.logger.debug('Opening custom action link', customAction.link); // Handle link navigation this.openUrl(customAction.link); } } // Public API methods /** * Update the iframe element reference (useful after navigation) */ updateIframeElement(newIframeElement) { this.logger.debug('Updating iframe element reference'); const oldIframeElement = this.iframeElement; this.iframeElement = newIframeElement; // If we have an active transport connection and the iframe elements are different, // we need to update the connection if (this.transport && oldIframeElement !== newIframeElement) { this.logger.debug('Updating transport connection to new iframe element'); try { // Disconnect from old iframe this.transport.disconnect(); // Connect to new iframe if (this.transport instanceof IframeTransport) { const connectResult = this.transport.connect(this.iframeElement); // Handle Promise return if (connectResult && typeof connectResult.then === 'function') { connectResult.then(() => { this.logger.debug('Successfully reconnected to new iframe element'); }).catch((error) => { this.logger.error('Failed to reconnect to new iframe element', error); this.handleReconnectionFailure(); }); } else { this.logger.debug('Transport connection updated (synchronous)'); } } } catch (error) { this.logger.error('Error during transport reconnection', error); this.handleReconnectionFailure(); } } } /** * Handle reconnection failure by falling back to full reinitialization */ handleReconnectionFailure() { this.logger.debug('Attempting full transport reinitialization as fallback'); this.initializeTransport().catch((e) => this.logger.error('Failed to reinitialize transport during fallback', e)); } /** * Open a URL in the child iframe */ openUrl(url) { this.logger.debug('Opening URL in child', url); this.sendEvent(QUARTAL_EVENTS.CHILD_PARENT_OPEN_URL, { url }); } /** * Open a fast action in the child */ openAction(action) { this.logger.debug('Opening action in child', action); this.sendEvent(QUARTAL_EVENTS.CHILD_PARENT_OPEN_ACTION, action); } /** * Redirect the child to a new route */ redirect(route) { this.logger.debug('Redirecting child', route); this.sendEvent(QUARTAL_EVENTS.CHILD_PARENT_REDIRECT, { url: route }); } /** * Clear cache in the child */ clearCache(cacheName) { this.logger.debug('Clearing cache in child', cacheName); this.sendEvent(QUARTAL_EVENTS.CHILD_PARENT_CLEAR_CACHE, { cacheName }); } /** * Clear storage in the child */ clearStorage(request) { this.logger.debug('Clearing storage in child', request); this.sendEvent(QUARTAL_EVENTS.CHILD_PARENT_CLEAR_STORAGE, request); } /** * Logout the child */ logout() { this.logger.debug('Logging out child'); this.sendEvent(QUARTAL_EVENTS.CHILD_PARENT_LOGOUT, {}); } /** * Set fast actions for the child */ setFastActions(actions) { this.logger.debug('Setting fast actions for child', actions); this.state.fastActions = actions; this.sendEvent(QUARTAL_EVENTS.CHILD_PARENT_FAST_ACTIONS, { links: actions }); } /** * Set custom notification groups */ setCustomNotificationGroups(groups) { this.logger.debug('Setting custom notification groups', groups); this.state.customNotificationGroups = groups; } /** * Set Quartal version */ setQuartalVersion(version) { this.logger.debug('Setting Quartal version', version); this.state.quartalVersion = version; } /** * Send fetch data response to child */ sendFetchDataResponse(response) { this.logger.debug('Sending fetch data response to child', response); this.sendEvent(QUARTAL_EVENTS.CHILD_PARENT_FETCH_DATA_RESPONSE, response); } /** * Get Quartal parent interface (for backward compatibility) */ getQuartalParent() { return { openAction: (action) => this.openAction(action), redirect: (route) => this.redirect(route), clearCache: (cacheName) => this.clearCache(cacheName), clearStorage: (request) => this.clearStorage(request), logout: () => this.logout() }; } /** * Check if client is ready */ isReady() { return this.state.isReady; } /** * Get current fast actions */ getFastActions() { return [...this.state.fastActions]; } /** * Get current state */ getState() { return { ...this.state }; } /** * Update callbacks */ updateCallbacks(callbacks) { this.callbacks = { ...this.callbacks, ...callbacks }; this.logger.debug('Callbacks updated', callbacks); } } class ChildClient extends BaseClient { constructor(config, callbacks = {}, transport) { const actualTransport = transport || new IframeTransport(); super(config, 'Child', actualTransport); this.callbacks = {}; this.adapters = {}; this.config = config; this.callbacks = callbacks; this.state = { isConnected: false, isReady: false, hasInitedSent: false, appSettings: { navigation: true, topnavbar: true, footer: true, showLoading: true, showMessages: true, showVersionUpdate: false }, customEntities: [], fastAction: null, fastActionCallback: null, parentData: null, fastActions: [], lastError: null }; // Initialize the client this.initialize().catch(error => { this.logger.error('Failed to initialize child client', error); }); } /** * Initialize transport for child-parent communication */ async initializeTransport() { try { await this.transport.connect(); this.logger.debug('Transport initialized for child'); } catch (error) { this.logger.error('Failed to initialize transport', error); throw error; } } /** * Set up child-specific event handlers */ setupEventListeners() { super.setupEventListeners(); this.logger.debug('Setting up child-specific event listeners'); // Child-specific event handlers this.on(QUARTAL_EVENTS.CHILD_PARENT_READY, (data) => { this.handleParentReady(data); }); this.on(QUARTAL_EVENTS.CHILD_PARENT_FAST_ACTIONS, (data) => { this.logger.debug('Fast actions received from parent', data); this.state.fastActions = data.links || []; this.callbacks.onFastAction?.(data); }); this.on(QUARTAL_EVENTS.CHILD_PARENT_FAST_ACTION_CALLBACK, (data) => { this.logger.debug('Fast action callback received from parent', data); this.state.fastActionCallback = data; this.callbacks.onFastActionCallback?.(data); }); this.on(QUARTAL_EVENTS.CHILD_PARENT_LOGOUT, (_data) => { this.logger.debug('Logout request from parent'); this.callbacks.onLogout?.(); }); this.on(QUARTAL_EVENTS.CHILD_PARENT_RELOAD, (_data) => { this.logger.debug('Reload request from parent'); this.callbacks.onReload?.(); }); this.on(QUARTAL_EVENTS.CHILD_PARENT_CLEAR_CACHE, (data) => { this.logger.debug('Clear cache request from parent', data); this.callbacks.onClearCache?.(data.cacheName); }); this.on(QUARTAL_EVENTS.CHILD_PARENT_CLEAR_STORAGE, (data) => { this.logger.debug('Clear storage request from parent', data); this.callbacks.onClearStorage?.(data); }); this.on(QUARTAL_EVENTS.CHILD_PARENT_REDIRECT, (data) => { this.logger.debug('Redirect to URL request from parent', data); this.callbacks.onRedirect?.(data.url); }); this.on(QUARTAL_EVENTS.CHILD_PARENT_OPEN_URL, (data) => { this.logger.debug('Open URL request from parent', data); this.callbacks.onOpenUrl?.(data.url); }); this.on(QUARTAL_EVENTS.CHILD_PARENT_OPEN_ACTION, (data) => { this.logger.debug('Open action request from parent', data); this.callbacks.onOpenAction?.(data); }); this.on(QUARTAL_EVENTS.CHILD_PARENT_FETCH_DATA_RESPONSE, (data) => { this.logger.debug('Fetch data response from parent', data); this.callbacks.onFetchedData?.(data); }); } /** * Handle connection established */ onConnect() { this.state.isConnected = true; this.logger.info('Child connected to parent'); // Send init event if not already sent if (!this.state.hasInitedSent) { this.sendInit(); } } /** * Handle disconnection */ onDisconnect() { this.state.isConnected = false; this.state.isReady = false; this.logger.info('Child disconnected from parent'); } /** * Handle parent ready event */ handleParentReady(data) { // Update state with parent data if (data.user) { this.config.user = data.user; } if (data.customActions) { this.config.customActions = data.customActions; } if (data.customEntities) { this.config.customEntities = data.customEntities; } if (data.customTabs) { this.config.customTabs = data.customTabs; } if (data.customTables) { this.config.customTables = data.customTables; } if (data.appSettings) { this.state.appSettings = { ...this.state.appSettings, ...data.appSettings }; this.callbacks.onAppSettingsUpdate?.(this.state.appSettings); } else { this.logger.debug('No appSettings found in parent data:', data); } this.state.isReady = true; this.callbacks.onInited?.(this.config); } /** * Send init event to parent */ sendInit() { if (this.state.hasInitedSent) { return; } const initData = { user: this.config.user, fastActions: this.config.fastActions, customActions: this.config.customActions, customEntities: this.config.customEntities, customTabs: this.config.customTabs, customTables: this.config.customTables }; this.logger.debug('Sending init to parent', initData); this.sendEvent(QUARTAL_EVENTS.PARENT_CHILD_INIT, initData); this.state.hasInitedSent = true; } // Public API methods /** * Send inited event to parent with version information */ sendInited(data) { this.logger.debug('Sending inited to parent', data); this.sendEvent(QUARTAL_EVENTS.PARENT_CHILD_READY, data); } /** * Send available user specific (child's) fast actions to parent */ sendFastActions(actions) { this.logger.debug('Sending available fast actions to parent', actions); this.sendEvent(QUARTAL_EVENTS.PARENT_CHILD_FAST_ACTIONS, actions); } /** * Send request to open fast action in parent */ sendOpenFastAction(action) { this.logger.debug('Sending request to open fast action in parent', action); this.state.fastAction = action; this.sendEvent(QUARTAL_EVENTS.PARENT_CHILD_OPEN_FAST_ACTION, action); } /** * Send fast action callback to parent */ sendFastActionCallback(callback) { this.logger.debug('Sending fast action callback to parent', callback); this.sendEvent(QUARTAL_EVENTS.PARENT_CHILD_FAST_ACTION_CALLBACK, callback); } /** * Send URL change to parent */ sendUrlChange(url) { this.logger.debug('Sending URL change to parent', url); this.sendEvent(QUARTAL_EVENTS.PARENT_CHILD_URL_CHANGE, { url }); } /** * Send height change to parent */ sendHeightChange(height) { this.logger.debug('Sending height change to parent', height); this.sendEvent(QUARTAL_EVENTS.PARENT_CHILD_HEIGHT_CHANGE, { height }); } /** * Send alert to parent */ sendAlert(alert) { this.logger.debug('Sending alert to parent', alert); this.sendEvent(QUARTAL_EVENTS.PARENT_CHILD_ALERT, alert); } /** * Send title change to parent */ sendTitleChange(title) { this.logger.debug('Sending title change to parent', title); this.sendEvent(QUARTAL_EVENTS.PARENT_CHILD_TITLE_CHANGE, { title }); } /** * Send mouse click to parent */ sendMouseClick() { this.logger.debug('Sending mouse click to parent'); this.sendEvent(QUARTAL_EVENTS.PARENT_CHILD_MOUSE_CLICK, {}); } /** * Send make payment request to parent */ sendMakePayment(payment) { this.logger.debug('Sending make payment request to parent'); this.sendEvent(QUARTAL_EVENTS.PARENT_CHILD_MAKE_PAYMENT, payment); } /** * Send error to parent */ sendError(error) { this.logger.error('Sending error to parent', error); this.state.lastError = error; this.sendEvent(QUARTAL_EVENTS.PARENT_CHILD_ERROR, error); } /** * Send fetch data request to parent */ sendFetchDataRequest(callbackMethod, parameter, transactionId) { this.logger.debug('Sending fetch data request to parent', callbackMethod, parameter); this.sendEvent(QUARTAL_EVENTS.PARENT_CHILD_FETCH_DATA, { method: callbackMethod, parameter: parameter, transactionId: transactionId }); } /** * Send pending request status to parent */ sendPendingRequest(pending) { this.logger.debug('Sending pending request status to parent', pending); this.sendEvent(QUARTAL_EVENTS.PARENT_CHILD_PENDING_REQUEST, { pending }); } /** * Send reload parent request to parent */ sendReloadParent() { this.logger.debug('Sending reload parent request to parent'); this.sendEvent(QUARTAL_EVENTS.PARENT_CHILD_RELOAD_PARENT, {}); } /** * Send dialog state change to parent */ sendDialogChanged(opened) { this.logger.debug('Sending dialog state change to parent', opened); this.sendEvent(QUARTAL_EVENTS.PARENT_CHILD_DIALOG_CHANGED, { opened }); } /** * Send data change notification to parent */ sendDataChanged(key, value) { this.logger.debug('Sending data change to parent', key, value); this.sendEvent(QUARTAL_EVENTS.PARENT_CHILD_DATA_CHANGED, { key, value }); } /** * Send user notifications to parent */ sendUserNotifications(notifications) { this.logger.debug('Sending user notifications to parent', notifications); this.sendEvent(QUARTAL_EVENTS.PARENT_CHILD_USER_NOTIFICATIONS, notifications); } /** * Send custom notifications to parent */ sendCustomNotifications(customNotificationGroups) { this.logger.debug('Sending custom notifications to parent', customNotificationGroups); this.sendEvent(QUARTAL_EVENTS.PARENT_CHILD_CUSTOM_NOTIFICATIONS, { customNotificationGroups }); } /** * Request parent to clear cache * @param cacheName Optional specific cache name to clear, if omitted all caches may be cleared */ clearCache(cacheName) { this.logger.debug('Requesting parent to clear cache', cacheName); this.sendEvent(QUARTAL_EVENTS.PARENT_CHILD_CLEAR_CACHE, { cacheName }); } /** * Set adapters for framework integration */ setAdapters(adapters) { this.adapters = { ...this.adapters, ...adapters }; this.logger.debug('Adapters updated', adapters); } /** * Get current user */ getCurrentUser() { return this.config.user || null; } /** * Navigate to a route */ navigate(route) { if (this.adapters.router) { this.adapters.router.navigate(route); } else { this.logger.warn('No router adapter set, cannot navigate'); } } /** * Get current URL */ getCurrentUrl() { if (this.adapters.router) { return this.adapters.router.getCurrentUrl(); } return window.location.href; } /** * Get Quartal child interface (for backward compatibility) */ getQuartalChild() { return { sendInited: (data) => this.sendInited(data), sendOpenFastAction: (action) => this.sendOpenFastAction(action), sendFastActionCallback: (callback) => this.sendFastActionCallback(callback), sendUrlChange: (url) => this.sendUrlChange(url), sendHeightChange: (height) => this.sendHeightChange(height), sendAlert: (alert) => this.sendAlert(alert), sendTitleChange: (title) => this.sendTitleChange(title), sendError: (error) => this.sendError(error), sendReloadParent: () => this.sendReloadParent(), clearCache: (cacheName) => this.clearCache(cacheName), getCurrentUser: () => this.getCurrentUser(), navigate: (route) => this.navigate(route), getCurrentUrl: () => this.getCurrentUrl() }; } /** * Get current app settings */ getAppSettings() { return { ...this.state.appSettings }; } /** * Get current custom entities */ getCustomEntities() { return [...this.state.customEntities]; } /** * Get current fast actions */ getFastActions() { return [...this.state.fastActions]; } /** * Check if client is ready */ isReady() { return this.state.isReady; } /** * Check if client is connected */ isConnected() { return this.state.isConnected; } /** * Get current state (required by BaseClient) */ getState() { return { ...this.state }; } /** * Update callbacks */ updateCallbacks(callbacks) { this.callbacks = { ...this.callbacks, ...callbacks }; this.logger.debug('Callbacks updated', callbacks); } } /** * Global Parent Client Manager * * This singleton manages the parent client lifecycle at the application level * instead of component level, preventing unnecessary destruction/recreation * when navigating between views. * * Benefits: * - Faster navigation (no reconnection overhead) * - No connection state issues * - Framework agnostic (works with Angular, Vue, React, etc.) * - Better resource management * - Scalable for multiple partners */ class ParentClientManager { constructor() { this.parentClient = null; this.activeIframeCount = 0; this.config = null; this.callbacks = null; this.logger = getGlobalLogger(); } /** * Get singleton instance */ static getInstance() { if (!ParentClientManager.instance) { ParentClientManager.instance = new ParentClientManager(); } return ParentClientManager.instance; } /** * Initialize or get existing parent client * This should be called when the first iframe component is created */ initializeParentClient(config, callbacks = {}) { // Check if existing client is destroyed and clean up if needed if (this.parentClient && this.parentClient.isClientDestroyed()) { this.logger.debug('Existing client is destroyed, clearing reference'); this.parentClient = null; this.config = null; this.callbacks = null; } // If client exists and config hasn't changed, return existing if (this.parentClient && this.configMatches(config)) { this.logger.debug('Reusing existing parent client'); this.activeIframeCount++;