Post Step 4 Completion

This commit is contained in:
2026-09-14 07:57:18 -07:00
parent db973a86f3
commit 446bf3533e
52 changed files with 32034 additions and 3 deletions
+199
View File
@@ -0,0 +1,199 @@
import { ProtocolError, record, counter, check, validateCatalog, validateArgs, validateSet, validateReportedValue } from './validation.js';
const events = new Set(['state.changed', 'selection.changed', 'action.executed', 'capability.changed', 'registry.changed', 'error']);
const responses = { hello: 'hello.result', describe: 'describe.result', 'state.get': 'state.result', set: 'set.result', invoke: 'invoke.result' };
export class ExhibitHost {
constructor({ timeoutMs = 5000, debounceMs = 100 } = {}) {
this.timeoutMs = timeoutMs; this.debounceMs = debounceMs;
this.listeners = new Set(); this.pending = new Map(); this.logs = [];
this.generation = 0; this.requestNumber = 0; this.resetView();
}
resetView() {
this.status = 'disconnected'; this.sessionId = null; this.contract = null; this.exhibit = null;
this.catalog = []; this.capabilities = []; this.registryRevision = null; this.stateRevision = null;
this.sequence = null; this.values = new Map(); this.sync = 'not synchronized'; this.eventLog = [];
this.snapshotEvents = null; this.refreshing = null; this.refreshWanted = false;
}
subscribe(fn) { this.listeners.add(fn); return () => this.listeners.delete(fn); }
changed() { for (const fn of this.listeners) fn(this); }
log(code, message, detail = null) {
this.logs.push({ time: new Date().toISOString(), code, message, detail });
if (this.logs.length > 200) this.logs.shift();
this.changed();
}
report(error) { this.log(error.code ?? 'HOST_ERROR', error.message); }
disconnect() {
this.generation++;
clearTimeout(this.refreshTimer);
this.unsubscribe?.(); this.transport?.close(); this.transport = null;
for (const p of this.pending.values()) { clearTimeout(p.timer); p.reject(new ProtocolError('DISCONNECTED', 'Session ended.')); }
this.pending.clear(); this.resetView(); this.changed();
}
async connect(transport) {
this.disconnect(); this.transport = transport;
this.unsubscribe = transport.subscribe(message => this.receive(message));
this.status = 'negotiating'; this.changed();
const generation = this.generation;
try {
const hello = await this.request('hello', { host: { name: 'XZBT-NGN', version: '0.1.0' }, supportedContractMajors: [5] });
if (generation !== this.generation) return;
check(hello.contract?.major === 5 && counter(hello.contract?.minor), 'Exhibit negotiated an unsupported contract.', 'UNSUPPORTED_VERSION');
check(typeof hello.sessionId === 'string' && hello.sessionId.length > 0 && record(hello.exhibit), 'Invalid hello result.');
this.sessionId = hello.sessionId; this.contract = hello.contract; this.exhibit = hello.exhibit;
this.status = 'connected'; this.log('SESSION', 'Exhibit session established.', { sessionId: this.sessionId });
await this.refresh(true);
} catch (error) {
if (generation === this.generation) { this.status = 'error'; this.report(error); }
throw error;
}
}
request(type, payload = {}) {
if (!this.transport || (type !== 'hello' && (!this.sessionId || this.status !== 'connected'))) {
return Promise.reject(new ProtocolError('INVALID_SESSION', 'Connect an exhibit session first.'));
}
const requestId = `ngn-${this.generation}-${++this.requestNumber}`;
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(requestId);
reject(new ProtocolError('TIMEOUT', `${type} timed out; its outcome may be unknown.`));
}, this.timeoutMs);
this.pending.set(requestId, { resolve, reject, timer, type });
try {
this.transport.send({ xzbt: '5.2', type, requestId, ...(type === 'hello' ? {} : { sessionId: this.sessionId }), ...payload });
} catch (error) { clearTimeout(timer); this.pending.delete(requestId); reject(error); }
});
}
receive(message) {
try {
check(record(message) && typeof message.type === 'string' && typeof message.xzbt === 'string', 'Malformed contract envelope.');
const pending = typeof message.requestId === 'string' && this.pending.get(message.requestId);
if (pending && !(message.type === 'error' && counter(message.sequence))) {
if (pending.type !== 'hello' && message.sessionId !== this.sessionId) {
this.log('STALE_SESSION', 'Ignored response for another session.'); return;
}
clearTimeout(pending.timer); this.pending.delete(message.requestId);
if (message.type === 'error') {
const error = new ProtocolError(message.error?.code ?? 'INVALID_MESSAGE', message.error?.message ?? 'Malformed error response.');
if (error.code === 'INVALID_SESSION') this.invalidate();
pending.reject(error); return;
}
if (message.type !== responses[pending.type] || (['set', 'invoke'].includes(pending.type) && message.ok !== true)) {
pending.reject(new ProtocolError('INVALID_MESSAGE', 'Unexpected response type or success flag.')); return;
}
pending.resolve(message); return;
}
if (!this.sessionId || message.sessionId !== this.sessionId) {
this.log('STALE_SESSION', 'Ignored message outside the negotiated session.'); return;
}
check(events.has(message.type), 'Unknown event or uncorrelated response.');
check(counter(message.sequence) && Number.isFinite(message.timestamp), 'Invalid event sequence or timestamp.');
if (this.sequence !== null && message.sequence !== this.sequence + 1) {
this.log('SEQUENCE_GAP', `Expected ${this.sequence + 1}, received ${message.sequence}.`);
this.scheduleRefresh(false);
if (message.sequence <= this.sequence) return;
}
this.sequence = message.sequence;
this.eventLog.push(message); if (this.eventLog.length > 200) this.eventLog.shift();
if (['state.changed', 'selection.changed'].includes(message.type)) {
if (this.snapshotEvents) this.snapshotEvents.push(message);
this.applyStateEvent(message);
}
if (message.type === 'registry.changed' || message.type === 'capability.changed') this.scheduleRefresh(true);
if (message.type === 'error') {
this.log(message.error?.code ?? 'INVALID_MESSAGE', message.error?.message ?? 'Malformed error event.', message);
if (message.error?.code === 'INVALID_SESSION') this.invalidate();
}
this.changed();
} catch (error) { this.report(error); if (this.sessionId) this.scheduleRefresh(false); }
}
invalidate() {
this.status = 'invalid session'; this.sync = 'reconnect required';
clearTimeout(this.refreshTimer);
for (const p of this.pending.values()) { clearTimeout(p.timer); p.reject(new ProtocolError('INVALID_SESSION', 'Reconnect to establish a new session.')); }
this.pending.clear(); this.changed();
}
applyStateEvent(event, replay = false) {
const target = this.catalog.find(t => t.id === event.target);
check(target && target.readable && target.kind !== 'impulse' && Object.hasOwn(event, 'value')
&& counter(event.stateRevision), 'Invalid persistent-state event.');
validateReportedValue(target, event.value);
if (this.stateRevision !== null && event.stateRevision < this.stateRevision) return;
if (!replay && this.stateRevision !== null && event.stateRevision > this.stateRevision + 1) {
this.log('REVISION_GAP', `State revision advanced from ${this.stateRevision} to ${event.stateRevision}.`);
this.scheduleRefresh(false);
}
this.values.set(event.target, event.value); this.stateRevision = event.stateRevision;
}
scheduleRefresh(describe) {
if (this.status !== 'connected') return;
this.sync = 'resynchronizing'; this.refreshWanted ||= describe;
clearTimeout(this.refreshTimer);
this.refreshTimer = setTimeout(() => {
const wanted = this.refreshWanted; this.refreshWanted = false;
this.refresh(wanted).catch(error => this.report(error));
}, this.debounceMs);
}
async refresh(describe = false) {
if (this.refreshing) {
const generation = this.generation;
await this.refreshing;
if (generation !== this.generation) return;
return this.refresh(describe);
}
const generation = this.generation;
const work = async () => {
this.sync = 'resynchronizing'; this.changed();
if (describe) {
const description = await this.request('describe');
if (generation !== this.generation) return;
validateCatalog(description);
this.catalog = description.targets; this.capabilities = description.capabilities;
this.registryRevision = description.registryRevision; this.exhibit = description.exhibit;
this.changed();
}
this.snapshotEvents = [];
const baseline = this.stateRevision;
const snapshot = await this.request('state.get');
if (generation !== this.generation) return;
check(counter(snapshot.stateRevision) && record(snapshot.values), 'Invalid state snapshot.');
check(baseline === null || snapshot.stateRevision >= baseline, 'Snapshot revision regressed.');
const readable = new Set(this.catalog.filter(t => t.readable && t.kind !== 'impulse').map(t => t.id));
check(Object.keys(snapshot.values).every(id => readable.has(id)), 'Snapshot contains an unknown or non-readable target.');
check([...readable].every(id => Object.hasOwn(snapshot.values, id)), 'Snapshot omits readable persistent state.');
for (const target of this.catalog) if (readable.has(target.id)) validateReportedValue(target, snapshot.values[target.id]);
this.values = new Map(Object.entries(snapshot.values)); this.stateRevision = snapshot.stateRevision;
for (const event of this.snapshotEvents) this.applyStateEvent(event, true);
this.snapshotEvents = null; this.sync = 'synchronized'; this.changed();
};
this.refreshing = work();
try { await this.refreshing; }
catch (error) { if (generation === this.generation) { this.sync = 'uncertain'; this.snapshotEvents = null; } throw error; }
finally { if (generation === this.generation) { this.refreshing = null; this.changed(); } }
}
target(id) {
const target = this.catalog.find(t => t.id === id);
check(target, 'Unknown target.', 'UNKNOWN_TARGET'); return target;
}
unavailable(target) {
return target.requires.filter(id => {
const capability = this.capabilities.find(c => c.id === id);
return !capability || ['unsupported', 'error'].includes(capability.state);
});
}
async operate(type, id, data) {
try {
const target = this.target(id);
check(this.unavailable(target).length === 0, 'A required capability is unavailable.', 'CAPABILITY_UNAVAILABLE');
if (type === 'set') validateSet(target, data);
else { check(target.kind === 'impulse', 'Target is not invokable.', 'TARGET_NOT_INVOKABLE'); validateArgs(target, data); }
await this.request(type, { target: id, [type === 'set' ? 'value' : 'args']: data });
await this.refresh(false);
} catch (error) {
if (error.code === 'TIMEOUT') { this.sync = 'uncertain'; this.scheduleRefresh(false); }
this.report(error); throw error;
}
}
set(id, value) { return this.operate('set', id, value); }
invoke(id, args = {}) { return this.operate('invoke', id, args); }
}
+20
View File
@@ -0,0 +1,20 @@
// This adapter alone knows about Window, origins, and message events.
export function postMessageTransport(frame, ownWindow = window) {
const origin = ownWindow.location.origin;
if (origin === 'null' || new URL(frame.src, ownWindow.location.href).origin !== origin) {
throw new Error('The exhibit must be served from the same HTTP origin as NGN.');
}
const peer = frame.contentWindow;
const listeners = new Set();
const receive = event => {
if (event.origin === origin && event.source === peer) {
for (const listener of listeners) listener(event.data);
}
};
ownWindow.addEventListener('message', receive);
return {
send(message) { peer.postMessage(message, origin); },
subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); },
close() { ownWindow.removeEventListener('message', receive); listeners.clear(); }
};
}
+127
View File
@@ -0,0 +1,127 @@
import { ExhibitHost } from './host.js';
import { argumentSchema } from './validation.js';
import { postMessageTransport } from './transport/post-message.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;
function element(tag, text, parent) {
const node = document.createElement(tag); if (text !== undefined) node.textContent = text;
parent?.append(node); return node;
}
function editor(spec, label, parent) {
const wrapper = element('label', label, parent);
const options = spec.enum;
const input = element(options ? 'select' : 'input', undefined, wrapper);
input.setAttribute('aria-label', label);
if (options) {
for (const [index, value] of options.entries()) {
const option = element('option', spec.optionLabels?.[index] ?? String(value), input); option.value = String(index);
}
} else {
input.type = spec.type === 'boolean' ? 'checkbox' : ['number', 'integer'].includes(spec.type) ? 'number' : 'text';
for (const key of ['min', 'max', 'step', 'minLength', 'maxLength']) if (spec[key] !== undefined) input[key] = spec[key];
if (input.type === 'number' && spec.step === undefined) input.step = spec.type === 'integer' ? '1' : 'any';
}
return {
input,
read() { return options ? options[Number(input.value)] : spec.type === 'boolean' ? input.checked
: ['number', 'integer'].includes(spec.type) ? (input.value === '' ? NaN : Number(input.value)) : input.value; },
write(value) {
if (options) input.value = String(options.findIndex(v => Object.is(v, value)));
else if (spec.type === 'boolean') input.checked = value === true;
else input.value = value ?? '';
}
};
}
function buildCatalog() {
rows.clear(); byId('catalog').replaceChildren();
for (const target of host.catalog) {
const section = element('article', undefined, byId('catalog')); 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);
const detail = element('details', undefined, section); element('summary', 'Target descriptor', detail); element('pre', json(target), detail);
const notice = element('p', '', section); notice.className = 'notice';
const form = element('form', undefined, section); form.className = 'controls'; form.noValidate = true;
let valueEditor, schemaError = '', argumentEditors = [];
if (target.kind === 'impulse') {
try {
for (const arg of argumentSchema(target)) {
const group = element('div', undefined, form);
let include = null;
if (!arg.required) { const l = element('label', `Include ${arg.label ?? arg.name}`, group); include = element('input', undefined, l); include.type = 'checkbox'; }
const field = editor(arg, arg.label ?? arg.name, group);
if (arg.description) element('p', arg.description, group);
argumentEditors.push({ arg, include, field });
}
} catch (error) { schemaError = error.message; }
} else if (target.writable) {
if (target.kind === 'selection') valueEditor = editor({ enum: target.options.map(o => o.value), optionLabels: target.options.map(o => o.label) }, 'New value', form);
else if (target.kind === 'range' || ['boolean', 'number', 'integer', 'string'].includes(target.valueType)) {
valueEditor = editor({ ...target, type: target.kind === 'range' ? 'number' : target.valueType }, 'New value', form);
} else schemaError = 'Unsupported state value type.';
}
let submit = null;
let row;
if (target.kind === 'impulse' || target.writable) {
submit = element('button', target.kind === 'impulse' ? 'Invoke' : 'Set', form);
form.addEventListener('submit', async event => {
event.preventDefault(); if (row.busy) return; row.busy = true; submit.disabled = true;
try {
if (target.kind === 'impulse') {
const args = Object.create(null);
for (const { arg, include, field } of argumentEditors) if (!include || include.checked) args[arg.name] = field.read();
await host.invoke(target.id, args);
} else await host.set(target.id, valueEditor.read());
} catch { /* Host reports every control failure in the visible console. */ }
row.busy = false; render();
});
}
row = { target, current, notice, submit, schemaError, valueEditor, initialized: false, busy: false };
rows.set(target.id, row);
}
}
function render() {
byId('status').textContent = `${host.status} · ${host.sync}`;
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('state').textContent = json(Object.fromEntries(host.values));
byId('events').textContent = json(host.eventLog);
byId('errors').textContent = json(host.logs);
byId('target-count').textContent = `(${host.catalog.length})`;
byId('refresh').disabled = host.status !== 'connected';
byId('reconnect').disabled = !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.';
if (!row.initialized && row.valueEditor && host.values.has(row.target.id)) {
row.valueEditor.write(host.values.get(row.target.id)); row.initialized = true;
}
const unavailable = host.unavailable(row.target);
row.notice.textContent = row.schemaError || (unavailable.length ? `Unavailable: ${unavailable.join(', ')}` : '');
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();
} catch (error) { host.report(error); }
});
byId('reconnect').addEventListener('click', attach);
byId('disconnect').addEventListener('click', () => { host.disconnect(); frame?.remove(); frame = null; render(); });
byId('refresh').addEventListener('click', () => host.refresh().catch(error => host.report(error)));
host.subscribe(render); render();
+106
View File
@@ -0,0 +1,106 @@
export class ProtocolError extends Error {
constructor(code, message) { super(message); this.name = 'ProtocolError'; this.code = code; }
}
export const record = value => value !== null && typeof value === 'object' && !Array.isArray(value);
export const counter = value => Number.isSafeInteger(value) && value >= 0;
export function check(condition, message, code = 'INVALID_MESSAGE') {
if (!condition) throw new ProtocolError(code, message);
}
const types = ['string', 'number', 'integer', 'boolean'];
export function argumentSchema(target) {
const args = target.arguments ?? [];
check(Array.isArray(args), 'Unsupported argument schema: arguments must be an array.', 'UNSUPPORTED_SCHEMA');
const names = new Set();
for (const arg of args) {
check(record(arg) && typeof arg.name === 'string' && !names.has(arg.name)
&& types.includes(arg.type) && typeof arg.required === 'boolean',
'Unsupported argument schema: invalid name, type, or required field.', 'UNSUPPORTED_SCHEMA');
names.add(arg.name);
for (const key of ['min', 'max', 'step']) {
check(arg[key] === undefined || (Number.isFinite(arg[key]) && ['number', 'integer'].includes(arg.type)
&& (key !== 'step' || arg[key] > 0)), `Unsupported argument ${key}.`, 'UNSUPPORTED_SCHEMA');
}
for (const key of ['minLength', 'maxLength']) {
check(arg[key] === undefined || (counter(arg[key]) && arg.type === 'string'),
`Unsupported argument ${key}.`, 'UNSUPPORTED_SCHEMA');
}
check(arg.min === undefined || arg.max === undefined || arg.min <= arg.max, 'Inverted bounds.', 'UNSUPPORTED_SCHEMA');
check(arg.minLength === undefined || arg.maxLength === undefined || arg.minLength <= arg.maxLength,
'Inverted length bounds.', 'UNSUPPORTED_SCHEMA');
check(arg.enum === undefined || (Array.isArray(arg.enum) && arg.enum.every(v => matches(v, arg.type))),
'Unsupported argument enum.', 'UNSUPPORTED_SCHEMA');
}
return args;
}
function matches(value, type) {
return type === 'integer' ? Number.isSafeInteger(value)
: type === 'number' ? Number.isFinite(value) : typeof value === type;
}
export function validateValue(value, spec) {
const fail = message => check(false, message, 'INVALID_VALUE');
if (!matches(value, spec.type)) fail(`Expected ${spec.type}.`);
if (spec.enum && !spec.enum.includes(value)) fail('Value is not a declared option.');
if (typeof value === 'number') {
if (spec.min !== undefined && value < spec.min) fail(`Minimum is ${spec.min}.`);
if (spec.max !== undefined && value > spec.max) fail(`Maximum is ${spec.max}.`);
if (spec.step !== undefined) {
const units = (value - (spec.min ?? 0)) / spec.step;
if (Math.abs(units - Math.round(units)) > 1e-7) fail(`Value must follow step ${spec.step}.`);
}
}
if (typeof value === 'string') {
if (spec.minLength !== undefined && value.length < spec.minLength) fail(`Minimum length is ${spec.minLength}.`);
if (spec.maxLength !== undefined && value.length > spec.maxLength) fail(`Maximum length is ${spec.maxLength}.`);
}
}
export function validateArgs(target, args) {
check(record(args), 'Invoke args must be an object.', 'INVALID_VALUE');
const schema = argumentSchema(target);
const known = new Set(schema.map(a => a.name));
for (const key of Object.keys(args)) check(known.has(key), `Undeclared argument: ${key}`, 'INVALID_VALUE');
for (const arg of schema) {
const present = Object.hasOwn(args, arg.name);
check(present || !arg.required, `Required argument: ${arg.name}`, 'INVALID_VALUE');
if (present) validateValue(args[arg.name], arg);
}
}
export function validateSet(target, value) {
check(target.kind !== 'impulse', 'Impulse targets cannot be set.', 'TARGET_NOT_SETTABLE');
check(target.writable, 'Target is read-only.', 'TARGET_READ_ONLY');
if (target.kind === 'selection') {
check(target.options.some(o => Object.is(o.value, value)), 'Value is not a declared option.', 'INVALID_VALUE');
} else validateValue(value, { ...target, type: target.kind === 'range' ? 'number' : target.valueType });
}
export function validateReportedValue(target, value) {
// Read-only targets use the same declared value shape without requiring writability.
if (target.kind === 'selection') {
check(target.options.some(o => Object.is(o.value, value)), `Invalid reported value for ${target.id}.`);
} else if (target.kind === 'range' || types.includes(target.valueType)) {
try { validateValue(value, { ...target, type: target.kind === 'range' ? 'number' : target.valueType }); }
catch (error) { throw new ProtocolError('INVALID_MESSAGE', `${target.id}: ${error.message}`); }
}
}
export function validateCatalog(message) {
check(record(message.exhibit) && record(message.contract) && message.contract.major === 5,
'Invalid description metadata.');
check(counter(message.registryRevision) && counter(message.stateRevision), 'Invalid description revisions.');
check(Array.isArray(message.targets) && Array.isArray(message.capabilities), 'Invalid catalog arrays.');
const ids = new Set();
for (const t of message.targets) {
check(record(t) && typeof t.id === 'string' && /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$/.test(t.id)
&& !ids.has(t.id) && ['state', 'range', 'selection', 'impulse'].includes(t.kind)
&& 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);
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)
&& t.options.every(o => record(o) && Object.hasOwn(o, 'value')), 'Invalid selection descriptor.');
}
const caps = new Set();
for (const c of message.capabilities) {
check(record(c) && typeof c.id === 'string' && !caps.has(c.id) && typeof c.state === 'string', 'Invalid capability.');
caps.add(c.id);
}
for (const t of message.targets) check(t.requires.every(id => caps.has(id)), 'Undeclared required capability.');
}