|
|
@@ -38,7 +38,9 @@
|
|
|
*/
|
|
|
var config = {
|
|
|
entrypointUrl: '/_statum/entrypoint',
|
|
|
- slotsUrl: '/_statum/slots'
|
|
|
+ slotsUrl: '/_statum/slots',
|
|
|
+ channelUrl: '/_statum/channel',
|
|
|
+ workerUrl: '/_statum/worker.js'
|
|
|
};
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
@@ -57,6 +59,18 @@
|
|
|
/** Listeners, keyed by type name. @type {Map<string, Set<Function>>} */
|
|
|
var listeners = new Map();
|
|
|
|
|
|
+ /**
|
|
|
+ * Idempotency tracking: slot key -> last-applied frame `signature`. Held in
|
|
|
+ * memory only (per tab), so a frame already persisted by another tab still
|
|
|
+ * re-draws a tab whose tracking lags. Entries are dropped on clear so a
|
|
|
+ * replayed frame re-applies.
|
|
|
+ * @type {Map<string, string>}
|
|
|
+ */
|
|
|
+ var signatures = new Map();
|
|
|
+
|
|
|
+ /** Optional UI handlers (set via `statum.on*Handler`). */
|
|
|
+ var handlers = { onConfirm: null, onNotify: null, onError: null };
|
|
|
+
|
|
|
/** @type {BroadcastChannel|null} */
|
|
|
var channel = null;
|
|
|
|
|
|
@@ -187,6 +201,76 @@
|
|
|
}
|
|
|
StatumError.prototype = Object.create(Error.prototype);
|
|
|
|
|
|
+ // ---------------------------------------------------------------------------
|
|
|
+ // UI handlers (confirm / notify / error)
|
|
|
+ // ---------------------------------------------------------------------------
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Run the `notify` directive's default behaviour or the custom
|
|
|
+ * `statum.onNotifyHandler`. The default surfaces the message via `alert()`.
|
|
|
+ * @param {string} [kind] Application-defined category (freeform).
|
|
|
+ * @param {string} [message]
|
|
|
+ */
|
|
|
+ function triggerNotify(kind, message) {
|
|
|
+ var fn = handlers.onNotify;
|
|
|
+ try {
|
|
|
+ if (fn) fn(kind, message);
|
|
|
+ else if (message != null) window.alert(message);
|
|
|
+ } catch (e) { console.error('[statum] notify handler error', e); }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Run the `stm-confirm` gate: the custom `statum.onConfirmHandler` if set,
|
|
|
+ * otherwise `window.confirm`. Supports async handlers (custom modals).
|
|
|
+ * @param {string} message
|
|
|
+ * @param {HTMLElement} element
|
|
|
+ * @returns {Promise<boolean>} truthy if the action should proceed.
|
|
|
+ */
|
|
|
+ async function triggerConfirm(message, element) {
|
|
|
+ var fn = handlers.onConfirm;
|
|
|
+ try {
|
|
|
+ if (fn) return !!(await fn(message, element));
|
|
|
+ return !!window.confirm(message);
|
|
|
+ } catch (e) {
|
|
|
+ console.error('[statum] confirm handler error', e);
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Run the custom `statum.onErrorHandler`, if set. Returns whether the default
|
|
|
+ * behaviour (promise rejection + `stm-on-error` classes) should proceed: the
|
|
|
+ * handler suppresses the default by returning (or resolving) `false`.
|
|
|
+ * @param {StatumError} error
|
|
|
+ * @param {HTMLElement|null} element
|
|
|
+ * @returns {Promise<boolean>}
|
|
|
+ */
|
|
|
+ async function triggerError(error, element) {
|
|
|
+ var fn = handlers.onError;
|
|
|
+ if (!fn) return true;
|
|
|
+ try {
|
|
|
+ var suppress = await fn(error, element);
|
|
|
+ return suppress !== false;
|
|
|
+ } catch (e) {
|
|
|
+ console.error('[statum] error handler error', e);
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Surface a failure: run `triggerError`, and if the default is not
|
|
|
+ * suppressed, apply `stm-on-error` classes (when an origin element exists)
|
|
|
+ * and re-throw so the originating promise rejects.
|
|
|
+ * @param {StatumError} error
|
|
|
+ * @param {HTMLElement|null} element
|
|
|
+ */
|
|
|
+ async function reportFailure(error, element) {
|
|
|
+ if (await triggerError(error, element)) {
|
|
|
+ if (element) applyErrorClasses(element, true);
|
|
|
+ throw error;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
// ---------------------------------------------------------------------------
|
|
|
// Persistence
|
|
|
// ---------------------------------------------------------------------------
|
|
|
@@ -284,12 +368,13 @@
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
/**
|
|
|
- * Fire all listeners for a type.
|
|
|
+ * Fire all listeners for a type. (Renamed from `notify` to avoid confusion
|
|
|
+ * with the `notify` directive.)
|
|
|
* @param {string} type
|
|
|
* @param {object|null} frame
|
|
|
* @param {object|null} snapshot
|
|
|
*/
|
|
|
- function notify(type, frame, snapshot) {
|
|
|
+ function fireListeners(type, frame, snapshot) {
|
|
|
var set = listeners.get(type);
|
|
|
if (!set) return;
|
|
|
var event = { type: type, frame: frame, snapshot: snapshot };
|
|
|
@@ -310,6 +395,16 @@
|
|
|
try { channel.postMessage(message); } catch (e) {}
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * Broadcast a message to other tabs regardless of slot scope. Used to relay
|
|
|
+ * realtime (live) events so they reach every tab, not just session/device
|
|
|
+ * ones.
|
|
|
+ */
|
|
|
+ function broadcastAlways(message) {
|
|
|
+ if (!channel) return;
|
|
|
+ try { channel.postMessage(message); } catch (e) {}
|
|
|
+ }
|
|
|
+
|
|
|
/** Open the BroadcastChannel and wire inbound messages. */
|
|
|
function setupChannel() {
|
|
|
if (typeof BroadcastChannel === 'undefined') return;
|
|
|
@@ -321,21 +416,22 @@
|
|
|
}
|
|
|
channel.onmessage = function (ev) {
|
|
|
var msg = ev.data;
|
|
|
- if (!msg || msg.kind !== 'set' && msg.kind !== 'clear') return;
|
|
|
+ if (!msg) return;
|
|
|
if (msg.kind === 'set' && msg.frame) {
|
|
|
try {
|
|
|
var snapshot = JSON.parse(msg.frame.content);
|
|
|
var type = snapshot.slot && snapshot.slot.type;
|
|
|
- if (type) {
|
|
|
- var entry = { frame: msg.frame, snapshot: snapshot };
|
|
|
- store.set(type, entry);
|
|
|
- persistEntry(type, entry);
|
|
|
- notify(type, msg.frame, snapshot);
|
|
|
- renderAll();
|
|
|
- }
|
|
|
+ if (type && commitSet(msg.frame, snapshot, type, false)) renderAll();
|
|
|
} catch (e) { /* ignore malformed */ }
|
|
|
- } else if (msg.kind === 'clear' && msg.type) {
|
|
|
- clearType(msg.type, { broadcast: false });
|
|
|
+ } else if (msg.kind === 'clear') {
|
|
|
+ var t = msg.type;
|
|
|
+ if (!t && msg.key != null) {
|
|
|
+ store.forEach(function (entry, type) {
|
|
|
+ if (entry.snapshot.slot && entry.snapshot.slot.key === msg.key) t = type;
|
|
|
+ });
|
|
|
+ }
|
|
|
+ if (t) clearType(t, { broadcast: false });
|
|
|
+ else if (msg.key != null) signatures.delete(msg.key);
|
|
|
renderAll();
|
|
|
}
|
|
|
};
|
|
|
@@ -346,8 +442,8 @@
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
/**
|
|
|
- * Remove a type from the store, un-persist it, fire listeners, and
|
|
|
- * (optionally) broadcast the clear to other tabs.
|
|
|
+ * Remove a type from the store, un-persist it, drop its idempotency entry,
|
|
|
+ * fire listeners, and (optionally) broadcast the clear to other tabs.
|
|
|
* @param {string} type
|
|
|
* @param {{broadcast?: boolean}} [opts]
|
|
|
*/
|
|
|
@@ -355,14 +451,60 @@
|
|
|
var entry = store.get(type);
|
|
|
if (!entry) return;
|
|
|
var scope = entry.snapshot.slot && entry.snapshot.slot.scope;
|
|
|
+ var key = entry.snapshot.slot && entry.snapshot.slot.key;
|
|
|
store.delete(type);
|
|
|
removePersisted(type);
|
|
|
- notify(type, null, null);
|
|
|
- if (opts && opts.broadcast) broadcast({ kind: 'clear', type: type }, scope);
|
|
|
+ if (key) signatures.delete(key);
|
|
|
+ fireListeners(type, null, null);
|
|
|
+ if (opts && opts.broadcast) broadcast({ kind: 'clear', key: key, type: type }, scope);
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * Apply a `set` directive: store/refresh a slot's snapshot.
|
|
|
+ * Resolve a slot key to its type and clear it. Used by the `clear` directive
|
|
|
+ * (keyed) and by live `clear` events.
|
|
|
+ * @param {string} key
|
|
|
+ * @param {boolean} [broadcast]
|
|
|
+ */
|
|
|
+ function clearByKey(key, broadcast) {
|
|
|
+ var typeToRemove = null;
|
|
|
+ store.forEach(function (entry, type) {
|
|
|
+ if (entry.snapshot.slot && entry.snapshot.slot.key === key) typeToRemove = type;
|
|
|
+ });
|
|
|
+ if (typeToRemove) clearType(typeToRemove, { broadcast: !!broadcast });
|
|
|
+ else if (key) signatures.delete(key);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Commit a `set` to the in-memory store, persist it, and fire listeners.
|
|
|
+ * Idempotent: if the frame's `signature` matches the last-applied signature
|
|
|
+ * for this slot (tracked in {@link signatures}), nothing happens and `false`
|
|
|
+ * is returned. When `doBroadcast` is true the set is forwarded to other tabs
|
|
|
+ * via the scope-gated broadcast (session/device only).
|
|
|
+ * @param {object} frame
|
|
|
+ * @param {object} snapshot
|
|
|
+ * @param {string} type
|
|
|
+ * @param {boolean} doBroadcast
|
|
|
+ * @returns {boolean} `true` if state changed.
|
|
|
+ */
|
|
|
+ function commitSet(frame, snapshot, type, doBroadcast) {
|
|
|
+ var key = snapshot.slot && snapshot.slot.key;
|
|
|
+ var sig = frame.signature;
|
|
|
+ if (key && sig !== undefined && signatures.get(key) === sig) return false;
|
|
|
+ var entry = { frame: frame, snapshot: snapshot };
|
|
|
+ store.set(type, entry);
|
|
|
+ persistEntry(type, entry);
|
|
|
+ fireListeners(type, frame, snapshot);
|
|
|
+ if (key && sig !== undefined) signatures.set(key, sig);
|
|
|
+ if (doBroadcast) {
|
|
|
+ var scope = snapshot.slot && snapshot.slot.scope;
|
|
|
+ broadcast({ kind: 'set', frame: frame }, scope);
|
|
|
+ }
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Apply a `set` directive: store/refresh a slot's snapshot (idempotently),
|
|
|
+ * broadcasting to other tabs.
|
|
|
* @param {object} directive `{ snapshot_frame }`
|
|
|
*/
|
|
|
function applySet(directive) {
|
|
|
@@ -372,12 +514,7 @@
|
|
|
try { snapshot = JSON.parse(frame.content); } catch (e) { return; }
|
|
|
var type = snapshot.slot && snapshot.slot.type;
|
|
|
if (!type) return;
|
|
|
- var entry = { frame: frame, snapshot: snapshot };
|
|
|
- store.set(type, entry);
|
|
|
- persistEntry(type, entry);
|
|
|
- notify(type, frame, snapshot);
|
|
|
- var scope = snapshot.slot && snapshot.slot.scope;
|
|
|
- broadcast({ kind: 'set', frame: frame }, scope);
|
|
|
+ commitSet(frame, snapshot, type, true);
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
@@ -386,12 +523,36 @@
|
|
|
* @param {object} directive `{ key }`
|
|
|
*/
|
|
|
function applyClear(directive) {
|
|
|
- var key = directive.key;
|
|
|
- var typeToRemove = null;
|
|
|
- store.forEach(function (entry, type) {
|
|
|
- if (entry.snapshot.slot && entry.snapshot.slot.key === key) typeToRemove = type;
|
|
|
- });
|
|
|
- if (typeToRemove) clearType(typeToRemove, { broadcast: true });
|
|
|
+ if (directive.key == null) return;
|
|
|
+ clearByKey(directive.key, true);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Apply a live `set` event received over the realtime channel. The frame is
|
|
|
+ * committed (idempotently) and, when new, broadcast to sibling tabs via the
|
|
|
+ * scope-agnostic {@link broadcastAlways} so every tab sees it regardless of
|
|
|
+ * which (or how many) tabs hold a subscription.
|
|
|
+ * @param {object} frame
|
|
|
+ */
|
|
|
+ function applyRemoteSet(frame) {
|
|
|
+ try {
|
|
|
+ var snapshot = JSON.parse(frame.content);
|
|
|
+ var type = snapshot.slot && snapshot.slot.type;
|
|
|
+ if (!type) return;
|
|
|
+ if (commitSet(frame, snapshot, type, false)) {
|
|
|
+ broadcastAlways({ kind: 'set', frame: frame });
|
|
|
+ renderAll();
|
|
|
+ }
|
|
|
+ } catch (e) { /* malformed frame */ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** Apply a live `clear` event received over the realtime channel. */
|
|
|
+ function applyRemoteClear(key) {
|
|
|
+ if (key == null) return;
|
|
|
+ signatures.delete(key);
|
|
|
+ clearByKey(key, false);
|
|
|
+ broadcastAlways({ kind: 'clear', key: key });
|
|
|
+ renderAll();
|
|
|
}
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
@@ -506,15 +667,16 @@
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * Apply the directives from a response. Set/clear run first, then `render`,
|
|
|
- * then terminal navigation (`navigate`, then `post`). Returns any error
|
|
|
- * directive encountered without throwing, so callers can decide.
|
|
|
+ * Apply the directives from a response. set/clear/subscribe/unsubscribe/notify
|
|
|
+ * run first, then `render`, then terminal navigation (`navigate`, then `post`).
|
|
|
+ * Error directives are collected (not thrown) so the caller can route them
|
|
|
+ * through `reportFailure` (and thus `onErrorHandler`).
|
|
|
* @param {object[]} directives
|
|
|
* @param {{originElement?: HTMLElement}} [ctx]
|
|
|
- * @returns {{error?: StatumError}}
|
|
|
+ * @returns {{errors: StatumError[]}}
|
|
|
*/
|
|
|
function applyDirectives(directives, ctx) {
|
|
|
- var result = { error: undefined };
|
|
|
+ var result = { errors: [] };
|
|
|
var list = Array.isArray(directives) ? directives : [];
|
|
|
var navigateDirective = null;
|
|
|
var postDirective = null;
|
|
|
@@ -525,10 +687,10 @@
|
|
|
switch (d.type) {
|
|
|
case 'set': applySet(d); break;
|
|
|
case 'clear': applyClear(d); break;
|
|
|
- case 'error':
|
|
|
- result.error = new StatumError(d.message, d.code, d.data);
|
|
|
- if (ctx && ctx.originElement) applyErrorClasses(ctx.originElement, true);
|
|
|
- break;
|
|
|
+ case 'subscribe': channelSubscribe(d.key); break;
|
|
|
+ case 'unsubscribe': channelUnsubscribe(d.key); break;
|
|
|
+ case 'notify': triggerNotify(d.kind, d.message); break;
|
|
|
+ case 'error': result.errors.push(new StatumError(d.message, d.code, d.data)); break;
|
|
|
case 'navigate': navigateDirective = d; break;
|
|
|
case 'post': postDirective = d; break;
|
|
|
}
|
|
|
@@ -572,14 +734,18 @@
|
|
|
* @param {{originElement?: HTMLElement}} [ctx]
|
|
|
*/
|
|
|
async function handleResponse(res, ctx) {
|
|
|
+ var el = ctx && ctx.originElement;
|
|
|
if (!isStatum(res)) {
|
|
|
- if (!res.ok) throw new StatumError('HTTP ' + res.status);
|
|
|
+ if (!res.ok) await reportFailure(new StatumError('HTTP ' + res.status), el);
|
|
|
return;
|
|
|
}
|
|
|
var directives;
|
|
|
try { directives = await res.json(); } catch (e) { directives = []; }
|
|
|
var result = applyDirectives(directives, ctx);
|
|
|
- if (result.error) throw result.error;
|
|
|
+ var errors = result.errors || [];
|
|
|
+ for (var i = 0; i < errors.length; i++) {
|
|
|
+ await reportFailure(errors[i], el);
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
@@ -632,10 +798,173 @@
|
|
|
headers.set('Content-Type', 'application/x-www-form-urlencoded');
|
|
|
}
|
|
|
|
|
|
- var res = await fetch(url, init);
|
|
|
+ var res;
|
|
|
+ try {
|
|
|
+ res = await fetch(url, init);
|
|
|
+ } catch (networkErr) {
|
|
|
+ await reportFailure(new StatumError('Network error'), originElement);
|
|
|
+ return;
|
|
|
+ }
|
|
|
await handleResponse(res, { originElement: originElement });
|
|
|
}
|
|
|
|
|
|
+ // ---------------------------------------------------------------------------
|
|
|
+ // Realtime channel (subscribe/unsubscribe directives)
|
|
|
+ // ---------------------------------------------------------------------------
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Per-tab fallback engine, used when `SharedWorker` is unavailable. Owns an
|
|
|
+ * `EventSource`, reads the channel id from the first `event: channel`,
|
|
|
+ * reference-counts slot keys within this tab, PATCHes subscription changes,
|
|
|
+ * applies incoming set/clear events (broadcasting them to siblings), and
|
|
|
+ * closes the stream once it has no subscriptions.
|
|
|
+ */
|
|
|
+ function createFallbackEngine() {
|
|
|
+ var es = null;
|
|
|
+ var channelId = null;
|
|
|
+ var refs = new Map(); // slotKey -> count
|
|
|
+ var pending = { subscribe: [], unsubscribe: [] };
|
|
|
+ var patchScheduled = false;
|
|
|
+
|
|
|
+ function openStream() {
|
|
|
+ if (es) return;
|
|
|
+ try { es = new EventSource(config.channelUrl); }
|
|
|
+ catch (e) { es = null; return; }
|
|
|
+ es.addEventListener('channel', function (ev) { channelId = ev.data; flushPatch(); });
|
|
|
+ es.addEventListener('set', function (ev) {
|
|
|
+ try { applyRemoteSet(JSON.parse(ev.data)); } catch (e) {}
|
|
|
+ });
|
|
|
+ es.addEventListener('clear', function (ev) { applyRemoteClear(ev.data); });
|
|
|
+ es.onerror = function () { /* EventSource auto-reconnects */ };
|
|
|
+ }
|
|
|
+
|
|
|
+ function closeStream() {
|
|
|
+ if (es) { try { es.close(); } catch (e) {} es = null; }
|
|
|
+ channelId = null;
|
|
|
+ }
|
|
|
+
|
|
|
+ function schedulePatch() {
|
|
|
+ if (patchScheduled) return;
|
|
|
+ patchScheduled = true;
|
|
|
+ Promise.resolve().then(function () { patchScheduled = false; flushPatch(); });
|
|
|
+ }
|
|
|
+
|
|
|
+ function flushPatch() {
|
|
|
+ if (!channelId) return;
|
|
|
+ var sub = pending.subscribe, uns = pending.unsubscribe;
|
|
|
+ if (!sub.length && !uns.length) return;
|
|
|
+ pending = { subscribe: [], unsubscribe: [] };
|
|
|
+ var body = {};
|
|
|
+ if (sub.length) body.subscribe = sub;
|
|
|
+ if (uns.length) body.unsubscribe = uns;
|
|
|
+ try {
|
|
|
+ fetch(config.channelUrl + '/' + encodeURIComponent(channelId), {
|
|
|
+ method: 'PATCH',
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
+ credentials: 'same-origin',
|
|
|
+ body: JSON.stringify(body)
|
|
|
+ }).catch(function () {});
|
|
|
+ } catch (e) {}
|
|
|
+ }
|
|
|
+
|
|
|
+ return {
|
|
|
+ subscribe: function (key) {
|
|
|
+ var c = refs.get(key) || 0;
|
|
|
+ refs.set(key, c + 1);
|
|
|
+ if (c === 0) { openStream(); pending.subscribe.push(key); schedulePatch(); }
|
|
|
+ },
|
|
|
+ unsubscribe: function (key) {
|
|
|
+ var c = refs.get(key) || 0;
|
|
|
+ if (c <= 0) return;
|
|
|
+ if (c - 1 <= 0) {
|
|
|
+ refs.delete(key);
|
|
|
+ pending.unsubscribe.push(key);
|
|
|
+ schedulePatch();
|
|
|
+ if (refs.size === 0) setTimeout(function () { if (refs.size === 0) closeStream(); }, 0);
|
|
|
+ } else {
|
|
|
+ refs.set(key, c - 1);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Channel manager. Prefers a `SharedWorker` (which owns the single SSE
|
|
|
+ * connection, reference-counts subscriptions across tabs, and relays events
|
|
|
+ * through the cross-tab `BroadcastChannel`); falls back to a per-tab
|
|
|
+ * `EventSource` engine when `SharedWorker` is unavailable or the worker
|
|
|
+ * script fails to load. Tracks the true per-slot subscription count so it can
|
|
|
+ * replay active subscriptions into the fallback on a mid-session switch.
|
|
|
+ */
|
|
|
+ function createChannelManager() {
|
|
|
+ var port = null;
|
|
|
+ var workerObj = null;
|
|
|
+ var fallback = null;
|
|
|
+ var mode = null; // 'worker' | 'fallback' | null
|
|
|
+ var counts = new Map(); // slotKey -> count (true subscription state)
|
|
|
+
|
|
|
+ function switchToFallback() {
|
|
|
+ if (mode === 'fallback') return;
|
|
|
+ mode = 'fallback';
|
|
|
+ port = null;
|
|
|
+ workerObj = null;
|
|
|
+ fallback = createFallbackEngine();
|
|
|
+ counts.forEach(function (c, key) {
|
|
|
+ for (var i = 0; i < c; i++) fallback.subscribe(key);
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ function ensure() {
|
|
|
+ if (mode !== null) return;
|
|
|
+ if (typeof SharedWorker !== 'undefined') {
|
|
|
+ try {
|
|
|
+ workerObj = new SharedWorker(config.workerUrl);
|
|
|
+ port = workerObj.port;
|
|
|
+ port.start();
|
|
|
+ port.postMessage({ op: 'init', url: config.channelUrl });
|
|
|
+ // The worker delivers events via the BroadcastChannel, so nothing is
|
|
|
+ // required on this port beyond command messages.
|
|
|
+ port.onmessage = function () {};
|
|
|
+ workerObj.onerror = function () {
|
|
|
+ console.warn('[statum] realtime worker failed; falling back to per-tab channel');
|
|
|
+ switchToFallback();
|
|
|
+ };
|
|
|
+ window.addEventListener('pagehide', function () {
|
|
|
+ if (mode === 'worker' && port) {
|
|
|
+ try { port.postMessage({ op: 'disconnect' }); } catch (e) {}
|
|
|
+ }
|
|
|
+ });
|
|
|
+ mode = 'worker';
|
|
|
+ return;
|
|
|
+ } catch (e) { workerObj = null; port = null; }
|
|
|
+ }
|
|
|
+ switchToFallback();
|
|
|
+ }
|
|
|
+
|
|
|
+ return {
|
|
|
+ subscribe: function (key) {
|
|
|
+ if (key == null) return;
|
|
|
+ ensure();
|
|
|
+ counts.set(key, (counts.get(key) || 0) + 1);
|
|
|
+ if (mode === 'worker') { try { port.postMessage({ op: 'subscribe', key: key }); } catch (e) {} }
|
|
|
+ else fallback.subscribe(key);
|
|
|
+ },
|
|
|
+ unsubscribe: function (key) {
|
|
|
+ if (key == null) return;
|
|
|
+ var c = (counts.get(key) || 0) - 1;
|
|
|
+ if (c <= 0) counts.delete(key); else counts.set(key, c);
|
|
|
+ ensure();
|
|
|
+ if (mode === 'worker') { try { port.postMessage({ op: 'unsubscribe', key: key }); } catch (e) {} }
|
|
|
+ else fallback.unsubscribe(key);
|
|
|
+ }
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ var channelManager = createChannelManager();
|
|
|
+
|
|
|
+ function channelSubscribe(key) { channelManager.subscribe(key); }
|
|
|
+ function channelUnsubscribe(key) { channelManager.unsubscribe(key); }
|
|
|
+
|
|
|
// ---------------------------------------------------------------------------
|
|
|
// Rendering: loops, conditionals, bindings
|
|
|
// ---------------------------------------------------------------------------
|
|
|
@@ -881,15 +1210,42 @@
|
|
|
processInner(el, scope);
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * Collect `stm-def-{name}-as` attributes on an element into a child scope.
|
|
|
+ * Each expression is evaluated against the incoming scope (so the new
|
|
|
+ * variables cannot reference each other within the same element), and the
|
|
|
+ * results are added to a child scope visible to the element and its
|
|
|
+ * descendants. Multiple defs on one element are supported. If there are no
|
|
|
+ * defs, the original scope is returned unchanged.
|
|
|
+ * @param {Element} el
|
|
|
+ * @param {object} scope
|
|
|
+ * @returns {object}
|
|
|
+ */
|
|
|
+ function applyDefs(el, scope) {
|
|
|
+ var attrs = el.attributes;
|
|
|
+ var child = null;
|
|
|
+ for (var i = 0; i < attrs.length; i++) {
|
|
|
+ var name = attrs[i].name;
|
|
|
+ if (name.indexOf('stm-def-') === 0 && name.slice(-3) === '-as') {
|
|
|
+ var varName = name.slice(8, -3);
|
|
|
+ if (!varName) continue;
|
|
|
+ if (!child) child = Object.create(scope || null);
|
|
|
+ child[varName] = evalExpr(attrs[i].value, scope);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return child || scope;
|
|
|
+ }
|
|
|
+
|
|
|
/** Bind the non-control-flow attributes and recurse into children. */
|
|
|
function processInner(el, scope) {
|
|
|
- el.__stmScope = scope; // latest scope, used when an action fires
|
|
|
- handleClass(el, scope);
|
|
|
- handleAttribute(el, scope);
|
|
|
- handleDisabled(el, scope);
|
|
|
- handleTextHtml(el, scope);
|
|
|
+ var s = applyDefs(el, scope); // local definitions: element + descendants
|
|
|
+ el.__stmScope = s; // latest scope, used when an action fires
|
|
|
+ handleClass(el, s);
|
|
|
+ handleAttribute(el, s);
|
|
|
+ handleDisabled(el, s);
|
|
|
+ handleTextHtml(el, s);
|
|
|
wireAction(el);
|
|
|
- render(el, scope);
|
|
|
+ render(el, s);
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
@@ -1005,8 +1361,12 @@
|
|
|
var action = evalExpr(el.getAttribute('stm-action'), scope);
|
|
|
if (!action || typeof action.uri !== 'string') return;
|
|
|
|
|
|
- // stm-confirm: gate the action behind a confirm() dialog (literal message).
|
|
|
- if (el.hasAttribute('stm-confirm') && !window.confirm(el.getAttribute('stm-confirm'))) return;
|
|
|
+ // stm-confirm: gate the action behind the confirm handler (async-capable,
|
|
|
+ // so custom modals are supported).
|
|
|
+ if (el.hasAttribute('stm-confirm')) {
|
|
|
+ var confirmed = await triggerConfirm(el.getAttribute('stm-confirm'), el);
|
|
|
+ if (!confirmed) return;
|
|
|
+ }
|
|
|
|
|
|
applyErrorClasses(el, false); // clear error state on a new attempt
|
|
|
var data = collectData(el);
|
|
|
@@ -1014,9 +1374,9 @@
|
|
|
try {
|
|
|
await performAction(action, data, el);
|
|
|
} catch (err) {
|
|
|
- // stm-on-error is applied by the directive path for `error` directives;
|
|
|
- // ensure it is also applied for other failures (network, HTTP).
|
|
|
- applyErrorClasses(el, true);
|
|
|
+ // Already handled: stm-on-error classes and the onErrorHandler run inside
|
|
|
+ // performAction/reportFailure. Swallow so an HTML-triggered action does
|
|
|
+ // not surface as an unhandled rejection.
|
|
|
} finally {
|
|
|
setBusyState(el, false);
|
|
|
}
|
|
|
@@ -1134,6 +1494,31 @@
|
|
|
// Initialization
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
+ /**
|
|
|
+ * Scan the document for `pstm-*` attributes and `<pstm-*>` elements, which
|
|
|
+ * are intended for server-side pre-rendering and should never reach the
|
|
|
+ * browser. Emits a single `console.warn` summarizing them.
|
|
|
+ */
|
|
|
+ function warnServerSideMarkup() {
|
|
|
+ var found = [];
|
|
|
+ var els = document.querySelectorAll('*');
|
|
|
+ for (var i = 0; i < els.length; i++) {
|
|
|
+ var el = els[i];
|
|
|
+ var tag = el.tagName.toLowerCase();
|
|
|
+ if (tag.indexOf('pstm-') === 0) found.push('<' + tag + '>');
|
|
|
+ var attrs = el.attributes;
|
|
|
+ for (var j = 0; j < attrs.length; j++) {
|
|
|
+ var name = attrs[j].name;
|
|
|
+ if (name.indexOf('pstm-') === 0) found.push('<' + tag + '> ' + name);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (found.length) {
|
|
|
+ console.warn('[statum] ' + found.length + ' server-side pstm-* attribute/element(s) ' +
|
|
|
+ 'found in the DOM; they should have been resolved during pre-rendering: ' +
|
|
|
+ found.slice(0, 10).join(', ') + (found.length > 10 ? ' …' : ''));
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
/**
|
|
|
* Initialize Statum: read URL overrides, run the session-cookie check,
|
|
|
* hydrate persisted state, open the cross-tab channel, fetch the entrypoint,
|
|
|
@@ -1148,8 +1533,12 @@
|
|
|
if (body) {
|
|
|
if (body.hasAttribute('stm-entrypoint')) config.entrypointUrl = body.getAttribute('stm-entrypoint');
|
|
|
if (body.hasAttribute('stm-slots')) config.slotsUrl = body.getAttribute('stm-slots');
|
|
|
+ if (body.hasAttribute('stm-channel')) config.channelUrl = body.getAttribute('stm-channel');
|
|
|
+ if (body.hasAttribute('stm-worker')) config.workerUrl = body.getAttribute('stm-worker');
|
|
|
}
|
|
|
|
|
|
+ try { warnServerSideMarkup(); } catch (e) {}
|
|
|
+
|
|
|
try { initSession(); } catch (e) {}
|
|
|
try { hydrate(); } catch (e) {}
|
|
|
setupChannel();
|
|
|
@@ -1201,7 +1590,15 @@
|
|
|
/** Attach a state-changed listener for a type. */
|
|
|
addStateListener: addStateListener,
|
|
|
/** Remove a previously-attached listener. */
|
|
|
- removeStateListener: removeStateListener
|
|
|
+ removeStateListener: removeStateListener,
|
|
|
+ /** The Statum error type (thrown by `act` and surfaced to handlers). */
|
|
|
+ Error: StatumError,
|
|
|
+ get onConfirmHandler() { return handlers.onConfirm; },
|
|
|
+ set onConfirmHandler(v) { handlers.onConfirm = typeof v === 'function' ? v : null; },
|
|
|
+ get onNotifyHandler() { return handlers.onNotify; },
|
|
|
+ set onNotifyHandler(v) { handlers.onNotify = typeof v === 'function' ? v : null; },
|
|
|
+ get onErrorHandler() { return handlers.onError; },
|
|
|
+ set onErrorHandler(v) { handlers.onError = typeof v === 'function' ? v : null; }
|
|
|
};
|
|
|
|
|
|
autoInit();
|