/* * 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 * @param {object} [options] * @param {function} [options.onConnectionChange] (attachedCount) */ function createOwner(core, options) { options = options || {}; var channel = new BroadcastChannel(CHANNEL_NAME); /* Participant bookkeeping is identity-based (a Set of live participant * ids), not a bare counter. This is what makes it idempotent: a * duplicate `attach` from a still-live participant cannot inflate the * count, and a `detach` for an id that is not (or no longer) present * cannot underflow it -- both matter because iframe reload/removal can * make attach/detach delivery imperfect (see attach()'s participantId * and local-surfaces.js's pre-removal dispose call). */ var participants = new Set(); function notifyConnectionChange() { if (typeof options.onConnectionChange === 'function') { options.onConnectionChange(participants.size); } } 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, snapshot: core.stateSnapshot(), registryRevision: core.registryRevision }); notifyConnectionChange(); 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') { /* A detach with no/unknown participantId (a stray legacy message, a * duplicate, or one that arrives after that id was already removed) * is a no-op -- Set.delete() only reports and fires the callback * when it actually removed something live. */ if (typeof msg.participantId === 'string' && participants.delete(msg.participantId)) { notifyConnectionChange(); } } }; 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 participants.size; }, 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(); /* A stable identity for THIS attachment (this document load, this * BroadcastChannel instance). A reload creates a brand-new attach() call * and therefore a brand-new participantId, which is exactly what lets * the owner's Set-based bookkeeping retire the old identity and adopt * the new one without the two ever being confused for each other. */ var participantId = randomId(); var attached = false; var detached = 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, participantId: participantId }); function detach() { /* Idempotent: local-surfaces.js's deterministic pre-removal dispose * call and this document's own best-effort `beforeunload` handler can * both end up calling detach() for the same attachment. Only the * first actually posts a message; every call still tears down the * timer/channel so repeated calls are always safe. */ if (attached && !detached) { detached = true; channel.postMessage({ type: 'detach', participantId: participantId }); } clearTimeout(timer); channel.close(); } 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: detach }; } window.MuseumGallerySurfaceBus = { CHANNEL_NAME: CHANNEL_NAME, createOwner: createOwner, attach: attach }; })();