generated from Labyricorn/labyricorn-project-template
Post Step 4 Completion
This commit is contained in:
@@ -0,0 +1,710 @@
|
||||
/*
|
||||
* XZBT Exhibit Contract 5.2 — generic contract core.
|
||||
*
|
||||
* WHY THIS FILE IS GENERIC
|
||||
* ------------------------
|
||||
* Everything in here is domain-free. It knows about the *shape* of the
|
||||
* contract (envelopes, target kinds, revisions, sequences, error codes,
|
||||
* capability lifecycle) and nothing about aquariums, planetariums, haunted
|
||||
* houses, or any other subject matter. It contains no exhibit state, no
|
||||
* exhibit vocabulary, and no transport.
|
||||
*
|
||||
* An exhibit supplies three things and gets a conforming contract surface:
|
||||
*
|
||||
* 1. a target catalog (canonical dotted IDs -> descriptors)
|
||||
* 2. a setter table (target ID -> absolute, idempotent setter)
|
||||
* 3. an action table (target ID -> real impulse implementation)
|
||||
*
|
||||
* The core owns the canonical mutation/invoke chokepoint, so every input
|
||||
* source (native UI, hotkey, host transport) converges on one path and one
|
||||
* set of transaction semantics. That is the whole point of sharing it: the
|
||||
* contract rules are subtle enough that three hand-rolled copies would drift.
|
||||
*
|
||||
* This file is a classic script (no ES modules) and publishes one global.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var CONTRACT_MAJOR = 5;
|
||||
var CONTRACT_MINOR = 2;
|
||||
var XZBT_VERSION = '5.2';
|
||||
|
||||
/* Contract 5.2 §15. The exhibit assigns source at its own trusted
|
||||
* boundary; a source supplied by a caller is never trusted. */
|
||||
var SOURCES = ['ui', 'midi', 'hotkey', 'host', 'scenario', 'internal', 'system'];
|
||||
|
||||
/* Contract 5.2 §24. */
|
||||
var ERROR_CODES = [
|
||||
'UNSUPPORTED_VERSION',
|
||||
'INVALID_MESSAGE',
|
||||
'INVALID_SESSION',
|
||||
'UNKNOWN_TARGET',
|
||||
'INVALID_VALUE',
|
||||
'INVALID_ARGUMENTS',
|
||||
'CAPABILITY_UNAVAILABLE',
|
||||
'TARGET_READ_ONLY',
|
||||
'TARGET_NOT_INVOKABLE',
|
||||
'TARGET_NOT_SETTABLE',
|
||||
'INTERNAL_ERROR'
|
||||
];
|
||||
|
||||
/* Contract 5.2 §17. */
|
||||
var CAPABILITY_STATES = ['unsupported', 'available', 'loading', 'ready', 'busy', 'error'];
|
||||
|
||||
/* Contract 5.2 §16. The base event set is closed; exhibits do not invent
|
||||
* new canonical event types. */
|
||||
var EVENT_TYPES = [
|
||||
'state.changed',
|
||||
'action.executed',
|
||||
'selection.changed',
|
||||
'capability.changed',
|
||||
'registry.changed',
|
||||
'error'
|
||||
];
|
||||
|
||||
var TARGET_ID_PATTERN = /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$/;
|
||||
|
||||
function isPlainObject(v) {
|
||||
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
||||
}
|
||||
|
||||
function isFiniteNumber(v) {
|
||||
return typeof v === 'number' && isFinite(v);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Target catalog
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* A catalog is the exhibit's fixed public surface. It is built once and
|
||||
* never reshaped by ordinary context changes (Authoring Guide §D/N):
|
||||
* a target that is temporarily unusable stays discoverable and reports
|
||||
* CAPABILITY_UNAVAILABLE instead of disappearing.
|
||||
*/
|
||||
function Catalog(descriptors) {
|
||||
this._byId = {};
|
||||
this._order = [];
|
||||
for (var i = 0; i < descriptors.length; i++) {
|
||||
var d = descriptors[i];
|
||||
if (!TARGET_ID_PATTERN.test(d.id)) {
|
||||
throw new Error('Invalid canonical target id: ' + d.id);
|
||||
}
|
||||
if (this._byId[d.id]) {
|
||||
throw new Error('Duplicate target id: ' + d.id);
|
||||
}
|
||||
this._byId[d.id] = d;
|
||||
this._order.push(d.id);
|
||||
}
|
||||
}
|
||||
|
||||
Catalog.prototype.has = function (id) {
|
||||
return Object.prototype.hasOwnProperty.call(this._byId, id);
|
||||
};
|
||||
|
||||
Catalog.prototype.get = function (id) {
|
||||
return this.has(id) ? this._byId[id] : null;
|
||||
};
|
||||
|
||||
Catalog.prototype.ids = function () {
|
||||
return this._order.slice();
|
||||
};
|
||||
|
||||
/** Descriptors as published by `describe`, in stable catalog order. */
|
||||
Catalog.prototype.descriptors = function () {
|
||||
var out = [];
|
||||
for (var i = 0; i < this._order.length; i++) {
|
||||
out.push(this._byId[this._order[i]]);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Capabilities
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Capabilities are discoverable and stateful (Contract §17). This registry
|
||||
* only records what the exhibit honestly reports; it does not invent
|
||||
* lifecycle transitions. An exhibit that is always ready says so once.
|
||||
*/
|
||||
function CapabilityRegistry() {
|
||||
this._caps = {};
|
||||
this._order = [];
|
||||
}
|
||||
|
||||
CapabilityRegistry.prototype.declare = function (id, state) {
|
||||
if (CAPABILITY_STATES.indexOf(state) === -1) {
|
||||
throw new Error('Unknown capability state: ' + state);
|
||||
}
|
||||
if (!this._caps[id]) this._order.push(id);
|
||||
this._caps[id] = { id: id, state: state };
|
||||
return this;
|
||||
};
|
||||
|
||||
CapabilityRegistry.prototype.stateOf = function (id) {
|
||||
return this._caps[id] ? this._caps[id].state : 'unsupported';
|
||||
};
|
||||
|
||||
CapabilityRegistry.prototype.snapshot = function () {
|
||||
var out = [];
|
||||
for (var i = 0; i < this._order.length; i++) {
|
||||
var c = this._caps[this._order[i]];
|
||||
out.push({ id: c.id, state: c.state });
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
/** True when every required capability is usable right now. */
|
||||
CapabilityRegistry.prototype.allUsable = function (requires) {
|
||||
if (!requires || !requires.length) return true;
|
||||
for (var i = 0; i < requires.length; i++) {
|
||||
var s = this.stateOf(requires[i]);
|
||||
if (s !== 'ready' && s !== 'busy') return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* The contract core
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* @param {object} options
|
||||
* @param {object} options.identity { product, version, build }
|
||||
* @param {Catalog} options.catalog
|
||||
* @param {CapabilityRegistry} options.capabilities
|
||||
* @param {object} options.setters target id -> function(value) -> {changed:boolean}
|
||||
* @param {object} options.readers target id -> function() -> value
|
||||
* @param {object} options.actions target id -> function(args) -> {ok, code?, message?}
|
||||
* @param {object} [options.availability] target id -> function() -> {ok, code?, message?}
|
||||
* @param {function} [options.onEvent] called with every normalized event
|
||||
*/
|
||||
function ContractCore(options) {
|
||||
this.identity = options.identity;
|
||||
this.catalog = options.catalog;
|
||||
this.capabilities = options.capabilities;
|
||||
this.setters = options.setters || {};
|
||||
this.readers = options.readers || {};
|
||||
this.actions = options.actions || {};
|
||||
this.availability = options.availability || {};
|
||||
this.onEvent = options.onEvent || function () {};
|
||||
|
||||
/* stateRevision tracks committed persistent-state history and does NOT
|
||||
* reset when a host reconnects (Contract §14, §16.1). */
|
||||
this.stateRevision = 0;
|
||||
/* registryRevision tracks the target set and its metadata. The catalog
|
||||
* is fixed, so this starts at 1 and stays there unless the exhibit
|
||||
* genuinely changes its registry. */
|
||||
this.registryRevision = 1;
|
||||
|
||||
/* sequence is session-scoped and resets on a new sessionId. */
|
||||
this.sequence = 0;
|
||||
this.sessionId = null;
|
||||
this._sessionCounter = 0;
|
||||
this._eventLog = [];
|
||||
}
|
||||
|
||||
ContractCore.prototype._emit = function (type, payload) {
|
||||
if (EVENT_TYPES.indexOf(type) === -1) {
|
||||
throw new Error('Not a canonical contract event type: ' + type);
|
||||
}
|
||||
this.sequence += 1;
|
||||
var event = {
|
||||
xzbt: XZBT_VERSION,
|
||||
type: type,
|
||||
sessionId: this.sessionId,
|
||||
sequence: this.sequence,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
for (var k in payload) {
|
||||
if (Object.prototype.hasOwnProperty.call(payload, k)) event[k] = payload[k];
|
||||
}
|
||||
this._eventLog.push(event);
|
||||
this.onEvent(event);
|
||||
return event;
|
||||
};
|
||||
|
||||
/** Events emitted so far in the current session (used by tests/harness). */
|
||||
ContractCore.prototype.eventLog = function () {
|
||||
return this._eventLog.slice();
|
||||
};
|
||||
|
||||
ContractCore.prototype._error = function (code, message) {
|
||||
return { ok: false, error: { code: code, message: message } };
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Value validation
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
ContractCore.prototype._validateValue = function (descriptor, value) {
|
||||
var kind = descriptor.kind;
|
||||
|
||||
if (kind === 'range') {
|
||||
if (!isFiniteNumber(value)) {
|
||||
return this._error('INVALID_VALUE', 'Range target requires a finite number.');
|
||||
}
|
||||
if (value < descriptor.min || value > descriptor.max) {
|
||||
/* No silent clamping: an out-of-range value is rejected outright
|
||||
* (Authoring Guide §T). */
|
||||
return this._error(
|
||||
'INVALID_VALUE',
|
||||
'Value ' + value + ' is outside [' + descriptor.min + ', ' + descriptor.max + '].'
|
||||
);
|
||||
}
|
||||
if (descriptor.step) {
|
||||
var steps = (value - descriptor.min) / descriptor.step;
|
||||
if (Math.abs(steps - Math.round(steps)) > 1e-9) {
|
||||
return this._error(
|
||||
'INVALID_VALUE',
|
||||
'Value ' + value + ' is not on the declared step of ' + descriptor.step + '.'
|
||||
);
|
||||
}
|
||||
}
|
||||
return { ok: true, value: value };
|
||||
}
|
||||
|
||||
if (kind === 'state') {
|
||||
if (descriptor.valueType === 'boolean') {
|
||||
if (typeof value !== 'boolean') {
|
||||
return this._error('INVALID_VALUE', 'State target requires a boolean.');
|
||||
}
|
||||
return { ok: true, value: value };
|
||||
}
|
||||
if (descriptor.valueType === 'string') {
|
||||
if (typeof value !== 'string') {
|
||||
return this._error('INVALID_VALUE', 'State target requires a string.');
|
||||
}
|
||||
if (descriptor.maxLength && value.length > descriptor.maxLength) {
|
||||
return this._error(
|
||||
'INVALID_VALUE',
|
||||
'String exceeds declared maxLength of ' + descriptor.maxLength + '.'
|
||||
);
|
||||
}
|
||||
return { ok: true, value: value };
|
||||
}
|
||||
if (descriptor.valueType === 'number') {
|
||||
if (!isFiniteNumber(value)) {
|
||||
return this._error('INVALID_VALUE', 'State target requires a finite number.');
|
||||
}
|
||||
return { ok: true, value: value };
|
||||
}
|
||||
return this._error('INVALID_VALUE', 'Unsupported valueType.');
|
||||
}
|
||||
|
||||
if (kind === 'selection') {
|
||||
if (typeof value !== 'string') {
|
||||
return this._error('INVALID_VALUE', 'Selection target requires a string value.');
|
||||
}
|
||||
for (var i = 0; i < descriptor.options.length; i++) {
|
||||
if (descriptor.options[i].value === value) return { ok: true, value: value };
|
||||
}
|
||||
return this._error('INVALID_VALUE', 'Value "' + value + '" is not a declared option.');
|
||||
}
|
||||
|
||||
return this._error('INVALID_VALUE', 'Target kind does not accept values.');
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Canonical mutation path (Authoring Guide §H)
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* The single chokepoint for every externally visible persistent-state
|
||||
* change, whatever its origin. One call is one mutation transaction:
|
||||
* commit, then increment stateRevision at most once, then emit the
|
||||
* resulting events carrying that revision (Contract §14).
|
||||
*
|
||||
* @param {string} targetId
|
||||
* @param {*} value
|
||||
* @param {string} source assigned by the caller's trusted boundary
|
||||
* @param {object} [context] { correlationId }
|
||||
*/
|
||||
ContractCore.prototype.applyMutation = function (targetId, value, source, context) {
|
||||
context = context || {};
|
||||
if (SOURCES.indexOf(source) === -1) {
|
||||
return this._error('INTERNAL_ERROR', 'Unknown source: ' + source);
|
||||
}
|
||||
|
||||
var descriptor = this.catalog.get(targetId);
|
||||
if (!descriptor) {
|
||||
return this._error('UNKNOWN_TARGET', 'The requested target is not registered.');
|
||||
}
|
||||
if (descriptor.kind === 'impulse') {
|
||||
return this._error('TARGET_NOT_SETTABLE', 'Impulse targets are not settable.');
|
||||
}
|
||||
if (!descriptor.writable) {
|
||||
return this._error('TARGET_READ_ONLY', 'The requested target is read-only.');
|
||||
}
|
||||
if (!this.capabilities.allUsable(descriptor.requires)) {
|
||||
return this._error(
|
||||
'CAPABILITY_UNAVAILABLE',
|
||||
'A capability required by this target is not usable.'
|
||||
);
|
||||
}
|
||||
|
||||
var validated = this._validateValue(descriptor, value);
|
||||
if (!validated.ok) return validated;
|
||||
|
||||
var setter = this.setters[targetId];
|
||||
if (typeof setter !== 'function') {
|
||||
return this._error('INTERNAL_ERROR', 'No setter is bound to this target.');
|
||||
}
|
||||
|
||||
/* The setter is absolute and idempotent: it reports whether anything
|
||||
* actually changed. A no-op must not touch stateRevision (Contract §14.3). */
|
||||
var result = setter(validated.value) || {};
|
||||
if (!result.changed) {
|
||||
return { ok: true, revisionChanged: false, stateRevision: this.stateRevision };
|
||||
}
|
||||
|
||||
this.stateRevision += 1;
|
||||
var revision = this.stateRevision;
|
||||
|
||||
/* A coherent multi-value transaction reports every value it changed so
|
||||
* all of them are emitted under the one shared revision. */
|
||||
var changed = result.changedTargets && result.changedTargets.length
|
||||
? result.changedTargets
|
||||
: [targetId];
|
||||
|
||||
for (var i = 0; i < changed.length; i++) {
|
||||
var id = changed[i];
|
||||
var d = this.catalog.get(id);
|
||||
if (!d || !d.readable) continue;
|
||||
var payload = {
|
||||
stateRevision: revision,
|
||||
target: id,
|
||||
value: this.readValue(id),
|
||||
source: source,
|
||||
/* Always present, null when the change did not originate from a
|
||||
* request. Emitting the key unconditionally keeps the event shape
|
||||
* identical no matter which input path caused the change, so a host
|
||||
* can rely on one schema (Authoring Guide §H). */
|
||||
correlationId: context.correlationId || null
|
||||
};
|
||||
this._emit(d.kind === 'selection' ? 'selection.changed' : 'state.changed', payload);
|
||||
}
|
||||
|
||||
return { ok: true, revisionChanged: true, stateRevision: revision };
|
||||
};
|
||||
|
||||
/**
|
||||
* The single chokepoint for every externally visible action.
|
||||
* Impulses never change persistent state, so they never touch
|
||||
* stateRevision (Contract §10.4, §14).
|
||||
*/
|
||||
ContractCore.prototype.invokeAction = function (targetId, args, source, context) {
|
||||
context = context || {};
|
||||
if (SOURCES.indexOf(source) === -1) {
|
||||
return this._error('INTERNAL_ERROR', 'Unknown source: ' + source);
|
||||
}
|
||||
|
||||
var descriptor = this.catalog.get(targetId);
|
||||
if (!descriptor) {
|
||||
return this._error('UNKNOWN_TARGET', 'The requested target is not registered.');
|
||||
}
|
||||
if (descriptor.kind !== 'impulse') {
|
||||
return this._error('TARGET_NOT_INVOKABLE', 'Only impulse targets are invokable.');
|
||||
}
|
||||
if (!this.capabilities.allUsable(descriptor.requires)) {
|
||||
return this._error(
|
||||
'CAPABILITY_UNAVAILABLE',
|
||||
'A capability required by this target is not usable.'
|
||||
);
|
||||
}
|
||||
|
||||
/* Contextual availability: the target stays discoverable, but may be
|
||||
* temporarily unusable because of exhibit state (Authoring Guide §N). */
|
||||
var gate = this.availability[targetId];
|
||||
if (typeof gate === 'function') {
|
||||
var verdict = gate();
|
||||
if (verdict && !verdict.ok) {
|
||||
return this._error(verdict.code || 'CAPABILITY_UNAVAILABLE', verdict.message || 'Unavailable.');
|
||||
}
|
||||
}
|
||||
|
||||
var action = this.actions[targetId];
|
||||
if (typeof action !== 'function') {
|
||||
return this._error('INTERNAL_ERROR', 'No action is bound to this target.');
|
||||
}
|
||||
|
||||
// Fixture-only correction for the owner's authoritative 5.2 clarification.
|
||||
if (!isPlainObject(args)) return this._error('INVALID_VALUE', 'args must be an object.');
|
||||
var declared = descriptor.arguments || [];
|
||||
var names = declared.map(function (arg) { return arg.name; });
|
||||
if (Object.keys(args).some(function (key) { return names.indexOf(key) === -1; })) {
|
||||
return this._error('INVALID_VALUE', 'Undeclared argument.');
|
||||
}
|
||||
for (var a = 0; a < declared.length; a++) {
|
||||
var spec = declared[a];
|
||||
if (!Object.prototype.hasOwnProperty.call(args, spec.name)) {
|
||||
if (spec.required) return this._error('INVALID_VALUE', 'Missing argument: ' + spec.name);
|
||||
continue;
|
||||
}
|
||||
var value = args[spec.name];
|
||||
var validType = spec.type === 'integer' ? Number.isSafeInteger(value)
|
||||
: spec.type === 'number' ? isFiniteNumber(value) : typeof value === spec.type;
|
||||
if (!validType || (spec.enum && spec.enum.indexOf(value) === -1)) {
|
||||
return this._error('INVALID_VALUE', 'Invalid argument: ' + spec.name);
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
var units = (value - (spec.min === undefined ? 0 : spec.min)) / spec.step;
|
||||
if ((spec.min !== undefined && value < spec.min) || (spec.max !== undefined && value > spec.max)
|
||||
|| (spec.step !== undefined && Math.abs(units - Math.round(units)) > 1e-7)) {
|
||||
return this._error('INVALID_VALUE', 'Argument outside numeric constraints.');
|
||||
}
|
||||
}
|
||||
if (typeof value === 'string' && ((spec.minLength !== undefined && value.length < spec.minLength)
|
||||
|| (spec.maxLength !== undefined && value.length > spec.maxLength))) {
|
||||
return this._error('INVALID_VALUE', 'Argument outside length constraints.');
|
||||
}
|
||||
}
|
||||
var outcome = action(args);
|
||||
if (!outcome || !outcome.ok) {
|
||||
return this._error(
|
||||
(outcome && outcome.code) || 'INTERNAL_ERROR',
|
||||
(outcome && outcome.message) || 'The action could not be executed.'
|
||||
);
|
||||
}
|
||||
|
||||
var payload = {
|
||||
target: targetId,
|
||||
args: outcome.args || args || {},
|
||||
source: source,
|
||||
correlationId: context.correlationId || null
|
||||
};
|
||||
this._emit('action.executed', payload);
|
||||
|
||||
return { ok: true };
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Reads
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
ContractCore.prototype.readValue = function (targetId) {
|
||||
var reader = this.readers[targetId];
|
||||
if (typeof reader === 'function') return reader();
|
||||
return null;
|
||||
};
|
||||
|
||||
/** Contract §13: only readable persistent targets belong in `values`. */
|
||||
ContractCore.prototype.stateSnapshot = function () {
|
||||
var values = {};
|
||||
var ids = this.catalog.ids();
|
||||
for (var i = 0; i < ids.length; i++) {
|
||||
var d = this.catalog.get(ids[i]);
|
||||
if (d.kind === 'impulse' || !d.readable) continue;
|
||||
values[ids[i]] = this.readValue(ids[i]);
|
||||
}
|
||||
return { stateRevision: this.stateRevision, values: values };
|
||||
};
|
||||
|
||||
ContractCore.prototype.describe = function () {
|
||||
return {
|
||||
exhibit: this.identity,
|
||||
contract: { major: CONTRACT_MAJOR, minor: CONTRACT_MINOR },
|
||||
registryRevision: this.registryRevision,
|
||||
stateRevision: this.stateRevision,
|
||||
capabilities: this.capabilities.snapshot(),
|
||||
targets: this.catalog.descriptors()
|
||||
};
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Capability transitions
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
ContractCore.prototype.setCapabilityState = function (id, state) {
|
||||
var previous = this.capabilities.stateOf(id);
|
||||
if (previous === state) return false;
|
||||
this.capabilities.declare(id, state);
|
||||
this._emit('capability.changed', { capability: id, state: state, previousState: previous });
|
||||
return true;
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Session handling
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
ContractCore.prototype._newSessionId = function () {
|
||||
this._sessionCounter += 1;
|
||||
var rand = Math.floor(Math.random() * 0xffff).toString(16);
|
||||
return 'sess-' + this._sessionCounter.toString(16) + rand;
|
||||
};
|
||||
|
||||
/**
|
||||
* Handshake (Contract §5). A new session resets the event sequence but
|
||||
* deliberately leaves stateRevision alone.
|
||||
*/
|
||||
ContractCore.prototype.handleHello = function (message) {
|
||||
var majors = message.supportedContractMajors;
|
||||
if (Array.isArray(majors) && majors.length && majors.indexOf(CONTRACT_MAJOR) === -1) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'UNSUPPORTED_VERSION',
|
||||
message: 'No compatible contract major. This exhibit implements major ' + CONTRACT_MAJOR + '.'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
this.sessionId = this._newSessionId();
|
||||
this.sequence = 0;
|
||||
this._eventLog = [];
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
result: {
|
||||
sessionId: this.sessionId,
|
||||
exhibit: this.identity,
|
||||
contract: { major: CONTRACT_MAJOR, minor: CONTRACT_MINOR }
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Request dispatch
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Handle one already-parsed contract request. Transport-agnostic: the
|
||||
* caller decides how the message arrived and what `source` that implies.
|
||||
*
|
||||
* @param {object} message
|
||||
* @param {string} source the authoritative source for this arrival path
|
||||
* @returns {object|null} response envelope, or null when the message is
|
||||
* not addressed to this exhibit at all
|
||||
*/
|
||||
ContractCore.prototype.handleRequest = function (message, source) {
|
||||
if (!isPlainObject(message)) {
|
||||
return this._errorEnvelope(null, null, 'INVALID_MESSAGE', 'Message must be an object.');
|
||||
}
|
||||
if (message.xzbt !== XZBT_VERSION) {
|
||||
return this._errorEnvelope(
|
||||
message.requestId || null,
|
||||
message.sessionId || null,
|
||||
'UNSUPPORTED_VERSION',
|
||||
'This exhibit implements contract ' + XZBT_VERSION + '.'
|
||||
);
|
||||
}
|
||||
if (typeof message.type !== 'string') {
|
||||
return this._errorEnvelope(
|
||||
message.requestId || null,
|
||||
message.sessionId || null,
|
||||
'INVALID_MESSAGE',
|
||||
'Message type is required.'
|
||||
);
|
||||
}
|
||||
|
||||
var requestId = typeof message.requestId === 'string' ? message.requestId : null;
|
||||
|
||||
if (message.type === 'hello') {
|
||||
var hello = this.handleHello(message);
|
||||
if (!hello.ok) {
|
||||
return this._errorEnvelope(requestId, null, hello.error.code, hello.error.message);
|
||||
}
|
||||
return this._okEnvelope('hello.result', requestId, hello.result);
|
||||
}
|
||||
|
||||
/* Every other request must carry the session issued by this exhibit. */
|
||||
if (typeof message.sessionId !== 'string' || message.sessionId !== this.sessionId) {
|
||||
return this._errorEnvelope(
|
||||
requestId,
|
||||
typeof message.sessionId === 'string' ? message.sessionId : null,
|
||||
'INVALID_SESSION',
|
||||
'A valid sessionId issued by this exhibit is required.'
|
||||
);
|
||||
}
|
||||
|
||||
switch (message.type) {
|
||||
case 'describe':
|
||||
return this._okEnvelope('describe.result', requestId, this.describe());
|
||||
|
||||
case 'state.get':
|
||||
return this._okEnvelope('state.result', requestId, this.stateSnapshot());
|
||||
|
||||
case 'set': {
|
||||
if (typeof message.target !== 'string') {
|
||||
return this._errorEnvelope(requestId, this.sessionId, 'INVALID_MESSAGE', 'set requires a target.');
|
||||
}
|
||||
var setResult = this.applyMutation(message.target, message.value, source, {
|
||||
correlationId: requestId
|
||||
});
|
||||
if (!setResult.ok) {
|
||||
return this._errorEnvelope(
|
||||
requestId, this.sessionId, setResult.error.code, setResult.error.message
|
||||
);
|
||||
}
|
||||
return this._okEnvelope('set.result', requestId, {
|
||||
stateRevision: setResult.stateRevision,
|
||||
changed: !!setResult.revisionChanged
|
||||
});
|
||||
}
|
||||
|
||||
case 'invoke': {
|
||||
if (typeof message.target !== 'string') {
|
||||
return this._errorEnvelope(requestId, this.sessionId, 'INVALID_MESSAGE', 'invoke requires a target.');
|
||||
}
|
||||
var invokeResult = this.invokeAction(message.target, message.args, source, {
|
||||
correlationId: requestId
|
||||
});
|
||||
if (!invokeResult.ok) {
|
||||
return this._errorEnvelope(
|
||||
requestId, this.sessionId, invokeResult.error.code, invokeResult.error.message
|
||||
);
|
||||
}
|
||||
return this._okEnvelope('invoke.result', requestId, {});
|
||||
}
|
||||
|
||||
default:
|
||||
return this._errorEnvelope(
|
||||
requestId, this.sessionId, 'INVALID_MESSAGE', 'Unsupported message type: ' + message.type
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
ContractCore.prototype._okEnvelope = function (type, requestId, payload) {
|
||||
var envelope = {
|
||||
xzbt: XZBT_VERSION,
|
||||
type: type,
|
||||
requestId: requestId,
|
||||
sessionId: this.sessionId,
|
||||
ok: true
|
||||
};
|
||||
for (var k in payload) {
|
||||
if (Object.prototype.hasOwnProperty.call(payload, k)) envelope[k] = payload[k];
|
||||
}
|
||||
return envelope;
|
||||
};
|
||||
|
||||
ContractCore.prototype._errorEnvelope = function (requestId, sessionId, code, message) {
|
||||
return {
|
||||
xzbt: XZBT_VERSION,
|
||||
type: 'error',
|
||||
requestId: requestId,
|
||||
sessionId: sessionId,
|
||||
ok: false,
|
||||
error: { code: code, message: message }
|
||||
};
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Exports
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
window.XZBTContractCore = {
|
||||
VERSION: XZBT_VERSION,
|
||||
CONTRACT_MAJOR: CONTRACT_MAJOR,
|
||||
CONTRACT_MINOR: CONTRACT_MINOR,
|
||||
SOURCES: SOURCES,
|
||||
ERROR_CODES: ERROR_CODES,
|
||||
CAPABILITY_STATES: CAPABILITY_STATES,
|
||||
EVENT_TYPES: EVENT_TYPES,
|
||||
TARGET_ID_PATTERN: TARGET_ID_PATTERN,
|
||||
Catalog: Catalog,
|
||||
CapabilityRegistry: CapabilityRegistry,
|
||||
ContractCore: ContractCore
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,234 @@
|
||||
/*
|
||||
* Shared presentation shell for the reference exhibits.
|
||||
*
|
||||
* This is deliberately generic chrome only: page frame, panel layout,
|
||||
* control primitives, and the announcement strip. Every domain-specific
|
||||
* colour, texture, and scene style lives in the exhibit's own style.css.
|
||||
* Nothing here knows what an exhibit is about.
|
||||
*/
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Segoe UI", Tahoma, Verdana, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
background: #10131a;
|
||||
color: #e8ecf2;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.exhibit {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 320px;
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
grid-template-areas:
|
||||
"header header"
|
||||
"stage panel"
|
||||
"footer footer";
|
||||
height: 100vh;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.exhibit-header {
|
||||
grid-area: header;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
padding: 8px 14px;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.exhibit-header h1 {
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.exhibit-header .subtitle {
|
||||
font-size: 12px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.exhibit-header .status {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.stage {
|
||||
grid-area: stage;
|
||||
position: relative;
|
||||
min-height: 0;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.stage canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.panel {
|
||||
grid-area: panel;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.panel h2 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.panel section + section {
|
||||
margin-top: 16px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.07);
|
||||
}
|
||||
|
||||
.control {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.control:last-child { margin-bottom: 0; }
|
||||
|
||||
.control label {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
margin-bottom: 5px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.control .readout {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 11px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
input[type="range"] {
|
||||
width: 100%;
|
||||
accent-color: currentColor;
|
||||
}
|
||||
|
||||
select {
|
||||
width: 100%;
|
||||
padding: 5px 6px;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
color: inherit;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
padding: 6px 10px;
|
||||
color: inherit;
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover { background: rgba(255, 255, 255, 0.14); }
|
||||
button:active { transform: translateY(1px); }
|
||||
|
||||
button[aria-pressed="true"] {
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
border-color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.button-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.button-row button { flex: 1 1 auto; }
|
||||
|
||||
.toggle-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.toggle-row:last-child { margin-bottom: 0; }
|
||||
|
||||
.exhibit-footer {
|
||||
grid-area: footer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 14px;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
font-size: 12px;
|
||||
min-height: 38px;
|
||||
}
|
||||
|
||||
.exhibit-footer .announcement-label {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.45;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* Announcement text is written with textContent only — never innerHTML. */
|
||||
.exhibit-footer .announcement-text {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.exhibit-footer .announcement-text.is-idle { opacity: 0.4; font-style: italic; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.exhibit {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: auto minmax(0, 1fr) auto auto;
|
||||
grid-template-areas:
|
||||
"header"
|
||||
"stage"
|
||||
"panel"
|
||||
"footer";
|
||||
height: auto;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.stage { min-height: 320px; }
|
||||
body { overflow: auto; }
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
* Shared presentation helpers for the reference exhibits.
|
||||
*
|
||||
* Generic DOM plumbing only: element lookup, safe text writing, and the
|
||||
* announcement strip. It has no contract awareness and no domain knowledge,
|
||||
* so an exhibit can use it, ignore it, or replace it without touching the
|
||||
* contract layer.
|
||||
*
|
||||
* The one rule worth stating out loud: every piece of text that reaches the
|
||||
* DOM goes through `textContent`. Nothing in this project ever assigns
|
||||
* `innerHTML`, so a string arriving from a host or a scenario can never
|
||||
* become markup or script (Contract §20, Authoring Guide §O).
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
function el(id) {
|
||||
return document.getElementById(id);
|
||||
}
|
||||
|
||||
/** Write text safely. Always textContent, never innerHTML. */
|
||||
function setText(node, text) {
|
||||
if (!node) return;
|
||||
node.textContent = text === null || text === undefined ? '' : String(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Announcement strip. Text is data: it is truncated to the declared
|
||||
* maximum and written with textContent.
|
||||
*/
|
||||
function Announcer(options) {
|
||||
this.node = options.node;
|
||||
this.idleText = options.idleText || '';
|
||||
this.maxLength = options.maxLength || 120;
|
||||
this._timer = null;
|
||||
this.setText(this.idleText, true);
|
||||
}
|
||||
|
||||
Announcer.prototype.setText = function (text, idle) {
|
||||
if (!this.node) return;
|
||||
var value = text === null || text === undefined ? '' : String(text);
|
||||
if (value.length > this.maxLength) value = value.slice(0, this.maxLength);
|
||||
this.node.textContent = value;
|
||||
if (idle) {
|
||||
this.node.classList.add('is-idle');
|
||||
} else {
|
||||
this.node.classList.remove('is-idle');
|
||||
}
|
||||
};
|
||||
|
||||
/** Show a transient message, then fall back to the idle text. */
|
||||
Announcer.prototype.flash = function (text, ms) {
|
||||
this.setText(text, false);
|
||||
if (this._timer) clearTimeout(this._timer);
|
||||
var self = this;
|
||||
this._timer = setTimeout(function () {
|
||||
self.setText(self.idleText, true);
|
||||
}, ms || 2600);
|
||||
};
|
||||
|
||||
/** Format a number for a readout, with a fixed number of decimals. */
|
||||
function formatNumber(value, decimals) {
|
||||
if (typeof value !== 'number' || !isFinite(value)) return '--';
|
||||
return value.toFixed(decimals === undefined ? 2 : decimals);
|
||||
}
|
||||
|
||||
/** Format a 0..1 value as a whole percentage. */
|
||||
function formatPercent(value) {
|
||||
if (typeof value !== 'number' || !isFinite(value)) return '--';
|
||||
return Math.round(value * 100) + '%';
|
||||
}
|
||||
|
||||
/** Format a 0..1 value as a whole number of degrees. */
|
||||
function formatDegrees(value) {
|
||||
if (typeof value !== 'number' || !isFinite(value)) return '--';
|
||||
return Math.round(value * 360) + '\u00b0';
|
||||
}
|
||||
|
||||
/** Format a 0..1 value as a whole number of minutes. */
|
||||
function formatMinutes(value) {
|
||||
if (typeof value !== 'number' || !isFinite(value)) return '--';
|
||||
return Math.round(value * 60) + ' min';
|
||||
}
|
||||
|
||||
window.XZBTShell = {
|
||||
el: el,
|
||||
setText: setText,
|
||||
Announcer: Announcer,
|
||||
formatNumber: formatNumber,
|
||||
formatPercent: formatPercent,
|
||||
formatDegrees: formatDegrees,
|
||||
formatMinutes: formatMinutes
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
* XZBT Exhibit Contract 5.2 — same-origin postMessage host transport.
|
||||
*
|
||||
* This is layer 6 of the Authoring Guide's recommended separation, and it is
|
||||
* deliberately the thinnest file in the project. It knows how to move
|
||||
* contract messages across one channel and nothing else: no exhibit
|
||||
* semantics, no target knowledge, no state.
|
||||
*
|
||||
* It is also entirely optional. An exhibit that never receives a `hello`
|
||||
* behaves exactly as it would with this file deleted — that is the
|
||||
* standalone-first rule (Contract §2, Authoring Guide §B).
|
||||
*
|
||||
* Security (Contract §25): both `event.origin` and `event.source` are
|
||||
* validated on every message. The transport also assigns `source = 'host'`
|
||||
* itself; a `source` field inside an incoming message is ignored, never
|
||||
* trusted (Contract §15).
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* @param {object} options
|
||||
* @param {object} options.core an XZBTContractCore.ContractCore
|
||||
* @param {string} [options.origin] expected host origin; defaults to the
|
||||
* document's own origin (same-origin)
|
||||
* @param {function} [options.onMessage] diagnostics hook
|
||||
*/
|
||||
function HostTransport(options) {
|
||||
this.core = options.core;
|
||||
this.expectedOrigin = options.origin || window.location.origin;
|
||||
this.onMessage = options.onMessage || function () {};
|
||||
this.connected = false;
|
||||
this._bound = this._onMessage.bind(this);
|
||||
|
||||
/* Events are pushed, not polled. The core already emits every state
|
||||
* change, action, and capability transition through its onEvent hook;
|
||||
* the transport's job is to forward them to the host. Without this the
|
||||
* host would see responses but never learn that anything changed
|
||||
* (Contract §16). */
|
||||
var self = this;
|
||||
var coreOnEvent = this.core.onEvent;
|
||||
this.core.onEvent = function (event) {
|
||||
if (typeof coreOnEvent === 'function') coreOnEvent(event);
|
||||
if (self.connected) self.send(event);
|
||||
};
|
||||
|
||||
window.addEventListener('message', this._bound);
|
||||
}
|
||||
|
||||
HostTransport.prototype._isTrusted = function (event) {
|
||||
/* Same-origin only. `file://` documents report origin "null", so a
|
||||
* file-opened exhibit simply never accepts host traffic — which is the
|
||||
* correct standalone behaviour, not a failure. */
|
||||
if (event.origin !== this.expectedOrigin) return false;
|
||||
if (event.source !== window.parent) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
HostTransport.prototype._onMessage = function (event) {
|
||||
if (!this._isTrusted(event)) return;
|
||||
|
||||
var message = event.data;
|
||||
if (!message || typeof message !== 'object') return;
|
||||
if (message.xzbt !== window.XZBTContractCore.VERSION) return;
|
||||
|
||||
/* Source is assigned here, at the trusted receiving boundary. */
|
||||
var response = this.core.handleRequest(message, 'host');
|
||||
if (!response) return;
|
||||
|
||||
if (response.type === 'hello.result') this.connected = true;
|
||||
this.onMessage(message, response);
|
||||
this.send(response);
|
||||
};
|
||||
|
||||
HostTransport.prototype.send = function (message) {
|
||||
if (window.parent === window) return;
|
||||
window.parent.postMessage(message, this.expectedOrigin);
|
||||
};
|
||||
|
||||
HostTransport.prototype.destroy = function () {
|
||||
window.removeEventListener('message', this._bound);
|
||||
};
|
||||
|
||||
window.XZBTHostTransport = HostTransport;
|
||||
})();
|
||||
Reference in New Issue
Block a user