import { ProtocolError } from './validation.js'; export function exhibitURL(value, base) { if (!value.trim()) throw new Error('Enter an exhibit URL or served path.'); const url = new URL(value.trim(), base); if (!['http:', 'https:'].includes(url.protocol) || url.origin !== new URL(base).origin || url.username || url.password) { throw new Error('Enter a same-origin HTTP exhibit URL or served path.'); } return url.href; } // Frame lifecycle only; protocol negotiation remains in ExhibitHost. export class ExhibitConnection { constructor({ host, createFrame, transport, base, changed = () => {}, loadTimeoutMs = 15000 }) { Object.assign(this, { host, createFrame, transport, base, changed, loadTimeoutMs }); this.frame = null; this.url = ''; this.loading = false; this.generation = 0; } disconnect() { this.generation++; clearTimeout(this.timer); this.loading = false; this.frame?.remove(); this.frame = null; this.host.disconnect(); this.changed(); } load(value) { const url = exhibitURL(value, this.base); // Invalid input preserves the active exhibit. this.disconnect(); this.url = url; this.loading = true; const generation = this.generation; const frame = this.createFrame(); this.frame = frame; frame.addEventListener('load', () => { if (generation !== this.generation) return; clearTimeout(this.timer); this.loading = false; this.attach(); }); this.timer = setTimeout(() => { if (generation !== this.generation) return; this.disconnect(); this.host.report(new ProtocolError('LOAD_TIMEOUT', 'Exhibit did not finish loading. Check the served path and reconnect.')); }, this.loadTimeoutMs); frame.src = url; this.changed(); } async attach() { if (!this.frame || this.loading) return; const generation = this.generation; try { await this.host.connect(this.transport(this.frame), this.url); } catch (error) { if (generation === this.generation && !error.code) this.host.report(error); } this.changed(); } reconnect() { if (this.frame && !this.loading) return this.attach(); if (this.url) this.load(this.url); } }