generated from Labyricorn/labyricorn-project-template
Steps 6.4-6.7B — Local surfaces, reference-exhibit validation, SciFi Observation surface
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.
This commit is contained in:
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* XZBT Surface Bus — SciFi-XZBT (Step 6.7B)
|
||||
*
|
||||
* Exhibit-internal state mirror between the authoritative console document
|
||||
* and the non-authoritative Observation surface document. Adapted from
|
||||
* Museum Gallery's proven `surface-bus.js` shape (Steps 6.3-6.6), with one
|
||||
* deliberate improvement: the BroadcastChannel name is scoped to a single
|
||||
* console instance id (XZBTSurfaceMode.channelName), so two console tabs on
|
||||
* one origin cannot cross-talk.
|
||||
*
|
||||
* This carries NO XZBT Exhibit Contract envelope — no `xzbt` field, no
|
||||
* `sessionId` — and is never observed by an NGN host (Contract 5.3 §31.7's
|
||||
* explicit allowance for exhibit-internal transports). Message shapes:
|
||||
*
|
||||
* surface -> owner : { type:'attach', requestId, participantId }
|
||||
* owner -> surface : { type:'attach.snapshot', inReplyTo, values,
|
||||
* stateRevision, registryRevision,
|
||||
* presentation:{ tickerText } }
|
||||
* owner -> all : { type:'state', target, value, stateRevision }
|
||||
* owner -> all : { type:'action', target, args }
|
||||
* owner -> all : { type:'presentation', kind:'ticker'|'obs-activity', ... }
|
||||
* surface -> owner : { type:'mutate', kind:'set'|'invoke', target, value|args }
|
||||
* surface -> owner : { type:'detach', participantId }
|
||||
*/
|
||||
(function (root) {
|
||||
'use strict';
|
||||
|
||||
function randomId() {
|
||||
return 'req-' + Math.random().toString(16).slice(2) + Date.now().toString(16);
|
||||
}
|
||||
|
||||
/**
|
||||
* Owner side. Call once, in the console (authoritative) document, after
|
||||
* the XZBTContractAdapter has been constructed.
|
||||
*
|
||||
* @param {object} adapter an XZBTContractAdapter instance
|
||||
* @param {object} options
|
||||
* @param {string} options.channelName instance-scoped channel name
|
||||
* @param {function} [options.onParticipantsChanged] (attachedCount)
|
||||
*/
|
||||
function createOwner(adapter, options) {
|
||||
options = options || {};
|
||||
if (!options.channelName) {
|
||||
throw new Error('surface-bus: createOwner requires options.channelName');
|
||||
}
|
||||
var channel = new BroadcastChannel(options.channelName);
|
||||
var lastPresentation = { tickerText: '' };
|
||||
|
||||
// Set-based participant bookkeeping (not a counter): a duplicate/late
|
||||
// attach or detach can never ratchet the count (Museum's lesson).
|
||||
var participants = new Set();
|
||||
var closed = false;
|
||||
|
||||
function notifyParticipantsChanged() {
|
||||
if (typeof options.onParticipantsChanged === 'function') {
|
||||
options.onParticipantsChanged(participants.size);
|
||||
}
|
||||
}
|
||||
|
||||
function currentValues() {
|
||||
var state = adapter.getContractState();
|
||||
var values = {};
|
||||
adapter.targets.forEach(function (target, id) {
|
||||
if (target.readable && target.kind !== 'impulse') {
|
||||
values[id] = state[id];
|
||||
}
|
||||
});
|
||||
return values;
|
||||
}
|
||||
|
||||
channel.onmessage = function (ev) {
|
||||
var msg = ev.data;
|
||||
if (!msg || typeof msg !== 'object') return;
|
||||
|
||||
if (msg.type === 'attach') {
|
||||
if (typeof msg.participantId === 'string') {
|
||||
participants.add(msg.participantId);
|
||||
}
|
||||
channel.postMessage({
|
||||
type: 'attach.snapshot',
|
||||
inReplyTo: msg.requestId,
|
||||
values: currentValues(),
|
||||
stateRevision: adapter.stateRevision,
|
||||
registryRevision: adapter.registryRevision,
|
||||
presentation: lastPresentation
|
||||
});
|
||||
notifyParticipantsChanged();
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'mutate') {
|
||||
// Every surface-originated interaction goes through exactly the
|
||||
// same applyMutation/invokeAction chokepoint a contract set/invoke
|
||||
// from NGN would use. Source is always 'ui'.
|
||||
if (msg.kind === 'set' && typeof msg.target === 'string') {
|
||||
adapter.applyMutation(msg.target, msg.value, 'ui');
|
||||
} else if (msg.kind === 'invoke' && typeof msg.target === 'string') {
|
||||
adapter.invokeAction(msg.target, msg.args || {}, 'ui');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'detach') {
|
||||
if (typeof msg.participantId === 'string' && participants.delete(msg.participantId)) {
|
||||
notifyParticipantsChanged();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
channel: channel,
|
||||
attachedCount: function () { return participants.size; },
|
||||
/** Broadcast one changed target (fed from adapter.onLocalChange). */
|
||||
broadcastState: function (target, value, stateRevision) {
|
||||
if (closed) return;
|
||||
channel.postMessage({ type: 'state', target: target, value: value, stateRevision: stateRevision });
|
||||
},
|
||||
/** Broadcast an executed impulse (fed from adapter.onLocalAction). */
|
||||
broadcastAction: function (target, args) {
|
||||
if (closed) return;
|
||||
channel.postMessage({ type: 'action', target: target, args: args || {} });
|
||||
},
|
||||
/** Broadcast a transient presentation-only extra (ticker text, an
|
||||
* ambient-activity decision). Not contract state. */
|
||||
broadcastPresentation: function (kind, payload) {
|
||||
if (closed) return;
|
||||
var msg = Object.assign({ type: 'presentation', kind: kind }, payload || {});
|
||||
if (kind === 'ticker' && payload && typeof payload.tickerText === 'string') {
|
||||
lastPresentation = { tickerText: payload.tickerText };
|
||||
}
|
||||
channel.postMessage(msg);
|
||||
},
|
||||
detach: function () {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
channel.close();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Surface side. Call from the Observation surface document. Requests
|
||||
* attachment and waits `timeoutMs` for a reply; if no owner document is
|
||||
* open on the same channel, `onTimeout` fires and the surface must
|
||||
* degrade to a waiting state rather than inventing its own authority.
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {string} options.channelName instance-scoped channel name
|
||||
* @param {number} [options.timeoutMs]
|
||||
* @param {function} [options.onSnapshot] (values, stateRevision, registryRevision, presentation)
|
||||
* @param {function} [options.onChange] (target, value, stateRevision)
|
||||
* @param {function} [options.onAction] (target, args)
|
||||
* @param {function} [options.onPresentation] (kind, payload)
|
||||
* @param {function} [options.onTimeout]
|
||||
*/
|
||||
function attach(options) {
|
||||
options = options || {};
|
||||
if (!options.channelName) {
|
||||
throw new Error('surface-bus: attach requires options.channelName');
|
||||
}
|
||||
var channel = new BroadcastChannel(options.channelName);
|
||||
var requestId = randomId();
|
||||
// A fresh participantId per attach() call. A reload is a brand-new
|
||||
// attach() and therefore a brand-new id, which is what lets the
|
||||
// owner's Set-based bookkeeping retire the old identity cleanly.
|
||||
var participantId = randomId();
|
||||
var attachedFlag = false;
|
||||
var detachedFlag = false;
|
||||
|
||||
var timer = setTimeout(function () {
|
||||
if (attachedFlag) return;
|
||||
if (typeof options.onTimeout === 'function') options.onTimeout();
|
||||
}, options.timeoutMs || 1500);
|
||||
|
||||
channel.onmessage = function (ev) {
|
||||
var msg = ev.data;
|
||||
if (!msg || typeof msg !== 'object') return;
|
||||
|
||||
if (msg.type === 'attach.snapshot' && msg.inReplyTo === requestId && !attachedFlag) {
|
||||
attachedFlag = true;
|
||||
clearTimeout(timer);
|
||||
if (typeof options.onSnapshot === 'function') {
|
||||
options.onSnapshot(msg.values, msg.stateRevision, msg.registryRevision, msg.presentation);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!attachedFlag) return;
|
||||
|
||||
if (msg.type === 'state') {
|
||||
if (typeof options.onChange === 'function') {
|
||||
options.onChange(msg.target, msg.value, msg.stateRevision);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'action') {
|
||||
if (typeof options.onAction === 'function') {
|
||||
options.onAction(msg.target, msg.args);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'presentation') {
|
||||
if (typeof options.onPresentation === 'function') {
|
||||
options.onPresentation(msg.kind, msg);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
channel.postMessage({ type: 'attach', requestId: requestId, participantId: participantId });
|
||||
|
||||
function detach() {
|
||||
// Idempotent: local-surfaces.js's pre-removal dispose call and this
|
||||
// document's own beforeunload handler can both fire for the same
|
||||
// attachment. Only the first actually posts; every call tears down
|
||||
// the timer/channel so repeated calls are always safe.
|
||||
if (attachedFlag && !detachedFlag) {
|
||||
detachedFlag = true;
|
||||
channel.postMessage({ type: 'detach', participantId: participantId });
|
||||
}
|
||||
clearTimeout(timer);
|
||||
channel.close();
|
||||
}
|
||||
|
||||
return {
|
||||
channel: channel,
|
||||
isAttached: function () { return attachedFlag; },
|
||||
/** Route a native surface interaction through the owner's canonical
|
||||
* mutation path. No-op (returns false) if not yet attached. */
|
||||
mutate: function (kind, target, valueOrArgs) {
|
||||
if (!attachedFlag) return false;
|
||||
var payload = { type: 'mutate', kind: kind, target: target };
|
||||
if (kind === 'set') payload.value = valueOrArgs;
|
||||
else payload.args = valueOrArgs || {};
|
||||
channel.postMessage(payload);
|
||||
return true;
|
||||
},
|
||||
detach: detach
|
||||
};
|
||||
}
|
||||
|
||||
var XZBTSurfaceBus = {
|
||||
createOwner: createOwner,
|
||||
attach: attach
|
||||
};
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = XZBTSurfaceBus;
|
||||
}
|
||||
if (root) {
|
||||
root.XZBTSurfaceBus = XZBTSurfaceBus;
|
||||
}
|
||||
})(typeof window !== 'undefined' ? window : (typeof globalThis !== 'undefined' ? globalThis : this));
|
||||
Reference in New Issue
Block a user