/** * 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));