generated from Labyricorn/labyricorn-project-template
One commit for the work accumulated in the working tree since Step 6.3, which had never been split into per-step commits: - src/local-surfaces.js + src/surface-url.js (new); src/ui.js, src/validation.js, src/connection.js and public/index.html updated for local-surface hosting and generic surface rendering - tests: local-surfaces (20), scifi-surfaces (24) and postmessage-interop (7) new; connection/museum-gallery/surface-validation suites updated - reference exhibits: shared/contract-core.js defaults to Contract 5.3 (major 5, minor 3, xzbt 5.3); museum-gallery advertises its surface catalog; aquarium/haunted-house/planetarium adapters updated - SciFi-XZBT (Step 6.7A/6.7B): surface-mode.js + surface-bus.js, Observation-surface boot branch, local-change hooks, view.pillars / view.warp-flight targets; fixture byte-identical to G:/.vibe/SciFi-XZBT - SciFi-XZBT contract adapter handshake fix: the inbound bridge filter no longer gates on an exact advisory xzbt value (Contract 5.3 §6.5), only on its presence/type, matching the host's own envelope validation; the adapter now advertises contract minor 3 / version 5.3.0, which it already implemented via the 5.3 surfaces field. Root cause of the five failing postmessage-interop tests (host hello was silently dropped). - docs: architecture 6.4 and 6.7A, reference 6.6 and 6.7; evidence logs; test-fixtures/PROVENANCE.md resync record Test results: NGN 154/154 (was 149/154); postmessage-interop 7/7 (was 2/7); SciFi contract harness 21/21, real-adapter suite 32/32. git diff --check clean for changed files; two pre-existing trailing-whitespace lines remain in test-fixtures/reference-exhibits/scifi/index.html, copied verbatim from the authoritative SciFi source. Step 6.7 live verification (browser Observation, packaged standalone) is still pending and is not claimed here.
884 lines
33 KiB
JavaScript
884 lines
33 KiB
JavaScript
/*
|
|
* XZBT Exhibit Contract 5.3 — 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 = 3;
|
|
var XZBT_VERSION = '5.3';
|
|
|
|
/*
|
|
* Contract 5.3 presentation-surface support.
|
|
*
|
|
* `surfaces` remains OPTIONAL per-exhibit: an exhibit that does not supply
|
|
* a SurfaceCatalog simply gets no `surfaces` key in `describe()` at all
|
|
* (Contract 5.3 §7). This file remains domain-free; it knows the *shape*
|
|
* of Contract 5.3 Section 31, not any exhibit's surface content.
|
|
*
|
|
* `CONTRACT_MINOR`/`XZBT_VERSION` above are this module's defaults, used by
|
|
* any exhibit that does not pass its own `contractMinor`/`xzbtVersion` to
|
|
* ContractCore. XZBT-NGN's maintained exhibit set moves forward with the
|
|
* contract (Contract 5.3 Changelog, §39): there is no standing requirement
|
|
* to keep a *currently maintained* exhibit frozen on an old minor version
|
|
* merely because it once shipped against it. An exhibit that has a real,
|
|
* independently justified reason to stay on an older minor may still pass
|
|
* an explicit lower `contractMinor`/`xzbtVersion` — nothing here prevents
|
|
* that — but it is no longer the silent default.
|
|
*/
|
|
|
|
/* Contract §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 §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 §17. */
|
|
var CAPABILITY_STATES = ['unsupported', 'available', 'loading', 'ready', 'busy', 'error'];
|
|
|
|
/* Contract §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;
|
|
};
|
|
|
|
/* ------------------------------------------------------------------ *
|
|
* Presentation surfaces (Contract 5.3 §31)
|
|
* ------------------------------------------------------------------ */
|
|
|
|
/**
|
|
* Validates a candidate surface `url` against Contract 5.3 §31.4: it must
|
|
* be same-origin-relative (a path and/or query/fragment), never absolute,
|
|
* protocol-relative, or carrying an explicit URL scheme.
|
|
*/
|
|
function isRelativeSurfaceUrl(url) {
|
|
if (typeof url !== 'string' || url.length === 0) return false;
|
|
if (url.indexOf('//') === 0) return false; // protocol-relative
|
|
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(url)) return false; // has a scheme
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* One individual surface descriptor's field-level validity, per Contract
|
|
* 5.3 §31.2. Duplicate ids are treated as an individual-entry failure of
|
|
* the later duplicate, consistent with §31.5's "skip only that entry".
|
|
*
|
|
* @returns {string|null} a diagnostic string, or null when valid.
|
|
*/
|
|
function invalidSurfaceReason(d, seenIds) {
|
|
if (!isPlainObject(d)) return 'entry is not an object';
|
|
if (typeof d.id !== 'string' || !TARGET_ID_PATTERN.test(d.id)) {
|
|
return 'id is missing or does not conform to the canonical grammar (§8.1)';
|
|
}
|
|
if (seenIds[d.id]) return 'duplicate id "' + d.id + '"';
|
|
if (typeof d.label !== 'string' || d.label.length === 0) return 'label is required';
|
|
if (d.kind !== 'surface') return 'kind must be the constant "surface"';
|
|
if (typeof d.primary !== 'boolean') return 'primary must be a boolean';
|
|
if (!isRelativeSurfaceUrl(d.url)) return 'url must be a same-origin-relative reference';
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Implements the deterministic validation order of Contract 5.3 §31.3:
|
|
*
|
|
* 1. validate each individual descriptor, discarding invalid entries;
|
|
* 2. evaluate the `primary` invariant against the surviving valid set;
|
|
* 3. zero valid entries left -> behave as if `surfaces` were absent;
|
|
* 4. exactly one `primary: true` among the valid set -> conformant;
|
|
* 5. zero or multiple `primary: true` among the valid set -> reject the
|
|
* whole catalog, fall back to absent, and record a diagnostic.
|
|
*
|
|
* This constructor never throws: an exhibit's own surface catalog is
|
|
* exhibit-authored input, and the whole point of §31.3's ordering is that
|
|
* a malformed catalog degrades to "no surfaces", not to a crash.
|
|
*/
|
|
function SurfaceCatalog(descriptors) {
|
|
this._byId = {};
|
|
this._order = [];
|
|
this._primaryId = null;
|
|
this._diagnostics = [];
|
|
this._malformed = false;
|
|
|
|
var valid = [];
|
|
var seenIds = {};
|
|
var list = Array.isArray(descriptors) ? descriptors : [];
|
|
for (var i = 0; i < list.length; i++) {
|
|
var reason = invalidSurfaceReason(list[i], seenIds);
|
|
if (reason) {
|
|
this._diagnostics.push(
|
|
'Discarded invalid surface entry at index ' + i + ': ' + reason + '.'
|
|
);
|
|
continue;
|
|
}
|
|
seenIds[list[i].id] = true;
|
|
valid.push(list[i]);
|
|
}
|
|
|
|
if (valid.length === 0) {
|
|
return; // Contract 5.3 §31.3 step 3: empty valid set behaves as absent.
|
|
}
|
|
|
|
var primaryCount = 0;
|
|
for (var j = 0; j < valid.length; j++) {
|
|
if (valid[j].primary === true) primaryCount += 1;
|
|
}
|
|
|
|
if (primaryCount !== 1) {
|
|
this._diagnostics.push(
|
|
'Rejected the surface catalog as a whole: expected exactly one ' +
|
|
'primary:true entry among ' + valid.length + ' valid entries, found ' +
|
|
primaryCount + '. Falling back to implicit single-surface behavior ' +
|
|
'(Contract 5.3 §31.3).'
|
|
);
|
|
this._malformed = true;
|
|
return; // whole catalog discarded; behaves as absent.
|
|
}
|
|
|
|
for (var k = 0; k < valid.length; k++) {
|
|
this._byId[valid[k].id] = valid[k];
|
|
this._order.push(valid[k].id);
|
|
if (valid[k].primary === true) this._primaryId = valid[k].id;
|
|
}
|
|
}
|
|
|
|
/** True when this catalog conformantly reduces to "no surfaces" (Contract 5.3 forms 1/2, or a rejected form-3 catalog). */
|
|
SurfaceCatalog.prototype.isEmpty = function () {
|
|
return this._order.length === 0;
|
|
};
|
|
|
|
/** True specifically when a non-empty input array was rejected for a `primary` violation, as opposed to genuinely having zero entries. */
|
|
SurfaceCatalog.prototype.wasRejectedAsMalformed = function () {
|
|
return this._malformed;
|
|
};
|
|
|
|
SurfaceCatalog.prototype.primaryId = function () {
|
|
return this._primaryId;
|
|
};
|
|
|
|
SurfaceCatalog.prototype.has = function (id) {
|
|
return Object.prototype.hasOwnProperty.call(this._byId, id);
|
|
};
|
|
|
|
SurfaceCatalog.prototype.get = function (id) {
|
|
return this.has(id) ? this._byId[id] : null;
|
|
};
|
|
|
|
/** Surface descriptors as published by `describe`, in stable order. */
|
|
SurfaceCatalog.prototype.descriptors = function () {
|
|
var out = [];
|
|
for (var i = 0; i < this._order.length; i++) out.push(this._byId[this._order[i]]);
|
|
return out;
|
|
};
|
|
|
|
/** Diagnostics accumulated during validation (individual and structural). */
|
|
SurfaceCatalog.prototype.diagnostics = function () {
|
|
return this._diagnostics.slice();
|
|
};
|
|
|
|
/* ------------------------------------------------------------------ *
|
|
* 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
|
|
* @param {SurfaceCatalog} [options.surfaces] Contract 5.3 §31 presentation
|
|
* surfaces. Omitted (the default) means this exhibit does not
|
|
* advertise multi-surface presentation; `describe()` then has no
|
|
* `surfaces` key at all, byte-identical to a pre-5.3 exhibit.
|
|
* @param {number} [options.contractMinor] defaults to the module's
|
|
* CONTRACT_MINOR (2). An exhibit adopting Contract 5.3 passes 3.
|
|
* @param {string} [options.xzbtVersion] defaults to the module's
|
|
* XZBT_VERSION ('5.2'). An exhibit adopting Contract 5.3 passes
|
|
* '5.3'. This governs the advisory `xzbt` envelope tag this
|
|
* instance emits and expects (Contract §6.5).
|
|
*/
|
|
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 () {};
|
|
this.surfaces = options.surfaces || null;
|
|
this.contractMinor = options.contractMinor === undefined ? CONTRACT_MINOR : options.contractMinor;
|
|
this.xzbtVersion = options.xzbtVersion || XZBT_VERSION;
|
|
|
|
/* 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: this.xzbtVersion,
|
|
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 () {
|
|
var out = {
|
|
exhibit: this.identity,
|
|
contract: { major: CONTRACT_MAJOR, minor: this.contractMinor },
|
|
registryRevision: this.registryRevision,
|
|
stateRevision: this.stateRevision,
|
|
capabilities: this.capabilities.snapshot(),
|
|
targets: this.catalog.descriptors()
|
|
};
|
|
/* Contract 5.3 §7: `surfaces` is OPTIONAL and, when an exhibit has not
|
|
* adopted it, MUST be indistinguishable from a 5.2 describe.result — so
|
|
* the key is omitted entirely rather than emitted as `[]` when no
|
|
* SurfaceCatalog was supplied at all. */
|
|
if (this.surfaces) {
|
|
out.surfaces = this.surfaces.descriptors();
|
|
}
|
|
return out;
|
|
};
|
|
|
|
/* ------------------------------------------------------------------ *
|
|
* 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: this.contractMinor }
|
|
}
|
|
};
|
|
};
|
|
|
|
/* ------------------------------------------------------------------ *
|
|
* 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.');
|
|
}
|
|
/* `xzbt` is advisory metadata, not a compatibility gate (Contract §6.5):
|
|
* a 5.3-aware host must still interoperate with a 5.2 exhibit and vice
|
|
* versa. Real compatibility is negotiated by handleHello below via
|
|
* `supportedContractMajors`/`contract.major`/`minor`; rejecting here on
|
|
* an exact string mismatch pre-empted that negotiation and made it
|
|
* impossible for a host and exhibit that both implement the contract,
|
|
* but stamp different advisory xzbt strings, to ever connect. */
|
|
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: this.xzbtVersion,
|
|
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: this.xzbtVersion,
|
|
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,
|
|
SurfaceCatalog: SurfaceCatalog,
|
|
ContractCore: ContractCore
|
|
};
|
|
})();
|