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.
255 lines
9.3 KiB
JavaScript
255 lines
9.3 KiB
JavaScript
/**
|
|
* 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));
|