Step 5 Complete

This commit is contained in:
2026-09-14 13:01:45 -07:00
parent 76928f7870
commit 961919e017
34 changed files with 3309 additions and 273 deletions
+49
View File
@@ -0,0 +1,49 @@
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)); }
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);
}
}
+36 -17
View File
@@ -1,12 +1,21 @@
import { ExhibitHost } from './host.js';
import { argumentSchema } from './validation.js';
import { postMessageTransport } from './transport/post-message.js';
import { ExhibitConnection } from './connection.js';
const host = new ExhibitHost();
const byId = id => document.getElementById(id);
const json = value => JSON.stringify(value, null, 2);
const rows = new Map();
let frame = null, renderedCatalog = null;
let renderedCatalog = null;
const connection = new ExhibitConnection({ host, base: location.href, transport: postMessageTransport, changed: render,
createFrame() {
const frame = document.createElement('iframe'); frame.title = 'Connected exhibit';
// Append only after src is assigned to avoid negotiating with the blank document.
queueMicrotask(() => { if (connection.frame === frame) byId('frame-container').append(frame); });
return frame;
}
});
function element(tag, text, parent) {
const node = document.createElement(tag); if (text !== undefined) node.textContent = text;
parent?.append(node); return node;
@@ -38,8 +47,15 @@ function editor(spec, label, parent) {
}
function buildCatalog() {
rows.clear(); byId('catalog').replaceChildren();
if (!host.catalog.length) element('p', 'Connect an exhibit to discover its controls.', byId('catalog'));
const groups = new Map();
for (const target of host.catalog) {
const section = element('article', undefined, byId('catalog')); section.className = 'target';
const category = target.category || 'Other';
if (!groups.has(category)) {
const group = element('div', undefined, byId('catalog')); group.className = 'target-group';
element('h3', category, group); groups.set(category, group);
}
const section = element('article', undefined, groups.get(category)); section.className = 'target';
element('h3', target.label ?? target.id, section); element('code', target.id, section);
element('p', `${target.kind} · ${target.readable ? 'readable' : 'not readable'} · ${target.writable ? 'writable' : 'not writable'}`, section);
const current = element('output', '', section);
@@ -85,16 +101,27 @@ function buildCatalog() {
}
}
function render() {
byId('status').textContent = `${host.status} · ${host.sync}`;
byId('status').textContent = connection.loading ? 'Loading exhibit…' : `${host.status} · ${host.sync}`;
byId('status').dataset.state = host.status;
byId('identity').textContent = host.exhibit
? `${host.exhibit.product} · ${host.exhibit.version ?? ''} · Contract ${host.contract.major}.${host.contract.minor}`
: 'No exhibit connected. Enter a served path to begin.';
byId('loaded-url').textContent = connection.url ? `Exhibit location: ${connection.url}` : '';
byId('metadata').textContent = json({ sessionId: host.sessionId, contract: host.contract, exhibit: host.exhibit,
registryRevision: host.registryRevision, stateRevision: host.stateRevision, sequence: host.sequence });
byId('capabilities').textContent = json(host.capabilities);
byId('capabilities').replaceChildren();
if (!host.capabilities.length) element('p', host.sessionId ? 'No capabilities declared.' : 'Capabilities appear after discovery.', byId('capabilities'));
for (const capability of host.capabilities) element('li', `${capability.id}: ${capability.state}`, byId('capabilities'));
byId('state').textContent = json(Object.fromEntries(host.values));
byId('events').textContent = json(host.eventLog);
byId('errors').textContent = json(host.logs);
const latest = host.logs.findLast(entry => entry.code !== 'SESSION');
byId('latest-error').textContent = latest ? `Latest diagnostic (${latest.time}): ${latest.code}: ${latest.message}` : '';
byId('latest-error').hidden = !latest;
byId('target-count').textContent = `(${host.catalog.length})`;
byId('refresh').disabled = host.status !== 'connected';
byId('reconnect').disabled = !frame;
byId('reconnect').disabled = !connection.url || connection.loading || host.status === 'negotiating';
byId('disconnect').disabled = !connection.frame;
if (host.catalog !== renderedCatalog) { renderedCatalog = host.catalog; buildCatalog(); }
for (const row of rows.values()) {
row.current.textContent = host.values.has(row.target.id) ? `Reported value: ${json(host.values.get(row.target.id))}` : 'No persistent value reported.';
@@ -106,22 +133,14 @@ function render() {
if (row.submit) row.submit.disabled = row.busy || host.status !== 'connected' || !!row.schemaError || unavailable.length > 0;
}
}
async function attach() {
if (!frame) return;
try { await host.connect(postMessageTransport(frame)); } catch (error) { if (!error.code) host.report(error); }
}
byId('connection').addEventListener('submit', event => {
event.preventDefault();
try {
const url = new URL(byId('exhibit-url').value, location.href);
if (url.origin !== location.origin || !['http:', 'https:'].includes(url.protocol)) throw new Error('Enter a same-origin HTTP exhibit URL.');
host.disconnect(); frame?.remove();
frame = document.createElement('iframe'); frame.title = 'Connected exhibit';
frame.addEventListener('load', attach); frame.src = url.href;
byId('frame-container').append(frame); render();
connection.load(byId('exhibit-url').value);
} catch (error) { host.report(error); }
});
byId('reconnect').addEventListener('click', attach);
byId('disconnect').addEventListener('click', () => { host.disconnect(); frame?.remove(); frame = null; render(); });
byId('reconnect').addEventListener('click', () => connection.reconnect());
byId('disconnect').addEventListener('click', () => connection.disconnect());
byId('refresh').addEventListener('click', () => host.refresh().catch(error => host.report(error)));
byId('clear-errors').addEventListener('click', () => { host.logs = []; render(); });
host.subscribe(render); render();
+2
View File
@@ -92,6 +92,8 @@ export function validateCatalog(message) {
&& typeof t.readable === 'boolean' && typeof t.writable === 'boolean'
&& Array.isArray(t.requires) && t.requires.every(id => typeof id === 'string'), 'Invalid target descriptor.');
ids.add(t.id);
check(t.restorable === undefined || typeof t.restorable === 'boolean', 'Invalid target restorable metadata.');
check(t.category === undefined || typeof t.category === 'string', 'Invalid target category metadata.');
if (t.kind === 'range') check(Number.isFinite(t.min) && Number.isFinite(t.max) && t.min <= t.max
&& Number.isFinite(t.step) && t.step > 0, 'Invalid range descriptor.');
if (t.kind === 'selection') check(Array.isArray(t.options)