generated from Labyricorn/labyricorn-project-template
Step 6.2 Complete — Museum Gallery reference exhibit and Contract 5.3 spec
This commit is contained in:
@@ -29,6 +29,17 @@
|
||||
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'];
|
||||
@@ -119,6 +130,139 @@
|
||||
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
|
||||
* ------------------------------------------------------------------ */
|
||||
@@ -179,6 +323,16 @@
|
||||
* @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;
|
||||
@@ -189,6 +343,9 @@
|
||||
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). */
|
||||
@@ -211,7 +368,7 @@
|
||||
}
|
||||
this.sequence += 1;
|
||||
var event = {
|
||||
xzbt: XZBT_VERSION,
|
||||
xzbt: this.xzbtVersion,
|
||||
type: type,
|
||||
sessionId: this.sessionId,
|
||||
sequence: this.sequence,
|
||||
@@ -502,14 +659,22 @@
|
||||
};
|
||||
|
||||
ContractCore.prototype.describe = function () {
|
||||
return {
|
||||
var out = {
|
||||
exhibit: this.identity,
|
||||
contract: { major: CONTRACT_MAJOR, minor: CONTRACT_MINOR },
|
||||
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;
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
@@ -559,7 +724,7 @@
|
||||
result: {
|
||||
sessionId: this.sessionId,
|
||||
exhibit: this.identity,
|
||||
contract: { major: CONTRACT_MAJOR, minor: CONTRACT_MINOR }
|
||||
contract: { major: CONTRACT_MAJOR, minor: this.contractMinor }
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -581,12 +746,12 @@
|
||||
if (!isPlainObject(message)) {
|
||||
return this._errorEnvelope(null, null, 'INVALID_MESSAGE', 'Message must be an object.');
|
||||
}
|
||||
if (message.xzbt !== XZBT_VERSION) {
|
||||
if (message.xzbt !== this.xzbtVersion) {
|
||||
return this._errorEnvelope(
|
||||
message.requestId || null,
|
||||
message.sessionId || null,
|
||||
'UNSUPPORTED_VERSION',
|
||||
'This exhibit implements contract ' + XZBT_VERSION + '.'
|
||||
'This exhibit implements contract ' + this.xzbtVersion + '.'
|
||||
);
|
||||
}
|
||||
if (typeof message.type !== 'string') {
|
||||
@@ -667,7 +832,7 @@
|
||||
|
||||
ContractCore.prototype._okEnvelope = function (type, requestId, payload) {
|
||||
var envelope = {
|
||||
xzbt: XZBT_VERSION,
|
||||
xzbt: this.xzbtVersion,
|
||||
type: type,
|
||||
requestId: requestId,
|
||||
sessionId: this.sessionId,
|
||||
@@ -681,7 +846,7 @@
|
||||
|
||||
ContractCore.prototype._errorEnvelope = function (requestId, sessionId, code, message) {
|
||||
return {
|
||||
xzbt: XZBT_VERSION,
|
||||
xzbt: this.xzbtVersion,
|
||||
type: 'error',
|
||||
requestId: requestId,
|
||||
sessionId: sessionId,
|
||||
@@ -705,6 +870,7 @@
|
||||
TARGET_ID_PATTERN: TARGET_ID_PATTERN,
|
||||
Catalog: Catalog,
|
||||
CapabilityRegistry: CapabilityRegistry,
|
||||
SurfaceCatalog: SurfaceCatalog,
|
||||
ContractCore: ContractCore
|
||||
};
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user