@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
JavaScript
'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++;