/* * Museum Gallery — exhibit-internal surface-attachment bus. * * This implements the generic attachment sequence from the Step 6.1 * architecture document (docs/architecture/XZBT-Multi-Surface-Model-Step6.1.md * §7.2) and Contract 5.3 §31.7's "cross-document attachment" transport * shape: BroadcastChannel, same-origin, with a clearly identified * authoritative owner document (the primary surface, control.html). * * This file is NOT part of the XZBT Exhibit Contract wire protocol. It * carries no contract envelope, is never observed by a host, and its * messages are exhibit-internal only. An exhibit is free to choose a * different transport (SharedWorker, in-process) — this reference exhibit's * choice of BroadcastChannel is documented in README.md, not in Contract 5.3. * * The sequence (Step 6.1 §7.2, steps 1-5): * 1. surface requests attachment * 2. owner replies with current snapshot + stateRevision * 3. owner registers the surface as a subscriber for ongoing events * 4. native interactions on the surface are posted back to the owner, * which routes them through the Core's one canonical mutation path * 5. on detach, the owner simply stops delivering to that channel * instance; no state is held by the surface, so nothing reconciles. */ (function () { 'use strict'; var CHANNEL_NAME = 'xzbt-museum-gallery-core-v1'; function randomId() { return 'req-' + Math.random().toString(16).slice(2) + Date.now().toString(16); } /** * Owner side. Call once, in the document that constructs the Exhibit * State Core (control.html). Wraps `core.onEvent` to forward every * normalized event to attached surfaces, and answers attach/mutate * requests by calling straight into the Core's existing methods — never a * second computation of state. * * @param {object} core an XZBTContractCore.ContractCore instance */ function createOwner(core) { var channel = new BroadcastChannel(CHANNEL_NAME); var attachedCount = 0; channel.onmessage = function (ev) { var msg = ev.data; if (!msg || typeof msg !== 'object') return; if (msg.type === 'attach') { attachedCount += 1; channel.postMessage({ type: 'attach.snapshot', inReplyTo: msg.requestId, snapshot: core.stateSnapshot(), registryRevision: core.registryRevision }); return; } if (msg.type === 'mutate') { /* Every surface-originated interaction that changes contract-visible * state or executes a contract-visible action goes through exactly * the same core.applyMutation/invokeAction chokepoint a contract * set/invoke from NGN would use (Contract 5.3 §31.7). Source is * always 'ui': a surface interaction is exhibit-native UI regardless * of which surface it originated on (Contract 5.3 §15). */ if (msg.kind === 'set' && typeof msg.target === 'string') { core.applyMutation(msg.target, msg.value, 'ui'); } else if (msg.kind === 'invoke' && typeof msg.target === 'string') { core.invokeAction(msg.target, msg.args || {}, 'ui'); } return; } if (msg.type === 'detach') { attachedCount = Math.max(0, attachedCount - 1); } }; var priorOnEvent = core.onEvent; core.onEvent = function (event) { if (typeof priorOnEvent === 'function') priorOnEvent(event); channel.postMessage({ type: 'core-event', event: event }); }; return { channel: channel, attachedCount: function () { return attachedCount; }, close: function () { channel.close(); } }; } /** * Surface side. Call from any non-primary surface document. Requests * attachment and waits `timeoutMs` for a reply; if the owner document * (control.html) is not open, `onTimeout` fires and the surface must * degrade to a clear waiting/disconnected state rather than inventing its * own Core (Contract 5.3 §31.8, Step 6.1 §7.2). * * @param {object} options * @param {number} [options.timeoutMs] * @param {function} [options.onSnapshot] (snapshot, registryRevision) * @param {function} [options.onEvent] (normalizedEvent) * @param {function} [options.onTimeout] */ function attach(options) { options = options || {}; var channel = new BroadcastChannel(CHANNEL_NAME); var requestId = randomId(); var attached = false; var timer = setTimeout(function () { if (attached) return; if (typeof options.onTimeout === 'function') options.onTimeout(); }, options.timeoutMs || 1000); channel.onmessage = function (ev) { var msg = ev.data; if (!msg || typeof msg !== 'object') return; if (msg.type === 'attach.snapshot' && msg.inReplyTo === requestId && !attached) { attached = true; clearTimeout(timer); if (typeof options.onSnapshot === 'function') { options.onSnapshot(msg.snapshot, msg.registryRevision); } return; } if (msg.type === 'core-event' && attached) { if (typeof options.onEvent === 'function') options.onEvent(msg.event); } }; channel.postMessage({ type: 'attach', requestId: requestId }); return { channel: channel, isAttached: function () { return attached; }, /** Route a native interaction through the owner's canonical mutation path. */ mutate: function (kind, target, valueOrArgs) { if (!attached) 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: function () { if (attached) channel.postMessage({ type: 'detach' }); clearTimeout(timer); channel.close(); } }; } window.MuseumGallerySurfaceBus = { CHANNEL_NAME: CHANNEL_NAME, createOwner: createOwner, attach: attach }; })();