Files
XZBT-NGN/test-fixtures/reference-exhibits/shared/contract-core.js
T

877 lines
32 KiB
JavaScript

/*
* 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.3 presentation-surface support (additive, optional).
*
* Every existing exhibit that does not pass `contractMinor`, `xzbtVersion`,
* or `surfaces` to ContractCore gets byte-identical behavior to before this
* addition: the defaults below equal the pre-5.3 constants exactly, and
* `describe()` omits the `surfaces` key entirely unless a SurfaceCatalog
* was supplied. This file remains domain-free; it knows the *shape* of
* Contract 5.3 Section 31, not any exhibit's surface content.
*/
/* 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;
};
/* ------------------------------------------------------------------ *
* 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.');
}
if (message.xzbt !== this.xzbtVersion) {
return this._errorEnvelope(
message.requestId || null,
message.sessionId || null,
'UNSUPPORTED_VERSION',
'This exhibit implements contract ' + this.xzbtVersion + '.'
);
}
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
};
})();