@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
Markdown
# 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