import { customElement, DeesElement, type TemplateResult, html, property, css, cssManager, state, query, } from '@design.estate/dees-element'; import { DeesIcon } from '@design.estate/dees-catalog'; import type { IDataProvider, IProviderMessage, IEcobridgeMessage, IProviderReadyPayload, IProviderResponsePayload, IProviderDataOfferPayload, IFeatureChangeRequest, TProviderFeature, TProviderStatus, ISendDataPayload, IRequestDataPayload, } from '../interfaces/dataprovider.js'; import { demo } from './eco-provider-frame.demo.js'; // Ensure components are registered DeesIcon; declare global { interface HTMLElementTagNameMap { 'eco-provider-frame': EcoProviderFrame; } } @customElement('eco-provider-frame') export class EcoProviderFrame extends DeesElement { public static demo = demo; public static demoGroup = 'Elements'; public static styles = [ cssManager.defaultStyles, css` :host { display: block; width: 100%; height: 100%; position: relative; } .provider-frame-container { width: 100%; height: 100%; position: relative; background: ${cssManager.bdTheme('#ffffff', 'hsl(240 6% 8%)')}; border-radius: 8px; overflow: hidden; } iframe { width: 100%; height: 100%; border: none; } .loading-overlay { position: absolute; top: 0; left: 0; right: 0; bottom: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 16px; background: ${cssManager.bdTheme('rgba(255,255,255,0.9)', 'rgba(0,0,0,0.8)')}; color: ${cssManager.bdTheme('hsl(0 0% 40%)', 'hsl(0 0% 60%)')}; z-index: 10; } .loading-overlay dees-icon { animation: spin 1s linear infinite; } @keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } .error-overlay { position: absolute; top: 0; left: 0; right: 0; bottom: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 16px; background: ${cssManager.bdTheme('rgba(255,255,255,0.95)', 'rgba(0,0,0,0.9)')}; color: ${cssManager.bdTheme('hsl(0 0% 30%)', 'hsl(0 0% 70%)')}; z-index: 10; padding: 32px; text-align: center; } .error-overlay .error-icon { color: hsl(0 72% 50%); } .error-title { font-size: 16px; font-weight: 600; color: hsl(0 72% 50%); } .error-message { font-size: 14px; max-width: 400px; line-height: 1.5; } .retry-button { margin-top: 8px; padding: 10px 20px; font-size: 14px; font-weight: 500; background: hsl(217 91% 60%); color: white; border: none; border-radius: 6px; cursor: pointer; transition: background 0.15s ease; } .retry-button:hover { background: hsl(217 91% 55%); } .disconnected-overlay { position: absolute; top: 0; left: 0; right: 0; bottom: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; background: ${cssManager.bdTheme('rgba(255,255,255,0.95)', 'rgba(0,0,0,0.9)')}; color: ${cssManager.bdTheme('hsl(0 0% 50%)', 'hsl(0 0% 60%)')}; z-index: 10; } `, ]; @property({ type: String }) accessor providerId = ''; @property({ type: String }) accessor providerUrl = ''; @property({ type: String }) accessor providerName = ''; @property({ type: Array }) accessor confirmedFeatures: TProviderFeature[] = []; @property({ type: Boolean }) accessor sandboxed = true; @state() accessor status: TProviderStatus = 'loading'; @state() accessor error: string | null = null; @state() accessor currentFeatures: TProviderFeature[] = []; @query('iframe') accessor iframe: HTMLIFrameElement | null; private messageHandler: ((e: MessageEvent) => void) | null = null; private pendingRequests: Map void; reject: (error: Error) => void; timeout: ReturnType; }> = new Map(); async connectedCallback(): Promise { await super.connectedCallback(); this.setupMessageListener(); } async disconnectedCallback(): Promise { await super.disconnectedCallback(); this.cleanupMessageListener(); this.clearPendingRequests(); } private setupMessageListener(): void { this.messageHandler = (event: MessageEvent) => { this.handleProviderMessage(event); }; window.addEventListener('message', this.messageHandler); } private cleanupMessageListener(): void { if (this.messageHandler) { window.removeEventListener('message', this.messageHandler); this.messageHandler = null; } } private clearPendingRequests(): void { for (const [requestId, pending] of this.pendingRequests) { clearTimeout(pending.timeout); pending.reject(new Error('Provider disconnected')); } this.pendingRequests.clear(); } public render(): TemplateResult { return html`
${this.providerUrl ? html` ` : ''} ${this.renderOverlay()}
`; } private renderOverlay(): TemplateResult | null { switch (this.status) { case 'loading': return html`
Loading provider...
`; case 'error': return html`
Connection Error ${this.error || 'Failed to connect to provider'}
`; case 'disconnected': return html`
Provider disconnected
`; default: return null; } } private handleIframeLoad(): void { // Start waiting for provider-ready message // If not received within timeout, show error setTimeout(() => { if (this.status === 'loading') { this.status = 'error'; this.error = 'Provider did not respond. Make sure it implements the ecobridge provider protocol.'; } }, 10000); // 10 second timeout for provider to declare ready } private handleIframeError(): void { this.status = 'error'; this.error = 'Failed to load provider URL'; } private handleProviderMessage(event: MessageEvent): void { // Verify origin matches provider URL if (!this.providerUrl) return; try { const providerOrigin = new URL(this.providerUrl).origin; if (event.origin !== providerOrigin) return; } catch { return; } const message = event.data as IProviderMessage; if (!message || typeof message.type !== 'string') return; // Only accept messages from this provider if (message.providerId && message.providerId !== this.providerId) return; switch (message.type) { case 'provider-ready': this.handleProviderReady(message.payload as IProviderReadyPayload); break; case 'provider-features': this.handleFeaturesUpdate(message.payload as { features: TProviderFeature[] }); break; case 'provider-response': this.handleProviderResponse(message.requestId!, message.payload as IProviderResponsePayload); break; case 'provider-error': this.handleProviderError(message.payload as { error: string }); break; case 'data-offer': this.handleDataOffer(message.payload as IProviderDataOfferPayload); break; } } private handleProviderReady(payload: IProviderReadyPayload): void { this.currentFeatures = payload.features || []; // Check for feature changes if (this.confirmedFeatures.length > 0) { const added = this.currentFeatures.filter(f => !this.confirmedFeatures.includes(f)); const removed = this.confirmedFeatures.filter(f => !this.currentFeatures.includes(f)); if (added.length > 0 || removed.length > 0) { const request: IFeatureChangeRequest = { providerId: this.providerId, providerName: payload.name || this.providerName, addedFeatures: added, removedFeatures: removed, currentFeatures: this.currentFeatures, }; this.dispatchEvent(new CustomEvent('provider-features-changed', { detail: { request }, bubbles: true, composed: true, })); } } this.status = 'connected'; const provider: IDataProvider = { id: this.providerId, name: payload.name || this.providerName, url: this.providerUrl, features: this.currentFeatures, confirmedFeatures: this.confirmedFeatures, icon: payload.icon, lastSeen: new Date(), status: 'connected', enabled: true, }; this.dispatchEvent(new CustomEvent('provider-ready', { detail: { provider }, bubbles: true, composed: true, })); } private handleFeaturesUpdate(payload: { features: TProviderFeature[] }): void { const previousFeatures = [...this.currentFeatures]; this.currentFeatures = payload.features || []; const added = this.currentFeatures.filter(f => !previousFeatures.includes(f)); const removed = previousFeatures.filter(f => !this.currentFeatures.includes(f)); if (added.length > 0 || removed.length > 0) { const request: IFeatureChangeRequest = { providerId: this.providerId, providerName: this.providerName, addedFeatures: added, removedFeatures: removed, currentFeatures: this.currentFeatures, }; this.dispatchEvent(new CustomEvent('provider-features-changed', { detail: { request }, bubbles: true, composed: true, })); } } private handleProviderResponse(requestId: string, response: IProviderResponsePayload): void { const pending = this.pendingRequests.get(requestId); if (pending) { clearTimeout(pending.timeout); this.pendingRequests.delete(requestId); pending.resolve(response); } this.dispatchEvent(new CustomEvent('provider-response', { detail: { requestId, response }, bubbles: true, composed: true, })); } private handleProviderError(payload: { error: string }): void { this.dispatchEvent(new CustomEvent('provider-error', { detail: { error: payload.error }, bubbles: true, composed: true, })); } private handleDataOffer(offer: IProviderDataOfferPayload): void { this.dispatchEvent(new CustomEvent('provider-data-offer', { detail: { offer }, bubbles: true, composed: true, })); } // Public API public retry(): void { this.status = 'loading'; this.error = null; if (this.iframe) { this.iframe.src = this.providerUrl; } } public sendMessage(message: IEcobridgeMessage): void { if (!this.iframe?.contentWindow) { console.warn('Cannot send message: iframe not ready'); return; } try { const origin = new URL(this.providerUrl).origin; this.iframe.contentWindow.postMessage(message, origin); } catch (e) { console.error('Failed to send message to provider:', e); } } public async sendData(data: ISendDataPayload, timeoutMs = 30000): Promise { const requestId = crypto.randomUUID(); return new Promise((resolve, reject) => { const timeout = setTimeout(() => { this.pendingRequests.delete(requestId); reject(new Error('Request timeout')); }, timeoutMs); this.pendingRequests.set(requestId, { resolve, reject, timeout }); this.sendMessage({ type: 'send-data', requestId, payload: data, }); }); } public async requestData(request: IRequestDataPayload, timeoutMs = 30000): Promise { const requestId = crypto.randomUUID(); return new Promise((resolve, reject) => { const timeout = setTimeout(() => { this.pendingRequests.delete(requestId); reject(new Error('Request timeout')); }, timeoutMs); this.pendingRequests.set(requestId, { resolve, reject, timeout }); this.sendMessage({ type: 'request-data', requestId, payload: request, }); }); } public ping(): void { this.sendMessage({ type: 'ping', requestId: crypto.randomUUID(), }); } public hasFeature(feature: TProviderFeature): boolean { return this.confirmedFeatures.includes(feature); } public getStatus(): TProviderStatus { return this.status; } }