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.
207 lines
8.1 KiB
JavaScript
207 lines
8.1 KiB
JavaScript
/*
|
|
* 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
|
|
};
|
|
})();
|