clanker 3 недель назад
Родитель
Сommit
2538b14153
4 измененных файлов с 679 добавлено и 57 удалено
  1. 9 2
      frontend-demo/server.php
  2. 145 0
      js/statum-worker.js
  3. 451 54
      js/statum.js
  4. 74 1
      model.md

+ 9 - 2
frontend-demo/server.php

@@ -39,10 +39,17 @@ function statum_respond(array $directives): void
     exit;
 }
 
-/** Wrap a snapshot in a trivial (unsigned) frame. */
+/** Wrap a snapshot in a trivial (unsigned) frame. The signature is a SHA256
+ *  hash of the content so that identical snapshots share a signature (used by
+ *  the client as an idempotency key) while distinct ones do not. */
 function frame(array $snapshot): array
 {
-    return ['content' => json_encode($snapshot), 'signer' => 'demo', 'signature' => ''];
+    $content = json_encode($snapshot);
+    return [
+        'content' => $content,
+        'signer' => 'demo',
+        'signature' => base64_encode(hash('sha256', $content, true)),
+    ];
 }
 
 /** Stable per-type slot key, kept in the session. */

+ 145 - 0
js/statum-worker.js

@@ -0,0 +1,145 @@
+/*!
+ * Statum realtime channel worker.
+ *
+ * Shared across every tab of an origin via `SharedWorker`. It owns the single
+ * realtime connection to `GET /_statum/channel`, reads the channel id from the
+ * stream's first `event: channel`, reference-counts slot subscriptions across
+ * all connected tabs, manages them with `PATCH /_statum/channel/{id}`, and
+ * relays incoming `set`/`clear` events to every tab through the cross-tab
+ * `BroadcastChannel` named "statum" (which the main library already listens
+ * on). The worker never touches Web Storage or the DOM; tabs apply events and
+ * persist frames themselves.
+ *
+ * Distribution: a standalone script (served at `/_statum/worker.js` by
+ * default, overridable with `stm-worker`). It has no dependencies.
+ */
+(function () {
+  'use strict';
+
+  var CHANNEL_NAME = 'statum';
+  var channelUrl = '/_statum/channel'; // default; overridden by a tab's `init` message
+
+  var bc = null;       // BroadcastChannel to the tabs
+  var es = null;       // EventSource to /_statum/channel
+  var channelId = null;
+  var refs = new Map();      // slotKey -> subscription count (across all ports)
+  var portKeys = new Map();  // MessagePort -> Set(slotKey)
+
+  var pending = { subscribe: [], unsubscribe: [] };
+  var patchScheduled = false;
+
+  function ensureBroadcast() {
+    if (bc || typeof BroadcastChannel === 'undefined') return;
+    try { bc = new BroadcastChannel(CHANNEL_NAME); } catch (e) { bc = null; }
+  }
+
+  function postToTabs(message) {
+    ensureBroadcast();
+    if (bc) { try { bc.postMessage(message); } catch (e) {} }
+  }
+
+  function openStream() {
+    if (es) return;
+    try {
+      es = new EventSource(channelUrl, { withCredentials: true });
+    } catch (e) { es = null; return; }
+
+    es.addEventListener('channel', function (ev) {
+      channelId = ev.data;
+      flushPatch();
+    });
+    es.addEventListener('set', function (ev) {
+      var frame;
+      try { frame = JSON.parse(ev.data); } catch (e) { return; }
+      postToTabs({ kind: 'set', frame: frame });
+    });
+    es.addEventListener('clear', function (ev) {
+      postToTabs({ kind: 'clear', key: ev.data });
+    });
+    es.onerror = function () { /* EventSource reconnects automatically */ };
+  }
+
+  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(channelUrl + '/' + encodeURIComponent(channelId), {
+        method: 'PATCH',
+        headers: { 'Content-Type': 'application/json' },
+        credentials: 'same-origin',
+        body: JSON.stringify(body)
+      }).catch(function () {});
+    } catch (e) {}
+  }
+
+  function addSub(port, key) {
+    var keys = portKeys.get(port);
+    if (!keys) { keys = new Set(); portKeys.set(port, keys); }
+    if (keys.has(key)) return; // this port already subscribed
+    keys.add(key);
+    var c = refs.get(key) || 0;
+    refs.set(key, c + 1);
+    if (c === 0) {
+      openStream();
+      pending.subscribe.push(key);
+      schedulePatch();
+    }
+  }
+
+  function removeSub(port, key) {
+    var keys = portKeys.get(port);
+    if (!keys || !keys.has(key)) return;
+    keys.delete(key);
+    var c = refs.get(key) || 0;
+    if (c <= 1) {
+      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);
+    }
+  }
+
+  function disconnectPort(port) {
+    var keys = portKeys.get(port);
+    if (keys) keys.forEach(function (key) { removeSub(port, key); });
+    portKeys.delete(port);
+  }
+
+  function handlePortMessage(port, ev) {
+    var msg = ev.data;
+    if (!msg) return;
+    if (msg.url) channelUrl = msg.url;        // {op:'init', url}
+    if (msg.op === 'subscribe' && msg.key != null) addSub(port, msg.key);
+    else if (msg.op === 'unsubscribe' && msg.key != null) removeSub(port, msg.key);
+    else if (msg.op === 'disconnect') disconnectPort(port);
+  }
+
+  // SharedWorker entry point: each connecting tab gets its own MessagePort.
+  self.onconnect = function (e) {
+    var port = e.ports[0];
+    portKeys.set(port, new Set());
+    port.onmessage = function (ev) { handlePortMessage(port, ev); };
+    port.start();
+    // Port closure (e.g. tab crash) is not reliably signalled, so refcounts
+    // for an abruptly-closed tab are reclaimed when the worker itself is torn
+    // down; a graceful close sends an explicit `disconnect` message first.
+  };
+})();

+ 451 - 54
js/statum.js

@@ -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();

+ 74 - 1
model.md

@@ -43,6 +43,9 @@ A statum snapshot captures an aspect of the application state at a particular po
     - `"set"`: Used to set a slot to a new snapshot.
     - `"clear"`: Used to clear a slot.
     - `"error"`: Used to report an error to the frontend.
+    - `"subscribe"`: Used to subscribe the client's realtime channel to a slot.
+    - `"unsubscribe"`: Used to unsubscribe the client's realtime channel from a slot.
+    - `"notify"`: Used to surface a transient notification to the user.
 
 Depending on the type, it will have other properties.
 
@@ -61,6 +64,8 @@ The post directive performs a full-page browser navigation via a real form POST,
 
 - `snapshot_frame`: A frame containing a snapshot.
 
+Set directives are idempotent. Each tab keeps a small in-memory map of slot key → frame `signature` and skips the store update, persistence, listener notification, and re-render when an incoming set carries a signature already recorded for that slot. The `signature` is a signed cryptographic hash of the frame's `content`, so a collision across genuinely different content is negligible. The map lives in memory only (not the persistent backend), so a frame another tab has already written to storage still triggers a re-draw in a tab whose in-memory tracking lags behind; and an entry is dropped whenever its slot is cleared, so a replayed frame re-applies after a clear.
+
 ### Clear Directive
 
 - `key`: The key of the slot to clear.
@@ -73,6 +78,25 @@ The post directive performs a full-page browser navigation via a real form POST,
 
 An error directive signals a failure to the frontend. When one is received, the `statum.act` promise for the originating action rejects and any `stm-on-error` handling on the originating element runs. An error directive returned from the entrypoint load is surfaced as a load failure.
 
+### Subscribe Directive
+
+- `key`: The key of the slot to subscribe the client's realtime channel to.
+
+Tells the client to begin receiving live updates for a slot over its realtime channel (see [Channel Endpoint](#channel-endpoint)). Subscribe is not terminal: it runs in the same pass as `set` and `clear`, and the channel operations it triggers are asynchronous and do not block the remaining directives in the response.
+
+### Unsubscribe Directive
+
+- `key`: The key of the slot to unsubscribe the client's realtime channel from.
+
+Removes a slot from the client's realtime channel subscriptions. Subscriptions are reference-counted across tabs: a slot is only removed from the server when the last subscription to it across all tabs is dropped, and the channel connection is closed when the final subscription of any kind is removed.
+
+### Notify Directive
+
+- `kind`: An application-defined notification category string (e.g. `"info"`, `"warning"`, `"success"`). It is opaque to the library, passed through to the handler, and does not affect the default behaviour. (The category is named `kind` rather than `type` because `type` is already the directive discriminator.)
+- `message`: A human-readable message string.
+
+Surfaces a transient message to the user. The default handler calls the browser's `alert()` with the `message`; this can be customised via `statum.onNotifyHandler` (see [Handlers](#handlers)). Notify runs in the same pass as `set` and `clear`.
+
 # Statum HTTP API
 
 All endpoints that return directives do so as a JSON array of Statum Directives, served with the Content-Type `application/vnd.statum+json`. The frontend uses this content type to distinguish a Statum directive response from an ordinary HTML or JSON response.
@@ -91,6 +115,28 @@ Returns a JSON array of Statum Directives.
 
 The URLs of action endpoints are entirely defined by the action itself (in the `uri` property). An additional `X-Statum-Private` header should contain the `private` data of the action if present. Returns a JSON array of Statum Directives.
 
+## Channel Endpoint
+
+The channel endpoint is the realtime delivery mechanism for live slot updates. It is a Server-Sent Events stream multiplexed across the slots the client has subscribed to.
+
+`GET /_statum/channel`: Opens the SSE stream. The first event carries the channel ID:
+
+- `event: channel` — `data` is the channel ID string.
+
+Subsequent events deliver slot updates, one directive per event:
+
+- `event: set` — `data` is a JSON snapshot frame (the same `{content, signer, signature}` object used by a set directive's `snapshot_frame`).
+- `event: clear` — `data` is the slot key of the cleared slot.
+
+Only `set` and `clear` events are delivered over the channel. The client opens a single connection — shared across tabs via a `SharedWorker`, or one per tab when `SharedWorker` is unavailable — on the first subscribe directive, and closes it once it has no subscriptions left.
+
+`PATCH /_statum/channel/{id}`: Adds or removes the channel's subscriptions. The `{id}` path segment is the channel ID delivered by the stream's first event. The request body is a JSON object:
+
+- `subscribe`: an array of slot keys to begin receiving updates for.
+- `unsubscribe`: an array of slot keys to stop receiving updates for.
+
+Either array may be omitted or empty; the client coalesces multiple subscribe/unsubscribe directives into a single PATCH where possible. The response body is not used.
+
 ## Request Headers
 
 All HTTP API requests must carry an `X-Statum-Slot` header for each slot the client currently holds (i.e. every snapshot in scope for the current page/flow). Note that sending base64 frames in headers can approach practical header-size limits on some servers and proxies; very large frames are better refreshed via the slot post endpoint flow below.
@@ -160,6 +206,18 @@ A listener can be removed with `statum.removeStateListener("typeName", callback)
 
 For `"session"` and `"device"` scoped slots, state changes are propagated to other tabs in the same origin via a `BroadcastChannel`, so listeners fire in every tab, not just the one that performed the change.
 
+## Handlers
+
+The default UI behaviours for confirmations, notifications, and errors can be customised by assigning handler functions on the `statum` object.
+
+- `statum.onConfirmHandler`: `(message, element) => boolean | Promise<boolean>`. Invoked for an action element carrying `stm-confirm` before the action runs; the action proceeds only if the handler resolves truthy. Defaults to `window.confirm(message)`.
+- `statum.onNotifyHandler`: `(kind, message) => void`. Invoked for each `notify` directive. Defaults to `window.alert(message)`.
+- `statum.onErrorHandler`: `(error, element) => boolean | Promise<boolean>`. Invoked for every `error` directive, entrypoint load failure, and HTTP/network failure. `element` is the originating action element, or `null` for the entrypoint. Returning (or resolving) `false` suppresses the default behaviour (rejecting the originating `statum.act` promise and applying any `stm-on-error` classes); any other return value leaves the default in place.
+
+## Realtime channel
+
+Live slot updates are delivered over the realtime channel (see [Channel Endpoint](#channel-endpoint)). The library manages this automatically in response to `subscribe` and `unsubscribe` directives: it opens a shared connection via a `SharedWorker` (script url overridable with `stm-worker`, default `/_statum/worker.js`) on the first subscribe, reference-counts subscriptions across tabs, and relays incoming `set`/`clear` events to every tab through the existing cross-tab `BroadcastChannel`. When `SharedWorker` is unavailable each tab opens its own connection and broadcasts the events it receives to its siblings. In every case a tab applies each distinct frame exactly once, thanks to [set-directive idempotency](#set-directive).
+
 # HTML API
 
 The Javascript library also has some functionality inspired by HTMX using attributes prefixed with `stm-`. Usually their values refer to a value reached by following a path into a slot's `public` data, optionally combined with a loop variable (see [Loops](#loops)). The template is re-evaluated each time the state changes.
@@ -170,7 +228,7 @@ Attribute values used as predicates (`stm-if`, `stm-else-if`, `stm-class.x`) are
 
 ## URL Overrides
 
-The attribute `stm-entrypoint` can be used *only* on the `<body>` tag, to override the default entrypoint url of `/_statum/entrypoint`. Same goes for the `stm-slots` attribute, which can be used to override the default slot post url of `/_statum/slots`.
+The attribute `stm-entrypoint` can be used *only* on the `<body>` tag, to override the default entrypoint url of `/_statum/entrypoint`. Same goes for the `stm-slots` attribute (default slot post url `/_statum/slots`), the `stm-channel` attribute (default realtime channel url `/_statum/channel`), and the `stm-worker` attribute (default `SharedWorker` script url `/_statum/worker.js`).
 
 ## Boot and Loading States
 
@@ -239,3 +297,18 @@ The `stm-for-{x}-in` attribute (where `{x}` is the loop variable name) repeats t
 ```
 
 When the referenced array changes, the loop is re-rendered. If a `stm-key="{expression}"` attribute is present, items are matched across renders by the key value, preserving DOM identity (and therefore focus, selection, and input state) for items whose key is unchanged. When no `stm-key` is given, the loop falls back to a full re-render.
+
+## Local definitions
+
+The attribute `stm-def-{name}-as` (where `{name}` is the variable name) defines a variable bound to an expression, available to the element and all of its descendants. The expression is evaluated in the current scope and its result is added to a child scope, so it can shadow a type binding or loop variable for the element's subtree. The value is re-evaluated on every render, so it stays reactive, and multiple `stm-def-*` attributes may be placed on a single element:
+
+```html
+<div stm-def-total-as="cart.count * 9.99" stm-def-empty-as="cart.count === 0">
+    <span stm-text="total"></span>
+    <p stm-if="empty">Your cart is empty.</p>
+</div>
+```
+
+## Server-side attribute warnings
+
+Attributes and elements prefixed with `pstm-` are intended for server-side pre-rendering (see `future.md`) and must not reach the browser. On initialization the library scans the document for any `pstm-*` attributes or `<pstm-*>` elements and emits a single `console.warn` summarizing them, since their presence indicates a template that was not fully pre-rendered.