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,323 lines (1,083 loc) 38.2 kB
# Quartal Bridge Client Universal client library for embedding applications with URL-configurable transport support and framework adapters for Angular and Vue. ## Features - 🚀 **Framework-agnostic** - Works without Angular/Vue dependencies - 🔧 **Framework Adapters** - Easy integration for Angular and Vue - 📦 **Treeshakable** - Only used parts included in bundle - 🔄 **Multiple Transports** - Iframe and PostMessage communication - 🌐 **URL Configuration** - Configure transport via URL parameters - 🎯 **Singleton pattern** - One instance per application - 🐛 **Debug logging** - Comprehensive logging and error handling - 📱 **TypeScript** - Full TypeScript support - 🎨 **Named instances** - Multiple isolated instances for complex applications ## Installation ```bash npm install @quartal/bridge-client # or yarn add @quartal/bridge-client # or pnpm add @quartal/bridge-client ``` ## Quick Start ### URL-Based Transport Configuration (Recommended) Child applications automatically detect transport configuration from URL parameters: ```html <!-- Iframe transport (default) --> <iframe src="https://your-app.com/quartal-accounting-embed"></iframe> <!-- PostMessage transport for popups --> <iframe src="https://your-app.com/quartal-accounting-embed?transport=postmessage&debug=true"></iframe> ``` ### Vue.js Child Application ```typescript import { QuartalVueAdapter } from '@quartal/bridge-client'; // Automatically uses URL parameters for transport configuration const adapter = new QuartalVueAdapter(router); const childClient = adapter.createChildClient({ appPrefix: 'VUE-APP', autoConnect: true }, { onInited: (data) => console.log('Connected to parent:', data), onOpenUrl: (url) => router.push(url) }); ``` ### Angular Child Application ```typescript import { QuartalAngularAdapter } from '@quartal/bridge-client'; // Automatically uses URL parameters for transport configuration ```typescript const adapter = new QuartalAngularAdapter(router); ``` const childClient = adapter.createChildClient({ appPrefix: 'NG-APP', autoConnect: true }, { onInited: (data) => console.log('Connected to parent:', data), onRedirect: (url) => this.router.navigate(url) }); ``` ## URL Transport Configuration Configure transport behavior via URL parameters without code changes: ### Supported Parameters | Parameter | Values | Default | Description | |-----------|--------|---------|-------------| | `transport` | `iframe`, `postmessage` | `iframe` | Transport type | | `debug` | `true`, `false` | `false` | Enable debug logging | | `trace` | `true`, `false` | `false` | Enable trace logging | | `targetOrigin` | URL or `*` | `*` | PostMessage target origin | | `channel` | string | `quartal-bridge` | PostMessage channel | | `timeout` | number | `10000` | Connection timeout (ms) | ### Examples ```html <!-- Standard iframe embedding --> <iframe src="https://app.com/embed"></iframe> <!-- PostMessage for popups with debug --> <iframe src="https://app.com/embed?transport=postmessage&debug=true"></iframe> <!-- Secure PostMessage with specific origin --> <iframe src="https://app.com/embed?transport=postmessage&targetOrigin=https://parent.com"></iframe> ``` ### JavaScript Popup ```javascript const popup = window.open( 'https://app.com/embed?transport=postmessage&targetOrigin=' + window.location.origin, 'quartal-app', 'width=1200,height=800' ); ``` For detailed configuration options, see [URL Transport Configuration](./docs/URL_TRANSPORT_CONFIG.md). ## Architecture Patterns ### Multi-level iframe Communication The Quartal Client supports complex iframe chains with different integration patterns: ``` partner-ui (Framework agnostic parent) ↓ parentClientManager.initializeParentClient() ↓ quartal-invoicing-ui (Angular Hybrid: child + parent) ↓ QuartalAngularAdapter.createChildClient() → AppService → QuartalEventBridge → IFrameComponent → parentClientManager.initializeParentClient() ↓ quartal-accounting-embed (Vue child) ↓ useAppService() composable with ChildClient ``` ### Integration Patterns #### 1. **Parent Applications** (Framework-agnostic) - **Pattern**: Use `parentClientManager` directly - **Benefits**: Framework-agnostic, singleton lifecycle management - **Used by**: Partners' own UI and quartal-invoicing-ui's iframe components #### 2. **Angular Child Applications** - **Pattern**: Use `QuartalAngularAdapter` - **Benefits**: Angular router/service integration, dependency injection - **Used by**: quartal-invoicing-ui (later partners' own UI) #### 3. **Vue Child Applications** - **Pattern**: Direct composable usage or `QuartalVueAdapter` - **Benefits**: Vue composable pattern, reactive integration - **Used by**: quartal-accounting-embed (later partners' own UI) ### Event Flow Examples All events follow the same technical path: **Child Client → QuartalEventBridge → Parent Client** 1. **Height Changes**: - quartal-accounting-embed (DOM observer) → `childClient.sendHeightChange()` - → quartal-invoicing-ui (`QuartalEventBridge` forwarding) - → partner-ui (`onHeightChange` callback) 2. **Dialog States**: - quartal-accounting-embed (QDialog component) → `childClient.sendDialogChanged()` - → quartal-invoicing-ui (`QuartalEventBridge` forwarding) - → partner-ui (`onDialogChanged` callback) 3. **HTTP Status**: - quartal-accounting-embed (HTTP interceptor) → `childClient.sendPendingRequest()` - → quartal-invoicing-ui (`QuartalEventBridge` forwarding) - → partner-ui (`onPendingRequest` callback) ### Hybrid Application Pattern For applications that act as both parent and child (like quartal-invoicing-ui): ```typescript import { QuartalEventBridge, INTERNAL_EVENTS } from '@quartal/client'; // quartal-invoicing-ui: Acts as child to partner applications AND parent to child applications export class AppService { private quartalClient: ChildClient; // Communicates UP to partner-ui private eventBridge: QuartalEventBridge; // Internal forwarding initializeClient() { // Use Angular adapter for child functionality const quartalAdapter = new QuartalAngularAdapter( this.router ); this.quartalClient = quartalAdapter.createChildClient({ debug: false, appPrefix: 'MyApp' }, { onInited: (data) => this.handleInitialization(data), onLogout: () => this.handleLogout() }); // Set up internal event forwarding this.setupEventForwarding(); } // Forward events from internal components to parent (partner-ui) private setupEventForwarding() { this.eventBridge.on(INTERNAL_EVENTS.PARENT_CHILD_DIALOG_CHANGED, (message) => { if (this.quartalClient?.isReady()) { this.quartalClient.sendDialogChanged(message.data.isOpen); } }); this.eventBridge.on(INTERNAL_EVENTS.PARENT_CHILD_HEIGHT_CHANGE, (message) => { if (this.quartalClient?.isReady()) { this.quartalClient.sendHeightChange(message.data.height); } }); } } export class IFrameComponent { private parentClient: ParentClient; // Communicates DOWN to child applications private eventBridge: QuartalEventBridge; // Internal forwarding // Initialize with parentClientManager for parent functionality private initializeClient() { this.parentClient = parentClientManager.initializeParentClient( { iframeElement: this.iframe.nativeElement, appPrefix: 'MyApp', debug: true, user: this.getCurrentUser() }, { onHeightChange: (height) => { // Forward to internal event bridge → AppService → partner-ui this.eventBridge.sendEvent(INTERNAL_EVENTS.PARENT_CHILD_HEIGHT_CHANGE, { height }, 'iframe-component'); }, onDialogChanged: (dialogState) => { this.eventBridge.sendEvent(INTERNAL_EVENTS.PARENT_CHILD_DIALOG_CHANGED, { isOpen: dialogState.opened }, 'iframe-component'); }, onPendingRequest: (pending) => { this.eventBridge.sendEvent(INTERNAL_EVENTS.PARENT_CHILD_PENDING_REQUEST, { pending }, 'iframe-component'); } } ); } } ``` ## Quick Start ### For Parent Applications ```typescript import { parentClientManager } from '@quartal/client'; const parentClient = parentClientManager.initializeParentClient( { iframeElement: document.getElementById('quartal-iframe') as HTMLIFrameElement, appPrefix: 'MyApp', user: { id: 'user123', email: 'user@example.com', name: 'John Doe', phone: '+358401234567' } }, { onInited: (data) => console.log('Child initialized:', data), onHeightChange: (height) => console.log('Height changed:', height) } ); ``` ### For Angular Child Applications ```typescript import { QuartalAngularAdapter } from '@quartal/client'; // In your service const adapter = new QuartalAngularAdapter(router); const childClient = adapter.createChildClient({ appPrefix: 'MyApp' }, { onInited: (data) => console.log('Ready:', data) }); ``` ### For Vue Child Applications ```typescript import { QuartalVueAdapter } from '@quartal/client'; // In your composable const adapter = new QuartalVueAdapter(router); const childClient = adapter.createChildClient({ appPrefix: 'MyApp' }, { onInited: () => console.log('Vue child ready') }); ``` ## Detailed Usage Examples > 📁 **Complete examples**: For production-ready, copy-paste examples see the [`examples/`](./examples/) directory. ### Basic Usage (without framework adapters) ```typescript import { parentClientManager, ChildClient } from '@quartal/client'; // Parent client using manager const parentClient = parentClientManager.initializeParentClient( { iframeElement: document.getElementById('quartal-iframe') as HTMLIFrameElement, appPrefix: 'MyApp', debug: true, trace: false, user: { id: 'user123', email: 'user@example.com', name: 'John Doe', phone: '+358401234567' } }, { onInited: (data) => console.log('Child initialized:', data), onHeightChange: (height) => console.log('Height changed:', height), onPendingRequest: (pending) => console.log('Pending request:', pending), onDialogChanged: (isOpen) => console.log('Dialog state:', isOpen), onAlert: (alert) => console.log('Alert:', alert), onUrlChange: (url) => console.log('URL changed:', url) } ); // Child client const childClient = new ChildClient({ debug: true, callbacks: { onLogout: () => console.log('Logout requested'), onReload: () => window.location.reload() } }); ``` ### Angular Integration ```typescript // quartal-iframe.component.ts (Angular Parent using parentClientManager) import { Component, ElementRef, ViewChild, OnDestroy } from '@angular/core'; import { parentClientManager, ParentClient } from '@quartal/client'; @Component({ selector: 'app-quartal-iframe', template: '<iframe #quartalIframe id="quartalIframe" [src]="iframeUrl"></iframe>' }) export class QuartalIframeComponent implements OnDestroy { @ViewChild('quartalIframe') iframe!: ElementRef<HTMLIFrameElement>; private parentClient?: ParentClient; ngAfterViewInit() { this.initializeClient(); } private async initializeClient() { const iframeElement = this.iframe.nativeElement; try { this.parentClient = parentClientManager.initializeParentClient( { iframeElement, appPrefix: 'MyApp', debug: false, trace: false, showNavigation: false, showTopNavbar: false, showFooter: false, showLoading: false, showMessages: false, customActions: this.getCustomActions(), customEntities: this.getCustomEntities(), customTabs: this.getCustomTabs(), customTables: this.getCustomTables(), user: this.user ? { id: String(this.user.id ?? this.user.username), email: this.user.email ?? this.user.username, name: this.user.name, phone: this.user.phone || } : undefined }, { onInited: (data) => { console.log('(MyApp) Child initialized:', data); this.openUrl(this.router.routerState.snapshot.url); }, onHeightChange: (height: number) => { this.handleHeightChange(height); }, onPendingRequest: (pending: boolean) => { this.httpStatus.setPendingRequests(pending); }, onDialogChanged: (dialogState: any) => { this.handleDialogChanged(dialogState); }, onAlert: (alert: any) => { this.handleAlert(alert); }, onUrlChange: (url: string) => { this.handleUrlChange(url); }, onMouseClick: () => { this.iframeService.setClick(); }, onCustomAction: (action: string, data: any) => { this.openParentAction(action, data); }, onError: (error: Error) => { console.error('(MyApp) Parent client error:', error); } } ); // Store globally for service access this.iframeService.setEmbedContainer(this.parentClient); } catch (error) { console.error('(MyApp) Failed to initialize parent client:', error); } } private handleHeightChange(height: number) { if (!this.dialogOpened) { setTimeout(() => { this.height = height + 'px'; }, 0); } } private handleDialogChanged(dialogState: any) { if (dialogState.opened) { this.renderer.addClass(document.body, 'dialog-opened'); this.originalHeight = this.height && this.height != '0px' ? this.height : '100vh'; this.height = '100vh'; this.dialogOpened = true; } else { this.renderer.removeClass(document.body, 'dialog-opened'); this.height = this.originalHeight; this.dialogOpened = false; } } ngOnDestroy() { // Unregister from parent client manager parentClientManager.unregisterIframeComponent(); this.iframeService.setEmbedContainer(null); } } // app.service.ts (Angular Child using Adapter and EventBridge) import { Injectable } from '@angular/core'; import { QuartalAngularAdapter, ChildClient, QuartalEventBridge, INTERNAL_EVENTS } from '@quartal/client'; @Injectable({ providedIn: 'root' }) export class AppService { private quartalClient?: ChildClient; private eventBridge = QuartalEventBridge.getInstance('quartal-invoicing-ui'); constructor( private router: Router ) {} initializeClient() { const quartalAdapter = new QuartalAngularAdapter( this.router ); this.quartalClient = quartalAdapter.createChildClient({ debug: false, trace: false, appPrefix: 'MyApp', autoConnect: true }, { onInited: (data) => { console.log('(MyApp) Child client initialized:', data); this.setAppSettings(data); this.initializeEventListeners(); }, onOpenUrl: (url: string) => { console.log('(MyApp) Open URL in parent:', url); this.openUrl(url); }, onRedirect: (url: string[]) => { console.log('(MyApp) Redirect URL in parent:', url); this.redirectTo(url); }, onLogout: () => { console.log('(MyApp) Logout from parent'); localStorage.removeItem('idToken'); } }); // Set up event forwarding for hybrid apps this.setupEventForwarding(); } private setupEventForwarding() { // Forward internal events to parent using imported INTERNAL_EVENTS this.eventBridge.on(INTERNAL_EVENTS.PARENT_CHILD_DIALOG_CHANGED, (message) => { if (this.quartalClient?.isReady()) { this.quartalClient.sendDialogChanged(message.data.isOpen); } }); this.eventBridge.on(INTERNAL_EVENTS.PARENT_CHILD_PENDING_REQUEST, (message) => { if (this.quartalClient?.isReady()) { this.quartalClient.sendPendingRequest(message.data.pending); } }); this.eventBridge.on(INTERNAL_EVENTS.PARENT_CHILD_ALERT, (message) => { if (this.quartalClient?.isReady()) { this.quartalClient.sendAlert(message.data); } }); this.eventBridge.on(INTERNAL_EVENTS.PARENT_CHILD_TITLE_CHANGE, (message) => { if (this.quartalClient?.isReady()) { this.quartalClient.sendTitleChange(message.data); } }); this.eventBridge.on(INTERNAL_EVENTS.PARENT_CHILD_MOUSE_CLICK, (message) => { if (this.quartalClient?.isReady()) { this.quartalClient.sendMouseClick(); } }); } } ``` ### Vue Integration ```typescript // Vue Child using QuartalVueAdapter (recommended pattern) // useAppService.ts import { getCurrentInstance } from 'vue'; import { useRouter } from 'vue-router'; import type { Router } from 'vue-router'; import { QuartalVueAdapter } from '@quartal/client'; // Global singleton state let globalVueAdapter: QuartalVueAdapter | null = null; let routerInstance: Router | null = null; function initializeVueAdapter(onReady?: () => void): QuartalVueAdapter { if (globalVueAdapter) { console.debug('(MyApp) Vue adapter already initialized'); if (onReady) onReady(); return globalVueAdapter; } console.debug('(MyApp) Creating new QuartalVueAdapter...'); try { // QuartalVueAdapter constructor takes (router) globalVueAdapter = new QuartalVueAdapter(routerInstance); // Create child client with callbacks const childClient = globalVueAdapter.createChildClient({ debug: false, trace: false, appPrefix: 'QA', autoConnect: true }, { onInited: (data) => { console.debug('(QA) Child client initialized:', data); // Handle app settings from parent if (onReady) onReady(); }, onOpenUrl: (url: string) => { console.log('(QA) Open URL in parent:', url); if (routerInstance) { routerInstance.push(url); } }, onRedirect: (url: string[]) => { console.log('(QA) Redirect URL in parent:', url); if (routerInstance) { routerInstance.push(url.join('/')); } }, onLogout: () => { console.log('(QA) Logout from parent'); localStorage.removeItem('idToken'); } }); console.debug('(MyApp) QuartalVueAdapter initialized successfully'); return globalVueAdapter; } catch (error) { console.error('(MyApp) Error initializing QuartalVueAdapter:', error); throw error; } } // Main composable function export function useAppService(onReady?: () => void) { // Get router instance if we're in a Vue component context const currentInstance = getCurrentInstance(); if (currentInstance && !routerInstance) { try { routerInstance = useRouter(); } catch (error) { console.warn('(MyApp) Could not get router instance:', error); } } // Initialize or update the adapter const adapter = initializeVueAdapter(onReady); return { adapter, openUrl: (url: string) => { const client = adapter?.getChildClient(); if (client) { try { client.navigate(url); } catch (error) { console.warn('(MyApp) Navigation failed:', error); // Fallback: send as fast action to parent client.sendOpenFastAction({ link: url }); } } else { console.warn('(MyApp) No adapter available for openUrl'); } }, notifyRouteChange: (url: string) => { const client = adapter?.getChildClient(); if (client && typeof client.sendUrlChange === 'function') { client.sendUrlChange(url); } }, setMouseClicked: () => { const client = adapter?.getChildClient(); if (client && typeof client.sendMouseClick === 'function') { client.sendMouseClick(); } }, destroy: () => { if (globalVueAdapter) { globalVueAdapter.destroy(); globalVueAdapter = null; } } }; } // Usage in Vue Component <script setup lang="ts"> import { onMounted, ref, watch } from 'vue'; import { useSession } from '@quartal/ui-accounting'; import { useAppService } from './services/useAppService'; const { user } = useSession(); // Initialize app service with Vue adapter let appService = useAppService(() => { console.log('(MyApp) Vue child client ready'); // Expose the client globally after it's ready if (appService.client) { (window as any).quartalClient = appService.client; (window as any).childClient = appService.client; } }); </script> ``` ## QuartalEventBridge - For Hybrid Applications Hybrid applications (that act as both parent and child) can use `QuartalEventBridge` for internal event forwarding. ### Use Cases - **quartal-invoicing-ui**: iframe.component (parent) → app.service (child) → partner application - **Future hybrid applications** ### Basic Example ```typescript import { QuartalEventBridge, INTERNAL_EVENTS } from '@quartal/client'; // Create or get instance for application const eventBridge = QuartalEventBridge.getInstance('partner-app'); // Parent component sends events export class IFrameComponent { private eventBridge = QuartalEventBridge.getInstance('partner-app'); private handleAlert(alert: any) { // Local handling first this.showLocalAlert(alert); // Forward to child component internally this.eventBridge.sendEvent(INTERNAL_EVENTS.PARENT_CHILD_ALERT, alert, 'iframe-component'); } } // Child component listens and forwards upward export class AppService { private eventBridge = QuartalEventBridge.getInstance('partner-app'); private unsubscribers: (() => void)[] = []; constructor() { this.setupEventForwarding(); } private setupEventForwarding() { // Listen to internal events and forward to parent this.unsubscribers.push( this.eventBridge.on(INTERNAL_EVENTS.PARENT_CHILD_ALERT, (message) => { if (this.quartalClient?.isReady()) { this.quartalClient.sendAlert(message.data); } }) ); this.unsubscribers.push( this.eventBridge.on(INTERNAL_EVENTS.PARENT_CHILD_DIALOG_CHANGED, (message) => { if (this.quartalClient?.isReady()) { this.quartalClient.sendDialogChanged(message.data.isOpen); } }) ); } destroy() { // Cleanup listeners this.unsubscribers.forEach(unsub => unsub()); } } ``` ### Named Instances ```typescript // Different instances for different applications const partnerAppBridge = QuartalEventBridge.getInstance('partner-app'); const invoicingBridge = QuartalEventBridge.getInstance('quartal-invoicing-ui'); const embedBridge = QuartalEventBridge.getInstance('quartal-accounting-embed'); // List active instances console.log(QuartalEventBridge.getActiveInstances()); // ['quartal-invoicing-ui', 'quartal-accounting-embed'] // Destroy specific instance QuartalEventBridge.destroyInstance('quartal-accounting-embed'); ``` ### Event Types ```typescript // Supported event types - INTERNAL_EVENTS values or custom strings type EventType = keyof typeof INTERNAL_EVENTS | string; // Sending events eventBridge.sendEvent(INTERNAL_EVENTS.PARENT_CHILD_DIALOG_CHANGED, { isOpen: true }, 'iframe-component'); eventBridge.sendEvent(INTERNAL_EVENTS.PARENT_CHILD_PENDING_REQUEST, { pending: false }, 'http-interceptor'); eventBridge.sendEvent('custom', { repositoryId: '2025' }, 'period-selector'); // Listening to events eventBridge.on(INTERNAL_EVENTS.PARENT_CHILD_DIALOG_CHANGED, (message) => { console.log('Dialog event:', message.data, 'from:', message.source); }); eventBridge.on('custom', (message) => { if (message.data.repositoryId) { console.log('Repository changed to:', message.data.repositoryId); } }); ``` ## Height Detection and Reporting Automatic height detection for smooth iframe resizing: ```typescript // In child application function setupHeightDetection(client: ChildClient) { let lastHeight = 0; let debounceTimer: any = null; const detectHeight = () => { const currentHeight = document.body.scrollHeight; // Only update if height changed significantly (5px threshold) if (Math.abs(currentHeight - lastHeight) > 5) { clearTimeout(debounceTimer); // Immediate update for significant increases (new content) if (currentHeight > lastHeight + 50) { lastHeight = currentHeight; client.sendHeightChange(currentHeight); return; } // Debounced update for smaller changes debounceTimer = setTimeout(() => { lastHeight = currentHeight; client.sendHeightChange(currentHeight); }, 800); } }; // Monitor DOM changes const observer = new MutationObserver(detectHeight); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); window.addEventListener('resize', detectHeight); // Initial detection detectHeight(); } ``` ## HTTP Status Tracking Track pending requests and notify parent: ```typescript // HTTP status composable (Vue) export function useHttpStatus() { const pendingRequests = ref(new Set<string>()); const pendingRequest = computed(() => pendingRequests.value.size > 0); function addRequest(id: string) { pendingRequests.value.add(id); } function removeRequest(id: string) { pendingRequests.value.delete(id); } return { pendingRequest: readonly(pendingRequest), addRequest, removeRequest }; } // HTTP interceptor setup export function setupHttpInterceptor() { const { addRequest, removeRequest } = useHttpStatus(); const originalFetch = window.fetch; window.fetch = async (...args) => { const requestId = `fetch_${Date.now()}_${Math.random()}`; addRequest(requestId); try { const response = await originalFetch(...args); return response; } finally { removeRequest(requestId); } }; } ``` ## Events ### Parent → Child (Most Common) - `CHILD_PARENT_READY` - Parent is ready - `CHILD_PARENT_LOGOUT` - Logout request - `CHILD_PARENT_RELOAD` - Reload request - `CHILD_PARENT_OPEN_URL` - Navigate to URL - `CHILD_PARENT_REDIRECT` - Redirect to URL - `CHILD_PARENT_FAST_ACTIONS` - Fast actions available ### Child → Parent (Most Common) - `PARENT_CHILD_READY` - Child is ready - `PARENT_CHILD_INIT` - Child initialized - `PARENT_CHILD_URL_CHANGE` - URL changed - `PARENT_CHILD_HEIGHT_CHANGE` - Height changed - `PARENT_CHILD_PENDING_REQUEST` - HTTP request status - `PARENT_CHILD_DIALOG_CHANGED` - Dialog state changed - `PARENT_CHILD_MOUSE_CLICK` - Mouse activity - `PARENT_CHILD_ALERT` - Alert message - `PARENT_CHILD_ERROR` - Error occurred > **Note**: This list shows the most commonly used events. For a complete list of all available events, see the `QUARTAL_EVENTS` constant in the TypeScript definitions. ## Debug Logging ```typescript // Enable debug logging with debug: true const client = new ParentClient({ debug: true, trace: true, // More detailed logging instanceName: 'MyApp' // App prefix for logging }); // Logs show: (MyApp) [inst_1] [DEBUG] Message ``` ## Named Instances ```typescript // One instance per instanceName const client1 = new ParentClient({ instanceName: 'App1', ... }); const client2 = new ParentClient({ instanceName: 'App1', ... }); // Returns same instance const client3 = new ParentClient({ instanceName: 'App2', ... }); // New instance ``` ## Development ```bash # Install dependencies pnpm install # Build TypeScript pnpm build # Test pnpm test # Lint pnpm lint ``` ## Publishing ### Full Deployment (Recommended for Monorepo) For complete deployment to both git subtree and npm registry (works only in monorepo environment): ```bash # Deploy to both repositories (recommended workflow in monorepo) ./scripts/deploy-bridge-client.sh [patch|minor|major] # Preview deployment without making changes ./scripts/deploy-bridge-client.sh patch --dry-run # Selective deployment ./scripts/deploy-bridge-client.sh patch --git-only # Git subtree only (monorepo only) ./scripts/deploy-bridge-client.sh patch --npm-only # npm registry only ``` ### Standalone Repository Publishing When bridge-client is cloned as a standalone repository from GitHub: ```bash # Only npm publishing is supported in standalone mode ./scripts/deploy-bridge-client.sh patch --npm-only # Git operations are automatically disabled in standalone mode # The script will detect this and only perform npm publishing ``` ### Package.json Scripts ```bash # Version-specific deployment (works differently based on environment) pnpm run deploy:patch # Deploy patch version (git+npm in monorepo, npm-only in standalone) pnpm run deploy:minor # Deploy minor version (git+npm in monorepo, npm-only in standalone) pnpm run deploy:major # Deploy major version (git+npm in monorepo, npm-only in standalone) # Selective deployment pnpm run deploy:git # Deploy only to git subtree (monorepo only - will fail in standalone) pnpm run deploy:npm # Deploy only to npm registry (works in both environments) ``` ## Best Practices ### 1. Choose the Right Pattern **Parent Applications:** - ✅ Use `parentClientManager.initializeParentClient()` - ✅ Framework-agnostic singleton management - ✅ Handles iframe lifecycle automatically **Angular Child Applications:** - ✅ Use `QuartalAngularAdapter.createChildClient()` - ✅ Automatic router and service integration - ✅ Dependency injection support **Vue Child Applications:** - ✅ Use `QuartalVueAdapter.createChildClient()` - ✅ Automatic router and service integration - ✅ Vue composable pattern, reactive integration - ✅ Reactive integration with Vue ecosystem ### 2. Instance Naming - Use descriptive app prefixes: `'MyApp'` (partner's application), `'Q'` (quartal invoicing), `'QA'` (quartal accounting) - Consistent naming across all clients in the same application ### 3. Error Handling ```typescript const client = new ChildClient({ callbacks: { onInited: (config) => { try { // Your initialization logic } catch (error) { client.sendError(error); } } } }); // Or with Angular adapter const adapter = new QuartalAngularAdapter(router); const client = adapter.createChildClient(config, { onInited: (data) => { try { this.handleInitialization(data); } catch (error) { console.error('Initialization failed:', error); } } }); ``` ### 4. Cleanup ```typescript // Angular parent cleanup ngOnDestroy() { // Unregister from parent client manager parentClientManager.unregisterIframeComponent(); // Clear service references this.iframeService.setEmbedContainer(null); // Cleanup event bridges this.eventBridge?.destroy(); } // Angular child cleanup ngOnDestroy() { // Adapter handles cleanup automatically this.quartalAdapter?.destroy(); } // Vue child cleanup onBeforeUnmount(() => { childClient?.destroy(); // or if using adapter adapter?.destroy(); }); ``` ### 5. Height Detection - Use debouncing for performance - Set meaningful thresholds (5px minimum change) - Monitor DOM mutations for dynamic content ### 6. HTTP Status Tracking - Track all async operations that affect UI state - Use unique request IDs - Clean up completed requests immediately ### 7. Publishing and Versioning - **Always test locally** before publishing - **Follow semantic versioning** for releases - **Update documentation** when adding new features - **Check consuming projects** after publishing new versions - Use `--dry-run` flag to preview what would be published ## Troubleshooting ### Common Issues 1. **Events not received** - Check if client is ready: `client.isReady()` - Verify instance names match - Enable debug logging 2. **Height not updating** - Ensure MutationObserver is set up - Check CSS transitions don't interfere - Verify threshold values 3. **Multiple instances** - Use named instances for isolation - Clean up properly on component destruction ### Debug Tips ```typescript // Enable detailed logging const client = parentClientManager.initializeParentClient({ iframeElement: document.getElementById('quartal-iframe'), appPrefix: 'MyApp', debug: true, trace: true }, { onError: (error) => console.error('(MyApp) Parent client error:', error) }); // Check client state console.log('Client state:', client.getState()); console.log('Is ready:', client.isReady()); console.log('Is connected:', client.isConnected()); // Monitor events QuartalEventBridge.getInstance('your-app').on('*', (message) => { console.log('Event:', message.type, message.data, 'from:', message.source); }); ``` ## API Reference > 💡 **Production Examples**: For complete, copy-paste ready implementations, see [`examples/`](./examples/) directory with Angular and Vue examples. ### ParentClientManager ```typescript import { parentClientManager } from '@quartal/client'; // Initialize parent client with manager const parentClient = parentClientManager.initializeParentClient( config: ParentClientConfig, callbacks: ParentClientCallbacks ): ParentClient; interface ParentClientConfig { iframeElement: HTMLIFrameElement; appPrefix?: string; // Application prefix for logging (e.g., 'MyApp', 'QA') debug?: boolean; trace?: boolean; showNavigation?: boolean; showTopNavbar?: boolean; showFooter?: boolean; showLoading?: boolean; showMessages?: boolean; customActions?: CustomActions; customEntities?: CustomEntity[]; customTabs?: CustomTabs; customTables?: CustomTables; user?: { id: string; email: string; name: string; }; } interface ParentClientCallbacks { onInited?: (data: any) => void; onFastActions?: (actions: any[]) => void; onFastActionCallback?: (callback: any) => void; onUrlChange?: (url: string) => void; onHeightChange?: (height: number) => void; onAlert?: (alert: any) => void; onTitleChange?: (title: string) => void; onMouseClick?: () => void; onFetchData?: (request: any) => void; onError?: (error: Error) => void; onPendingRequest?: (pending: boolean) => void; onDialogChanged?: (dialogState: any) => void; onDataChanged?: (data: any) => void; onUserNotifications?: (notifications: any[]) => void; onMakePayment?: (payment: any) => void; onCustomAction?: (action: string, data: any) => void; } // Manager methods parentClientManager.unregisterIframeComponent(): void; // Cleanup on component destroy ``` ### ParentClient (Direct Usage) ```typescript interface ParentClientConfig { iframeElement: HTMLIFrameElement; debug?: boolean; trace?: boolean; instanceName?: string; // For named instances in hybrid apps callbacks?: { onReady?: () => void; onHeightChange?: (height: number) => void; onPendingRequest?: (pending: boolean) => void; onDialogChanged?: (isOpen: boolean) => void; onMouseClick?: () => void; onUrlChange?: (url: string) => void; onAlert?: (alert: any) => void; onError?: (error: Error) => void; }; } class ParentClient { constructor(config: ParentClientConfig); // Core methods isReady(): boolean; isConnected(): boolean; destroy(): void; // Adapter management setAdapters(adapters: Record<string, any>): void; // Communication methods sendToChild(message: any): void; // State management getState(): ParentClientState; } ``` ### ChildClient ```typescript interface ChildClientConfig { debug?: boolean; trace?: boolean; instanceName?: string; // For named instances in hybrid apps callbacks?: { onInited?: (config: any) => void; onLogout?: () => void; onReload?: () => void; onNavigate?: (url: string) => void; }; } class ChildClient { constructor(config: ChildClientConfig); // Core methods isReady(): boolean; isConnected(): boolean; destroy(): void; // Adapter management setAdapters(adapters: Record<string, any>): void; // Communication methods sendInited(data: any): void; sendHeightChange(height: number): void; sendPendingRequest(pending: boolean): void; sendDialogChanged(isOpen: boolean): void; sendMouseClick(): void; sendUrlChange(url: string): void; sendAlert(alert: any): void; sendMakePayment(payment: any): void; sendError(error: Error): void; navigate(url: string): void; // State management getState(): ChildClientState; } ``` ### Adapters ```typescript // Angular Adapter (Used in child applications) import { QuartalAngularAdapter } from '@quartal/client'; const adapter = new QuartalAngularAdapter(router); // Create child client with framework integration const childClient = adapter.createChildClient({ debug: false, trace: false, appPrefix: 'Q' }, { onInited: (data) => console.log('(MyApp) Child initialized:', data), onLogout: () => this.authService.logout() }); // Vue Router Adapter (Used directly or via QuartalVueAdapter) class VueRouterAdapter { constructor(router: Router); onUrlChange(callback: (url: string) => void): () => void; } // Vue Adapter (Optional pattern) import { QuartalVueAdapter } from '@quartal/client'; const adapter = new QuartalVueAdapter(router); const childClient = adapter.createChildClient(config, callbacks); // Angular Router Adapter (Used internally by QuartalAngularAdapter) class AngularRouterAdapter { constructor(router: Router); onUrlChange(callback: (url: string) => void): Subscription; } ``` ## License MIT ## Contributing We welcome contributions! Please see [CONTRIBUTING.md](./CONTRIBUTING.md) for development guidelines. 1. Fork the repository 2. Create a feature branch 3. Make your changes and add tests 4. Submit a pull request ### For Maintainers Internal deployment notes can be found in package.json scripts. ## Changelog ### v1.0.0 - 🎉 Initial release - ✨ Basic parent-child communication - ✨ Angular and Vue adapters - ✨ QuartalEventBridge for hybrid applications - ✨ Named instances support - ✨ Automatic height detection and reporting - ✨ HTTP status tracking - 🐛 Debug logging