503 lines
14 KiB
TypeScript
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;
|
|
}
|
|
}
|