Files
catalog/ts_web/elements/eco-provider-frame/eco-provider-frame.ts

503 lines
14 KiB
TypeScript

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<string, {
resolve: (value: IProviderResponsePayload) => void;
reject: (error: Error) => void;
timeout: ReturnType<typeof setTimeout>;
}> = new Map();
async connectedCallback(): Promise<void> {
await super.connectedCallback();
this.setupMessageListener();
}
async disconnectedCallback(): Promise<void> {
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`
<div class="provider-frame-container">
${this.providerUrl ? html`
<iframe
src=${this.providerUrl}
sandbox=${this.sandboxed ? 'allow-scripts allow-same-origin allow-forms' : ''}
@load=${this.handleIframeLoad}
@error=${this.handleIframeError}
></iframe>
` : ''}
${this.renderOverlay()}
</div>
`;
}
private renderOverlay(): TemplateResult | null {
switch (this.status) {
case 'loading':
return html`
<div class="loading-overlay">
<dees-icon .icon=${'lucide:loader'} .iconSize=${32}></dees-icon>
<span>Loading provider...</span>
</div>
`;
case 'error':
return html`
<div class="error-overlay">
<dees-icon class="error-icon" .icon=${'lucide:alertCircle'} .iconSize=${48}></dees-icon>
<span class="error-title">Connection Error</span>
<span class="error-message">${this.error || 'Failed to connect to provider'}</span>
<button class="retry-button" @click=${this.retry}>Retry</button>
</div>
`;
case 'disconnected':
return html`
<div class="disconnected-overlay">
<dees-icon .icon=${'lucide:unplug'} .iconSize=${32}></dees-icon>
<span>Provider disconnected</span>
</div>
`;
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<IProviderResponsePayload> {
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<IProviderResponsePayload> {
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;
}
}