/*! * Statum - client-side state library (core module). * * An HTMX-inspired state layer. The library manages signed "snapshots" of * application state (one per slot type), keeps them fresh via the server's * `transmit_after` / `invalid_after` timestamps, and ships them to the server * with every request through the X-Statum-Slot machinery. See model.md for * the full specification. * * CORE IS HEADLESS: it owns the slot protocol (state store, persistence, the * request/directive machinery, the realtime channel) and the UI handler * overrides, and NEVER touches the DOM. It therefore works alone — point * `statum.config` at your endpoints and init() fetches the entrypoint and * applies directives to state — which makes core a usable state layer for * non-document templating frameworks. * * Everything DOM-related lives in statum-document.js, which installs four * hooks through the module bridge: * * bridge.setBootState(ready) - stm-preloader / stm-content visibility * bridge.renderDriver(scope) - the render walk (the whole DOM scan) * bridge.afterRenderCycle() - stm-guard link evaluation (guards) * bridge.applyErrorClasses(el,on) - stm-on-error / stm-on-success classes * * All four default to no-ops, so headless pages lose nothing by running core * alone. Endpoint overrides also arrive from document (it reads the * `stm-entrypoint` / `stm-slots` / `stm-channel` / `stm-worker` body * attributes into `statum.config`); init() uses config as given and never * reads the DOM itself. * * The client ships as modules, each a plain IIFE with no dependencies and no * build step, loaded in this order: * * 1. statum-core.js - this file: creates `window.statum`. * 2. statum-document.js - the render walk, the DOM bindings, the attribute * registry (`statum.registerAttribute`) and the * form bindings (`stm-prefill`, * `stm-clear-on-success`, `stm-clear-on-error`, * `statum.document`). * 3. statum-markdown.js - the CommonMark engine and `stm-markdown`. * * Targets modern evergreen browsers (uses fetch, BroadcastChannel, * queueMicrotask, URLSearchParams, FormData). */ (function () { 'use strict'; /** Content-Type used by every Statum directive response. */ var STATUM_CONTENT_TYPE = 'application/vnd.statum+json'; /** Request/response header carrying one slot's reference (or base64 frame). */ var SLOT_HEADER = 'X-Statum-Slot'; /** Request header carrying an action's opaque `private` blob. */ var PRIVATE_HEADER = 'X-Statum-Private'; /** Session-cookie marker used to detect a fresh browser session. */ var SESSION_COOKIE = '_statum_sdc'; /** BroadcastChannel name used to sync session/device state across tabs. */ var CHANNEL_NAME = 'statum'; /** localStorage / sessionStorage key prefix for persisted frames. */ var STORAGE_PREFIX = 'statum:'; // --------------------------------------------------------------------------- // Configuration // --------------------------------------------------------------------------- /** * Endpoint URLs. Defaults; pages override them either by writing * `statum.config` (headless pages do this before init runs) or with the * `stm-entrypoint` / `stm-slots` / `stm-channel` / `stm-worker` attributes * on the `` element, which statum-document.js reads into this object * during its own (synchronous) load. Core itself never reads the DOM. * * @type {{entrypointUrl: string, slotsUrl: string}} */ var config = { entrypointUrl: '/_statum/entrypoint', slotsUrl: '/_statum/slots', channelUrl: '/_statum/channel', // The realtime worker is embedded in the library as a default resource // (served at /_statum/resource/statum-worker.js); override with stm-worker. workerUrl: '/_statum/resource/statum-worker.js' }; // --------------------------------------------------------------------------- // State store // --------------------------------------------------------------------------- /** * In-memory store, keyed by slot type. Each value is `{frame, snapshot}` * where `snapshot` is the parsed `frame.content`. This map is the single * source of truth for rendering; persistent backends merely hydrate it. * * @type {Map} */ var store = new Map(); /** Listeners, keyed by type name. @type {Map>} */ 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} */ 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; /** Whether {@link init} has already run. */ var initialized = false; /** * Whether the entrypoint has been fetched. Post-entrypoint guard * evaluation (statum-document.js) is gated on it through the bridge. */ var entrypointLoaded = false; // --------------------------------------------------------------------------- // Small utilities // --------------------------------------------------------------------------- /** * Parse an ISO 8601 timestamp to epoch millis, or `undefined`. * @param {string|undefined} s * @returns {number|undefined} */ function parseTime(s) { if (!s) return undefined; var t = new Date(s).getTime(); return isNaN(t) ? undefined : t; } /** * Base64-encode a UTF-8 string (safe for frames containing non-Latin1 data). * @param {string} str * @returns {string} */ function b64encode(str) { var bytes = new TextEncoder().encode(str); var bin = ''; var chunk = 0x8000; for (var i = 0; i < bytes.length; i += chunk) { bin += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk)); } return btoa(bin); } /** Read a cookie value, or `null` if absent. @param {string} name */ function getCookie(name) { var escaped = name.replace(/([.$?*|{}()[\]\/+^])/g, '\\$1'); var match = document.cookie.match(new RegExp('(?:^|; )' + escaped + '=([^;]*)')); return match ? decodeURIComponent(match[1]) : null; } /** Set a session cookie (no expiry => cleared when the browser closes). */ function setCookie(name, value) { document.cookie = name + '=' + encodeURIComponent(value) + '; path=/; SameSite=Lax'; } // --------------------------------------------------------------------------- // Expression evaluation // --------------------------------------------------------------------------- /** * Compiled-expression cache. Expressions are evaluated as JavaScript against * a scope object (the page's HTML is trusted, per the model). * @type {Map} */ var exprCache = new Map(); /** * Wrap a scope for expression evaluation so bare identifiers resolve in a * controlled order: names on the scope (slot types, loop variables, * `stm-def-*` locals) win; names missing from the scope but present on * `globalThis` (`Math`, `parseInt`, `String`, …) forward to that global; * anything else resolves to `undefined` instead of throwing a * `ReferenceError` or leaking an unintended global. The `has` trap claims * every name so `with` never falls through around the proxy, and the inner * try/catch of the compiled expression still turns property access on a * resolved `undefined`/`null` into `undefined` for that expression alone. * @param {object} scope * @returns { { get: function(string, *): *, set: function(string, *): boolean, has: function(string): boolean } } */ function evalScope(scope) { return new Proxy(scope, { get: function (target, name) { if (name === Symbol.unscopables) return undefined; if (name in target) return target[name]; if (name in globalThis) return globalThis[name]; return undefined; }, set: function (target, name, value) { target[name] = value; return true; }, has: function () { return true; } }); } /** * Compile an expression once. The compiled form evaluates the expression * source inside a `with` over an {@link evalScope} proxy, with an inner * try/catch so a property access on an unset slot's `undefined` public data * degrades to `undefined` for that expression instead of throwing. * @param {string} expr * @returns {function(object): *} */ function compileExpr(expr) { var cached = exprCache.get(expr); if (cached) return cached; var fn = new Function('scopeProxy', 'with (scopeProxy) { try { return (' + expr + '); } catch (e) { return undefined; } }'); exprCache.set(expr, fn); return fn; } /** * Evaluate an expression against a scope. An unset/empty expression and any * parse error resolve to `undefined` (the "leave original DOM content" * policy; no warning — an author may legitimately bind an expression that * references a slot the page does not hold yet). * @param {string} expr * @param {object} [scope] * @returns {*} */ function evalExpr(expr, scope) { if (expr == null || expr === '') return undefined; try { return compileExpr(expr)(evalScope(scope || {})); } catch (e) { return undefined; } } /** * Build a child scope that inherits the parent (so type bindings remain * visible) and adds one own loop variable. * @param {object} parent * @param {string} name * @param {*} value * @returns {object} */ function childScope(parent, name, value) { var scope = Object.create(parent || null); scope[name] = value; return scope; } // --------------------------------------------------------------------------- // StatumError // --------------------------------------------------------------------------- /** * Error type thrown for HTTP failures and `error` directives. * @param {string} [message] * @param {string} [code] * @param {object} [data] */ function StatumError(message, code, data) { this.name = 'StatumError'; this.message = message || ''; this.code = code || undefined; this.data = data || undefined; } 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} 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} */ 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 the error-state classes (when an origin element exists; * through the document-installed hook) 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 // --------------------------------------------------------------------------- /** * Pick the storage backend for a scope. * @param {string} scope * @returns {Storage|null} `null` for in-memory scopes. */ function storageFor(scope) { if (scope === 'device' || scope === 'session') return localStorage; if (scope === 'flow') return sessionStorage; return null; // page / transient } /** * Persist a slot's frame to the backend matching its scope, removing any * stale copy from the other backend first (in case the scope changed). * @param {string} type * @param {{frame: object, snapshot: object}} entry */ function persistEntry(type, entry) { var scope = entry.snapshot.slot && entry.snapshot.slot.scope; removePersisted(type); var storage = storageFor(scope); if (!storage) return; try { storage.setItem(STORAGE_PREFIX + type, JSON.stringify(entry.frame)); } catch (e) { /* storage full or unavailable; remain in-memory only */ } } /** Remove a persisted frame from both backends. @param {string} type */ function removePersisted(type) { try { localStorage.removeItem(STORAGE_PREFIX + type); } catch (e) {} try { sessionStorage.removeItem(STORAGE_PREFIX + type); } catch (e) {} } /** Load every persisted frame from a backend into the in-memory store. */ function loadFrom(storage) { for (var i = 0; i < storage.length; i++) { var key = storage.key(i); if (!key || key.indexOf(STORAGE_PREFIX) !== 0) continue; try { var frame = JSON.parse(storage.getItem(key)); var snapshot = JSON.parse(frame.content); var type = snapshot.slot && snapshot.slot.type; if (type && !store.has(type)) store.set(type, { frame: frame, snapshot: snapshot }); } catch (e) { /* corrupt entry: ignore */ } } } /** Hydrate the store from localStorage and sessionStorage. */ function hydrate() { try { loadFrom(localStorage); } catch (e) {} try { loadFrom(sessionStorage); } catch (e) {} } // --------------------------------------------------------------------------- // Session-cookie initialization // --------------------------------------------------------------------------- /** * Detect a fresh browser session using the `_statum_sdc` session cookie. * On a fresh session, clear all `session`-scoped persisted frames, then set * the marker. Session cookies are shared across tabs, so a tab opened * mid-session sees the marker and leaves session data intact. */ function initSession() { if (getCookie(SESSION_COOKIE) !== null) return; clearSessionSlotsFromStorage(); setCookie(SESSION_COOKIE, '1'); } /** Remove all `session`-scoped frames from localStorage. */ function clearSessionSlotsFromStorage() { var toRemove = []; for (var i = 0; i < localStorage.length; i++) { var key = localStorage.key(i); if (!key || key.indexOf(STORAGE_PREFIX) !== 0) continue; try { var frame = JSON.parse(localStorage.getItem(key)); var snapshot = JSON.parse(frame.content); if (snapshot.slot && snapshot.slot.scope === 'session') toRemove.push(key); } catch (e) {} } for (var j = 0; j < toRemove.length; j++) localStorage.removeItem(toRemove[j]); } // --------------------------------------------------------------------------- // Listeners & cross-tab broadcast // --------------------------------------------------------------------------- /** * 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 fireListeners(type, frame, snapshot) { var set = listeners.get(type); if (!set) return; var event = { type: type, frame: frame, snapshot: snapshot }; set.forEach(function (cb) { try { cb(event); } catch (e) { console.error('[statum] listener error', e); } }); } /** * Broadcast a state change to other tabs. Only `session`/`device` scopes are * shared (flow/page/transient are per-tab). * @param {object} message * @param {string} [scope] */ function broadcast(message, scope) { if (!channel) return; if (scope !== 'session' && scope !== 'device') return; 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; try { channel = new BroadcastChannel(CHANNEL_NAME); } catch (e) { channel = null; return; } channel.onmessage = function (ev) { var msg = ev.data; 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 && commitSet(msg.frame, snapshot, type, false)) renderAll(); } catch (e) { /* ignore malformed */ } } 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(); } }; } // --------------------------------------------------------------------------- // State mutation // --------------------------------------------------------------------------- /** * 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] */ function clearType(type, opts) { 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); if (key) signatures.delete(key); fireListeners(type, null, null); if (opts && opts.broadcast) broadcast({ kind: 'clear', key: key, type: type }, scope); } /** * 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); } /** * Resolve a frame signature to its slot type and clear it. Used by the * `invalidate` directive, which references a signature (not a key) because the * server reports a bad signature without trusting the frame's content. * @param {string} signature * @param {boolean} [broadcast] */ function clearBySignature(signature, broadcast) { var typeToRemove = null; store.forEach(function (entry, type) { if (entry.frame && entry.frame.signature === signature) typeToRemove = type; }); if (typeToRemove) clearType(typeToRemove, { broadcast: !!broadcast }); else if (signature) signatures.delete(signature); } /** * 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) { var frame = directive.snapshot_frame; if (!frame || !frame.content) return; var snapshot; try { snapshot = JSON.parse(frame.content); } catch (e) { return; } var type = snapshot.slot && snapshot.slot.type; if (!type) return; commitSet(frame, snapshot, type, true); } /** * Apply a `clear` directive. The directive carries a slot key, so the type * holding that key is resolved and removed. * @param {object} directive `{ key }` */ function applyClear(directive) { 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(); } // --------------------------------------------------------------------------- // Snapshot freshness & request headers // --------------------------------------------------------------------------- /** * Categorize a held snapshot relative to the current time. * @returns {'silent'|'always'|'invalid'|'stale'|'fresh'} * - `silent`: unidirectional (`transmit_after` undefined) - never sent. * - `always`: `transmit_after === as_at` - send the base64 frame each time. * - `invalid`: past `invalid_after` - drop and refetch. * - `stale`: past `transmit_after` but not yet invalid - pre-flight first. * - `fresh`: still cached server-side - send the `as_at` reference. */ function entryCategory(entry) { var snap = entry.snapshot; var transmit = snap.transmit_after; if (transmit === undefined || transmit === null) return 'silent'; if (transmit === snap.as_at) return 'always'; var now = Date.now(); var invalidAt = parseTime(snap.invalid_after); var transmitAt = parseTime(transmit); if (invalidAt !== undefined && now >= invalidAt) return 'invalid'; if (transmitAt !== undefined && now >= transmitAt) return 'stale'; return 'fresh'; } /** * Drop locally-invalid snapshots and pre-flight any stale ones. Pre-flighting * posts the stale frames to the slot endpoint and applies the returned `set` * directives, refreshing their `as_at` values before the real request runs. * * Single-flight: concurrent callers share one in-flight preparation. * @returns {Promise} */ var preparePromise = null; function prepareSlots() { if (preparePromise) return preparePromise; preparePromise = doPrepareSlots().then(function () { preparePromise = null; }, function () { preparePromise = null; }); return preparePromise; } async function doPrepareSlots() { var stale = []; store.forEach(function (entry, type) { var cat = entryCategory(entry); if (cat === 'invalid') { clearType(type, { broadcast: false }); } else if (cat === 'stale') { stale.push(entry.frame); } }); if (stale.length) { var directives = await postSlots(stale); var pfResult = applyDirectives(directives, { originElement: null }); executeTerminal(pfResult); } } /** * Build the `X-Statum-Slot` header values for every held slot, using the * freshness rules. Assumes {@link prepareSlots} has just run. * @returns {string[]} */ function buildSlotHeaders() { var headers = []; store.forEach(function (entry) { var snap = entry.snapshot; var cat = entryCategory(entry); if (cat === 'silent' || cat === 'invalid') return; if (cat === 'always') { headers.push(b64encode(JSON.stringify(entry.frame))); } else { headers.push('key=' + snap.slot.key + '; as_at=' + snap.as_at); } }); return headers; } /** * Collect the current frames held for the given slot keys, in the order the * keys are first supplied. Used by the `transmit` directive (which forces a * slot's frame to be posted as if its `transmit_after` had elapsed). Keys the * client does not currently hold are skipped. * @param {string[]} keys * @returns {object[]} */ function collectFramesForKeys(keys) { var keySet = Object.create(null); for (var i = 0; i < keys.length; i++) keySet[keys[i]] = true; var frames = []; store.forEach(function (entry) { var key = entry.snapshot.slot && entry.snapshot.slot.key; if (key && keySet[key]) { frames.push(entry.frame); delete keySet[key]; } }); return frames; } // --------------------------------------------------------------------------- // HTTP helpers // --------------------------------------------------------------------------- /** True if a response is a Statum directive response. */ function isStatum(res) { return (res.headers.get('Content-Type') || '').indexOf(STATUM_CONTENT_TYPE) !== -1; } /** * POST an array of frames to the slot endpoint (the pre-flight target) and * return its directive array. This request intentionally carries no slot * headers (it is itself the refresh). * @param {object[]} frames * @returns {Promise} */ async function postSlots(frames) { var headers = new Headers(); headers.set('Accept', STATUM_CONTENT_TYPE); headers.set('Content-Type', 'application/json'); var res = await fetch(config.slotsUrl, { method: 'POST', headers: headers, body: JSON.stringify(frames), credentials: 'same-origin' }); if (!isStatum(res)) return []; try { return await res.json(); } catch (e) { return []; } } /** * Apply the non-terminal directives from a response (set/clear/subscribe/ * unsubscribe/notify/transmit-collection), then `render`. * * Terminal directives (`navigate`, `post`, `reenter`), error directives, and * any transmit/retry intent are collected on the returned result rather than * executed here: {@link handleResponse} performs the asynchronous * transmission (and optional retry) and then runs terminal navigation, so * that a `transmit` with `retry` can re-issue the request before the page is * navigated away. * @param {object[]} directives * @param {{originElement?: HTMLElement}} [ctx] * @returns {{errors: StatumError[], transmitKeys: string[], retry: boolean, navigate: object|null, post: object|null, reenter: boolean}} */ function applyDirectives(directives, ctx) { var result = { errors: [], transmitKeys: [], retry: false, navigate: null, post: null, reenter: false, formSubmits: [], formResets: [] }; var list = Array.isArray(directives) ? directives : []; for (var i = 0; i < list.length; i++) { var d = list[i]; if (!d || typeof d !== 'object') continue; switch (d.type) { case 'set': applySet(d); break; case 'clear': applyClear(d); break; case 'invalidate': if (d.signature != null) { console.warn('[statum] server could not verify slot frame ' + d.signature + '; clearing it'); clearBySignature(d.signature, true); } break; case 'subscribe': channelSubscribe(d.key); break; case 'unsubscribe': channelUnsubscribe(d.key); break; case 'notify': triggerNotify(d.kind, d.message); break; case 'transmit': if (d.key != null) { if (result.transmitKeys.indexOf(d.key) === -1) result.transmitKeys.push(d.key); if (d.retry) result.retry = true; } break; case 'error': { var err = new StatumError(d.message, d.code, d.data); var detail = d.code ? (d.message + ' [' + d.code + ']') : d.message; console.error('[statum] error directive: ' + (detail || '(no message)')); result.errors.push(err); } break; case 'navigate': result.navigate = d; break; case 'reenter': result.reenter = true; break; case 'post': result.post = d; break; case 'form-submit': result.formSubmits.push(d); break; case 'form-reset': result.formResets.push(d); break; } } renderAll(); return result; } /** * Execute the terminal directives collected by {@link applyDirectives}. * * Order: `form-reset`/`form-submit` first (their work is synchronous), then * a `reenter` (the entrypoint refresh is awaited so the action's promise * resolves only once the re-entered state has been applied), then * `navigate`/`post` — navigation wins over reenter's refresh when both are * present, running after it. Async because of reenter. */ async function executeTerminal(result) { result.formResets.forEach(doFormReset); result.formSubmits.forEach(doFormSubmit); if (result.reenter) { try { await loadEntrypoint(); } catch (err) { console.error('[statum] reenter: entrypoint refresh failed', err); } } if (result.navigate) doNavigate(result.navigate); if (result.post) doPost(result.post); } /** Execute a `form-submit` directive: optionally override action/method, then submit. */ function doFormSubmit(d) { var form = document.getElementById(d.form); if (form) { if (d.action) form.setAttribute('action', d.action); if (d.method) form.setAttribute('method', d.method); form.submit(); } } /** Execute a `form-reset` directive. */ function doFormReset(d) { var form = document.getElementById(d.form); if (form) form.reset(); } /** Execute a `navigate` directive as a full page load. */ function doNavigate(d) { if (d.uri) window.location.href = d.uri; } /** Execute a `post` directive as a real (navigating) form POST. */ function doPost(d) { if (!d.uri) return; var form = document.createElement('form'); form.method = 'POST'; form.action = d.uri; form.style.display = 'none'; var data = d.data || {}; Object.keys(data).forEach(function (name) { var input = document.createElement('input'); input.type = 'hidden'; input.name = name; input.value = data[name] == null ? '' : String(data[name]); form.appendChild(input); }); document.body.appendChild(form); form.submit(); } /** * Handle a fetch response: if it is a Statum response, apply its directives; * otherwise throw on HTTP errors and no-op on other successful responses. * * Directive ordering: non-terminal directives are applied and the page * rendered; then any `transmit` directives force their slot frames to be * posted (applying the directives the slot endpoint returns); then, if a * transmit requested a retry, the originating request (`ctx.retry`) is * re-issued and this response's terminal directives and errors are skipped. * Otherwise terminal directives run — form resets/submits, then an awaited * `reenter` entrypoint refresh (a failure is logged, never propagated), then * `navigate`/`post` — and finally error directives are surfaced. Because * the terminal phase is awaited, an action promise resolves only after a * `reenter` refresh has been applied. * @param {Response} res * @param {{originElement?: HTMLElement, retry?: Function}} [ctx] */ async function handleResponse(res, ctx) { var el = ctx && ctx.originElement; if (!isStatum(res)) { 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.transmitKeys && result.transmitKeys.length) { var frames = collectFramesForKeys(result.transmitKeys); if (frames.length) { var txDirectives = await postSlots(frames); applyDirectives(txDirectives, { originElement: el }); } } if (result.retry && ctx && typeof ctx.retry === 'function') { await ctx.retry(); return; } await executeTerminal(result); var errors = result.errors || []; for (var i = 0; i < errors.length; i++) { await reportFailure(errors[i], el); } } // --------------------------------------------------------------------------- // Entrypoint & action requests // --------------------------------------------------------------------------- /** * Fetch the entrypoint for the current URL, applying its directives. Carries * slot headers (and pre-flights) like any other request. The request is * wrapped in an `attempt` closure passed as the response `retry` callback, so * a `transmit` directive with `retry` can re-issue it after transmission. * @returns {Promise} */ function loadEntrypoint() { // From this point on guards are "post-entrypoint" (all of them evaluate). entrypointLoaded = true; var attempt = async function () { await prepareSlots(); var headers = new Headers(); headers.set('Accept', STATUM_CONTENT_TYPE); buildSlotHeaders().forEach(function (v) { headers.append(SLOT_HEADER, v); }); var url = config.entrypointUrl + '?uri=' + encodeURIComponent(window.location.href); var res = await fetch(url, { headers: headers, credentials: 'same-origin' }); await handleResponse(res, { originElement: null, retry: attempt }); }; return attempt(); } /** * Perform an action request. * * The request is wrapped in an `attempt` closure passed as the response * `retry` callback, so a `transmit` directive with `retry` can re-issue the * same action after the transmission (rebuilding headers from current state). * * @param {object} action `{ uri, method, private }` * @param {object} [data] Key/value parameters. Encoded as query string for * GET/HEAD, otherwise as `application/x-www-form-urlencoded`. * @param {HTMLElement} [originElement] Element that triggered the action, so * that `stm-on-error` classes can be applied on an `error` directive. * @returns {Promise} Resolves once the request completes and its * directives have run; rejects on HTTP failure or an `error` directive. */ function performAction(action, data, originElement) { var attempt = async function () { await prepareSlots(); var headers = new Headers(); headers.set('Accept', STATUM_CONTENT_TYPE); buildSlotHeaders().forEach(function (v) { headers.append(SLOT_HEADER, v); }); if (action.private) headers.set(PRIVATE_HEADER, action.private); var method = (action.method || 'GET').toUpperCase(); var params = data || {}; var init = { method: method, headers: headers, credentials: 'same-origin' }; var url = action.uri; if (method === 'GET' || method === 'HEAD') { var qs = new URLSearchParams(params).toString(); if (qs) url += (url.indexOf('?') !== -1 ? '&' : '?') + qs; } else { init.body = new URLSearchParams(params).toString(); headers.set('Content-Type', 'application/x-www-form-urlencoded'); } var res; try { res = await fetch(url, init); } catch (networkErr) { await reportFailure(new StatumError('Network error'), originElement); return; } await handleResponse(res, { originElement: originElement, retry: attempt }); }; return attempt(); } // --------------------------------------------------------------------------- // 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); } // --------------------------------------------------------------------------- // Document seam: the hooks statum-document.js installs // --------------------------------------------------------------------------- // // Core never touches the DOM, so everything visual — the boot visibility // toggle, the render walk, the guard evaluation, the error-state classes — // reaches the DOM through these hooks, defaulting to no-ops. With // statum-document.js loaded every hook is real; without it core still boots, // fetches the entrypoint and applies directives to state (headless use). /** @type {Function} stm-preloader / stm-content visibility; no-op headless. */ var setBootState = function () {}; /** @type {Function} The render walk driver `fn(scope)`; absent headless. */ var renderDriver = null; /** * @type {Function} Post-walk cycle hook `fn()`: stm-guard evaluation. Runs * after the afterEvaluate hooks, exactly where evaluateGuards used to. */ var afterRenderCycle = function () {}; /** * @type {Function} `fn(el, on)`: apply or clear the stm-on-error / * stm-on-success classes on an action element. No-op headless. */ var applyErrorClasses = function () {}; // --------------------------------------------------------------------------- // Evaluation hooks (no-arg callbacks before/after each render cycle) // --------------------------------------------------------------------------- var beforeHooks = []; var afterHooks = []; /** * Run one render cycle: the beforeEvaluate hooks, the render walk (when a * document module has installed its driver), the afterEvaluate hooks, then * the document-side cycle hook (guard evaluation). Headless (no driver), the * cycle still runs the hooks — they observe state changes, not DOM. */ function renderAll() { beforeHooks.forEach(function (fn) { try { fn(); } catch (e) { console.error('[statum] beforeEvaluate hook error', e); } }); try { // The DOM walk lives in statum-document.js and is installed through // the seam; without it the cycle still runs hooks and the cycle hook. if (renderDriver) renderDriver(stateScope()); } catch (e) { console.error('[statum] render error', e); } afterHooks.forEach(function (fn) { try { fn(); } catch (e) { console.error('[statum] afterEvaluate hook error', e); } }); try { afterRenderCycle(); } catch (e) { console.error('[statum] render cycle hook error', e); } } // --------------------------------------------------------------------------- // Public state accessors // --------------------------------------------------------------------------- /** Build the scope used for expression evaluation: `{ typeName: public }`. */ function stateScope() { var scope = {}; store.forEach(function (entry, type) { scope[type] = entry.snapshot.public; }); return scope; } /** Read a type's `public` data (what the attributes bind to). */ function read(type) { var entry = store.get(type); return entry ? entry.snapshot.public : undefined; } /** Read a type's full snapshot object. */ function getSnapshot(type) { var entry = store.get(type); return entry ? entry.snapshot : undefined; } /** Read the frame wrapping a type's snapshot. */ function getFrame(type) { var entry = store.get(type); return entry ? entry.frame : undefined; } /** Build an object of `{ typeName: public }` for every held type. */ function state() { var result = {}; store.forEach(function (entry, type) { result[type] = entry.snapshot.public; }); return result; } /** * Clear state. With a type argument, clears that type only; without, clears * all held state. * @param {string} [type] */ function clear(type) { if (type === undefined) { Array.from(store.keys()).forEach(function (t) { clearType(t, { broadcast: true }); }); } else { clearType(type, { broadcast: true }); } renderAll(); } /** * Perform an action. `action` may be an action object or a string path that * resolves to an action object within the current state. * @param {object|string} action * @param {object} [data] * @returns {Promise} */ function act(action, data) { var resolved = action; if (typeof action === 'string') resolved = evalExpr(action, stateScope()); if (!resolved || typeof resolved.uri !== 'string') { return Promise.reject(new StatumError('Invalid action')); } return performAction(resolved, data, null); } /** Attach a state listener for a type. */ function addStateListener(type, callback) { if (typeof callback !== 'function') return; if (!listeners.has(type)) listeners.set(type, new Set()); listeners.get(type).add(callback); } /** Remove a previously-attached state listener. */ function removeStateListener(type, callback) { var set = listeners.get(type); if (!set) return; set.delete(callback); if (set.size === 0) listeners.delete(type); } // --------------------------------------------------------------------------- // Initialization // --------------------------------------------------------------------------- /** * Initialize Statum: run the session-cookie check, hydrate persisted state, * open the cross-tab channel, fetch the entrypoint, and run render cycles. * Safe to call multiple times. * * Headless by design: endpoint config comes from `statum.config` (pages — * or statum-document.js, reading the `stm-*` body attributes — write it * before init runs), and the boot-state toggle is the document hook. Nothing * here scans the DOM. * @returns {Promise} */ async function init() { if (initialized) return; initialized = true; try { initSession(); } catch (e) {} try { hydrate(); } catch (e) {} setupChannel(); setBootState(false); renderAll(); // bind against any persisted/hydrated state immediately try { await loadEntrypoint(); } catch (err) { console.error('[statum] entrypoint load failed', err); } finally { setBootState(true); } } function autoInit() { var ready = 'loading'; try { ready = document.readyState; } catch (e) {} if (ready === 'loading') { try { document.addEventListener('DOMContentLoaded', init); } catch (e) { init(); } } else { init(); } } // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- /** @namespace statum */ window.statum = { /** Library version. */ version: '0.1.0', /** Mutable endpoint configuration. */ config: config, /** (Re)initialize the library. Usually runs automatically on load. */ init: init, /** Read a type's `public` data. */ read: read, /** Read a type's full snapshot object. */ getSnapshot: getSnapshot, /** Read the frame wrapping a type's snapshot. */ getFrame: getFrame, /** Build `{ typeName: public }` for every held type. */ state: state, /** Clear one type (`clear("x")`) or all state (`clear()`). */ clear: clear, /** Perform an action object or named action. */ act: act, /** Attach a state-changed listener for a type. */ addStateListener: addStateListener, /** Remove a previously-attached listener. */ removeStateListener: removeStateListener, /** Register a callback that runs before each render cycle (no arguments). */ beforeEvaluate: function (fn) { if (typeof fn === 'function') beforeHooks.push(fn); }, /** Register a callback that runs after each render cycle (no arguments). */ afterEvaluate: function (fn) { if (typeof fn === 'function') afterHooks.push(fn); }, /** 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; } }; // The seam must be exposed before any other module loads. autoInit only // ever runs on DOMContentLoaded (or immediately at load); both happen // outside this script tag, so the later module tags always see the bridge. window.statum._bridge = { /** Expression machinery (core-owned). */ evalExpr: evalExpr, childScope: childScope, stateScope: stateScope, /** Whether the entrypoint has been fetched (guards gate on it). */ entrypointLoaded: function () { return entrypointLoaded; }, /** Render cycle + requests. */ renderAll: renderAll, loadEntrypoint: loadEntrypoint, performAction: performAction, /** UI handler overrides (runElementAction gates on confirm). */ triggerConfirm: triggerConfirm, triggerNotify: triggerNotify, triggerError: triggerError, handlers: handlers, /** * Attribute-handler registration. The registry itself lives in * statum-document.js (it is DOM machinery: the walk iterates it); until * that module loads, registrations are QUEUED here so a module can load * against core alone (tests load core+markdown, for instance). Document * drains the queue through the real registry when it installs it. */ registerAttribute: function (name, fn, opts) { pendingRegistrations.push([name, fn, opts]); }, /** Take (and clear) the registrations queued before document loaded. */ takePendingRegistrations: function () { var pending = pendingRegistrations; pendingRegistrations = []; return pending; }, /** * Hooks statum-document.js installs (core defaults the first to a no-op * and the rest to "absent"; the setters keep the names stable for that * module's installer). */ setBootState: function (fn) { if (typeof fn === 'function') setBootState = fn; }, renderDriver: function (fn) { if (typeof fn === 'function') renderDriver = fn; }, afterRenderCycle: function (fn) { if (typeof fn === 'function') afterRenderCycle = fn; }, applyErrorClasses: function (fn) { if (typeof fn === 'function') applyErrorClasses = fn; } }; /** @type {Array<[string, Function, object|undefined]>} */ var pendingRegistrations = []; autoInit(); })();