generated from Labyricorn/labyricorn-project-template
Post Step 4 Completion
This commit is contained in:
+199
@@ -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); }
|
||||
}
|
||||
@@ -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(); }
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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.');
|
||||
}
|
||||
Reference in New Issue
Block a user