statum-core.js 55 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464
  1. /*!
  2. * Statum - client-side state library (core module).
  3. *
  4. * An HTMX-inspired state layer. The library manages signed "snapshots" of
  5. * application state (one per slot type), keeps them fresh via the server's
  6. * `transmit_after` / `invalid_after` timestamps, and ships them to the server
  7. * with every request through the X-Statum-Slot machinery. See model.md for
  8. * the full specification.
  9. *
  10. * CORE IS HEADLESS: it owns the slot protocol (state store, persistence, the
  11. * request/directive machinery, the realtime channel) and the UI handler
  12. * overrides, and NEVER touches the DOM. It therefore works alone — point
  13. * `statum.config` at your endpoints and init() fetches the entrypoint and
  14. * applies directives to state — which makes core a usable state layer for
  15. * non-document templating frameworks.
  16. *
  17. * Everything DOM-related lives in statum-document.js, which installs four
  18. * hooks through the module bridge:
  19. *
  20. * bridge.setBootState(ready) - stm-preloader / stm-content visibility
  21. * bridge.renderDriver(scope) - the render walk (the whole DOM scan)
  22. * bridge.afterRenderCycle() - stm-guard link evaluation (guards)
  23. * bridge.applyErrorClasses(el,on) - stm-on-error / stm-on-success classes
  24. *
  25. * All four default to no-ops, so headless pages lose nothing by running core
  26. * alone. Endpoint overrides also arrive from document (it reads the
  27. * `stm-entrypoint` / `stm-slots` / `stm-channel` / `stm-worker` body
  28. * attributes into `statum.config`); init() uses config as given and never
  29. * reads the DOM itself.
  30. *
  31. * The client ships as modules, each a plain IIFE with no dependencies and no
  32. * build step, loaded in this order:
  33. *
  34. * 1. statum-core.js - this file: creates `window.statum`.
  35. * 2. statum-document.js - the render walk, the DOM bindings, the attribute
  36. * registry (`statum.registerAttribute`) and the
  37. * form bindings (`stm-prefill`,
  38. * `stm-clear-on-success`, `stm-clear-on-error`,
  39. * `statum.document`).
  40. * 3. statum-markdown.js - the CommonMark engine and `stm-markdown`.
  41. *
  42. * Targets modern evergreen browsers (uses fetch, BroadcastChannel,
  43. * queueMicrotask, URLSearchParams, FormData).
  44. */
  45. (function () {
  46. 'use strict';
  47. /** Content-Type used by every Statum directive response. */
  48. var STATUM_CONTENT_TYPE = 'application/vnd.statum+json';
  49. /** Request/response header carrying one slot's reference (or base64 frame). */
  50. var SLOT_HEADER = 'X-Statum-Slot';
  51. /** Request header carrying an action's opaque `private` blob. */
  52. var PRIVATE_HEADER = 'X-Statum-Private';
  53. /** Session-cookie marker used to detect a fresh browser session. */
  54. var SESSION_COOKIE = '_statum_sdc';
  55. /** BroadcastChannel name used to sync session/device state across tabs. */
  56. var CHANNEL_NAME = 'statum';
  57. /** localStorage / sessionStorage key prefix for persisted frames. */
  58. var STORAGE_PREFIX = 'statum:';
  59. // ---------------------------------------------------------------------------
  60. // Configuration
  61. // ---------------------------------------------------------------------------
  62. /**
  63. * Endpoint URLs. Defaults; pages override them either by writing
  64. * `statum.config` (headless pages do this before init runs) or with the
  65. * `stm-entrypoint` / `stm-slots` / `stm-channel` / `stm-worker` attributes
  66. * on the `<body>` element, which statum-document.js reads into this object
  67. * during its own (synchronous) load. Core itself never reads the DOM.
  68. *
  69. * @type {{entrypointUrl: string, slotsUrl: string}}
  70. */
  71. var config = {
  72. entrypointUrl: '/_statum/entrypoint',
  73. slotsUrl: '/_statum/slots',
  74. channelUrl: '/_statum/channel',
  75. // The realtime worker is embedded in the library as a default resource
  76. // (served at /_statum/resource/statum-worker.js); override with stm-worker.
  77. workerUrl: '/_statum/resource/statum-worker.js'
  78. };
  79. // ---------------------------------------------------------------------------
  80. // State store
  81. // ---------------------------------------------------------------------------
  82. /**
  83. * In-memory store, keyed by slot type. Each value is `{frame, snapshot}`
  84. * where `snapshot` is the parsed `frame.content`. This map is the single
  85. * source of truth for rendering; persistent backends merely hydrate it.
  86. *
  87. * @type {Map<string, {frame: object, snapshot: object}>}
  88. */
  89. var store = new Map();
  90. /** Listeners, keyed by type name. @type {Map<string, Set<Function>>} */
  91. var listeners = new Map();
  92. /**
  93. * Idempotency tracking: slot key -> last-applied frame `signature`. Held in
  94. * memory only (per tab), so a frame already persisted by another tab still
  95. * re-draws a tab whose tracking lags. Entries are dropped on clear so a
  96. * replayed frame re-applies.
  97. * @type {Map<string, string>}
  98. */
  99. var signatures = new Map();
  100. /** Optional UI handlers (set via `statum.on*Handler`). */
  101. var handlers = { onConfirm: null, onNotify: null, onError: null };
  102. /** @type {BroadcastChannel|null} */
  103. var channel = null;
  104. /** Whether {@link init} has already run. */
  105. var initialized = false;
  106. /**
  107. * Whether the entrypoint has been fetched. Post-entrypoint guard
  108. * evaluation (statum-document.js) is gated on it through the bridge.
  109. */
  110. var entrypointLoaded = false;
  111. // ---------------------------------------------------------------------------
  112. // Small utilities
  113. // ---------------------------------------------------------------------------
  114. /**
  115. * Parse an ISO 8601 timestamp to epoch millis, or `undefined`.
  116. * @param {string|undefined} s
  117. * @returns {number|undefined}
  118. */
  119. function parseTime(s) {
  120. if (!s) return undefined;
  121. var t = new Date(s).getTime();
  122. return isNaN(t) ? undefined : t;
  123. }
  124. /**
  125. * Base64-encode a UTF-8 string (safe for frames containing non-Latin1 data).
  126. * @param {string} str
  127. * @returns {string}
  128. */
  129. function b64encode(str) {
  130. var bytes = new TextEncoder().encode(str);
  131. var bin = '';
  132. var chunk = 0x8000;
  133. for (var i = 0; i < bytes.length; i += chunk) {
  134. bin += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk));
  135. }
  136. return btoa(bin);
  137. }
  138. /** Read a cookie value, or `null` if absent. @param {string} name */
  139. function getCookie(name) {
  140. var escaped = name.replace(/([.$?*|{}()[\]\/+^])/g, '\\$1');
  141. var match = document.cookie.match(new RegExp('(?:^|; )' + escaped + '=([^;]*)'));
  142. return match ? decodeURIComponent(match[1]) : null;
  143. }
  144. /** Set a session cookie (no expiry => cleared when the browser closes). */
  145. function setCookie(name, value) {
  146. document.cookie = name + '=' + encodeURIComponent(value) + '; path=/; SameSite=Lax';
  147. }
  148. // ---------------------------------------------------------------------------
  149. // Expression evaluation
  150. // ---------------------------------------------------------------------------
  151. /**
  152. * Compiled-expression cache. Expressions are evaluated as JavaScript against
  153. * a scope object (the page's HTML is trusted, per the model).
  154. * @type {Map<string, Function>}
  155. */
  156. var exprCache = new Map();
  157. /**
  158. * Wrap a scope for expression evaluation so bare identifiers resolve in a
  159. * controlled order: names on the scope (slot types, loop variables,
  160. * `stm-local-*` locals) win; names missing from the scope but present on
  161. * `globalThis` (`Math`, `parseInt`, `String`, …) forward to that global;
  162. * anything else resolves to `undefined` instead of throwing a
  163. * `ReferenceError` or leaking an unintended global. The `has` trap claims
  164. * every name so `with` never falls through around the proxy, and the inner
  165. * try/catch of the compiled expression still turns property access on a
  166. * resolved `undefined`/`null` into `undefined` for that expression alone.
  167. * @param {object} scope
  168. * @returns {
  169. {
  170. get: function(string, *): *,
  171. set: function(string, *): boolean,
  172. has: function(string): boolean
  173. }
  174. }
  175. */
  176. function evalScope(scope) {
  177. return new Proxy(scope, {
  178. get: function (target, name) {
  179. if (name === Symbol.unscopables) return undefined;
  180. if (name in target) return target[name];
  181. if (name in globalThis) return globalThis[name];
  182. return undefined;
  183. },
  184. set: function (target, name, value) {
  185. target[name] = value;
  186. return true;
  187. },
  188. has: function () { return true; }
  189. });
  190. }
  191. /**
  192. * Compile an expression once. The compiled form evaluates the expression
  193. * source inside a `with` over an {@link evalScope} proxy, with an inner
  194. * try/catch so a property access on an unset slot's `undefined` public data
  195. * degrades to `undefined` for that expression instead of throwing.
  196. * @param {string} expr
  197. * @returns {function(object): *}
  198. */
  199. function compileExpr(expr) {
  200. var cached = exprCache.get(expr);
  201. if (cached) return cached;
  202. var fn = new Function('scopeProxy', 'with (scopeProxy) { try { return (' +
  203. expr + '); } catch (e) { return undefined; } }');
  204. exprCache.set(expr, fn);
  205. return fn;
  206. }
  207. /**
  208. * Evaluate an expression against a scope. An unset/empty expression and any
  209. * parse error resolve to `undefined` (the "leave original DOM content"
  210. * policy; no warning — an author may legitimately bind an expression that
  211. * references a slot the page does not hold yet).
  212. * @param {string} expr
  213. * @param {object} [scope]
  214. * @returns {*}
  215. */
  216. function evalExpr(expr, scope) {
  217. if (expr == null || expr === '') return undefined;
  218. try {
  219. return compileExpr(expr)(evalScope(scope || {}));
  220. } catch (e) {
  221. return undefined;
  222. }
  223. }
  224. /**
  225. * Build a child scope that inherits the parent (so type bindings remain
  226. * visible) and adds one own loop variable.
  227. * @param {object} parent
  228. * @param {string} name
  229. * @param {*} value
  230. * @returns {object}
  231. */
  232. function childScope(parent, name, value) {
  233. var scope = Object.create(parent || null);
  234. scope[name] = value;
  235. return scope;
  236. }
  237. // ---------------------------------------------------------------------------
  238. // StatumError
  239. // ---------------------------------------------------------------------------
  240. /**
  241. * Error type thrown for HTTP failures and `error` directives.
  242. * @param {string} [message]
  243. * @param {string} [code]
  244. * @param {object} [data]
  245. */
  246. function StatumError(message, code, data) {
  247. this.name = 'StatumError';
  248. this.message = message || '';
  249. this.code = code || undefined;
  250. this.data = data || undefined;
  251. }
  252. StatumError.prototype = Object.create(Error.prototype);
  253. // ---------------------------------------------------------------------------
  254. // UI handlers (confirm / notify / error)
  255. // ---------------------------------------------------------------------------
  256. /**
  257. * Run the `notify` directive's default behaviour or the custom
  258. * `statum.onNotifyHandler`. The default surfaces the message via `alert()`.
  259. * @param {string} [kind] Application-defined category (freeform).
  260. * @param {string} [message]
  261. */
  262. function triggerNotify(kind, message) {
  263. var fn = handlers.onNotify;
  264. try {
  265. if (fn) fn(kind, message);
  266. else if (message != null) window.alert(message);
  267. } catch (e) { console.error('[statum] notify handler error', e); }
  268. }
  269. /**
  270. * Run the `stm-confirm` gate: the custom `statum.onConfirmHandler` if set,
  271. * otherwise `window.confirm`. Supports async handlers (custom modals).
  272. * @param {string} message
  273. * @param {HTMLElement} element
  274. * @returns {Promise<boolean>} truthy if the action should proceed.
  275. */
  276. async function triggerConfirm(message, element) {
  277. var fn = handlers.onConfirm;
  278. try {
  279. if (fn) return !!(await fn(message, element));
  280. return !!window.confirm(message);
  281. } catch (e) {
  282. console.error('[statum] confirm handler error', e);
  283. return false;
  284. }
  285. }
  286. /**
  287. * Run the custom `statum.onErrorHandler`, if set. Returns whether the default
  288. * behaviour (promise rejection + `stm-on-error` classes) should proceed: the
  289. * handler suppresses the default by returning (or resolving) `false`.
  290. * @param {StatumError} error
  291. * @param {HTMLElement|null} element
  292. * @returns {Promise<boolean>}
  293. */
  294. async function triggerError(error, element) {
  295. var fn = handlers.onError;
  296. if (!fn) return true;
  297. try {
  298. var suppress = await fn(error, element);
  299. return suppress !== false;
  300. } catch (e) {
  301. console.error('[statum] error handler error', e);
  302. return true;
  303. }
  304. }
  305. /**
  306. * Surface a failure: run `triggerError`, and if the default is not
  307. * suppressed, apply the error-state classes (when an origin element exists;
  308. * through the document-installed hook) and re-throw so the originating
  309. * promise rejects.
  310. * @param {StatumError} error
  311. * @param {HTMLElement|null} element
  312. */
  313. async function reportFailure(error, element) {
  314. if (await triggerError(error, element)) {
  315. if (element) applyErrorClasses(element, true);
  316. throw error;
  317. }
  318. }
  319. // ---------------------------------------------------------------------------
  320. // Persistence
  321. // ---------------------------------------------------------------------------
  322. /**
  323. * Pick the storage backend for a scope.
  324. * @param {string} scope
  325. * @returns {Storage|null} `null` for in-memory scopes.
  326. */
  327. function storageFor(scope) {
  328. if (scope === 'device' || scope === 'session') return localStorage;
  329. if (scope === 'flow') return sessionStorage;
  330. return null; // page / transient
  331. }
  332. /**
  333. * Persist a slot's frame to the backend matching its scope, removing any
  334. * stale copy from the other backend first (in case the scope changed).
  335. * @param {string} type
  336. * @param {{frame: object, snapshot: object}} entry
  337. */
  338. function persistEntry(type, entry) {
  339. var scope = entry.snapshot.slot && entry.snapshot.slot.scope;
  340. removePersisted(type);
  341. var storage = storageFor(scope);
  342. if (!storage) return;
  343. try {
  344. storage.setItem(STORAGE_PREFIX + type, JSON.stringify(entry.frame));
  345. } catch (e) {
  346. /* storage full or unavailable; remain in-memory only */
  347. }
  348. }
  349. /** Remove a persisted frame from both backends. @param {string} type */
  350. function removePersisted(type) {
  351. try { localStorage.removeItem(STORAGE_PREFIX + type); } catch (e) {}
  352. try { sessionStorage.removeItem(STORAGE_PREFIX + type); } catch (e) {}
  353. }
  354. /** Load every persisted frame from a backend into the in-memory store. */
  355. function loadFrom(storage) {
  356. for (var i = 0; i < storage.length; i++) {
  357. var key = storage.key(i);
  358. if (!key || key.indexOf(STORAGE_PREFIX) !== 0) continue;
  359. try {
  360. var frame = JSON.parse(storage.getItem(key));
  361. var snapshot = JSON.parse(frame.content);
  362. var type = snapshot.slot && snapshot.slot.type;
  363. if (type && !store.has(type)) store.set(type, { frame: frame, snapshot: snapshot });
  364. } catch (e) {
  365. /* corrupt entry: ignore */
  366. }
  367. }
  368. }
  369. /** Hydrate the store from localStorage and sessionStorage. */
  370. function hydrate() {
  371. try { loadFrom(localStorage); } catch (e) {}
  372. try { loadFrom(sessionStorage); } catch (e) {}
  373. }
  374. // ---------------------------------------------------------------------------
  375. // Session-cookie initialization
  376. // ---------------------------------------------------------------------------
  377. /**
  378. * Detect a fresh browser session using the `_statum_sdc` session cookie.
  379. * On a fresh session, clear all `session`-scoped persisted frames, then set
  380. * the marker. Session cookies are shared across tabs, so a tab opened
  381. * mid-session sees the marker and leaves session data intact.
  382. */
  383. function initSession() {
  384. if (getCookie(SESSION_COOKIE) !== null) return;
  385. clearSessionSlotsFromStorage();
  386. setCookie(SESSION_COOKIE, '1');
  387. }
  388. /** Remove all `session`-scoped frames from localStorage. */
  389. function clearSessionSlotsFromStorage() {
  390. var toRemove = [];
  391. for (var i = 0; i < localStorage.length; i++) {
  392. var key = localStorage.key(i);
  393. if (!key || key.indexOf(STORAGE_PREFIX) !== 0) continue;
  394. try {
  395. var frame = JSON.parse(localStorage.getItem(key));
  396. var snapshot = JSON.parse(frame.content);
  397. if (snapshot.slot && snapshot.slot.scope === 'session') toRemove.push(key);
  398. } catch (e) {}
  399. }
  400. for (var j = 0; j < toRemove.length; j++) localStorage.removeItem(toRemove[j]);
  401. }
  402. // ---------------------------------------------------------------------------
  403. // Listeners & cross-tab broadcast
  404. // ---------------------------------------------------------------------------
  405. /**
  406. * Fire all listeners for a type. (Renamed from `notify` to avoid confusion
  407. * with the `notify` directive.)
  408. * @param {string} type
  409. * @param {object|null} frame
  410. * @param {object|null} snapshot
  411. */
  412. function fireListeners(type, frame, snapshot) {
  413. var set = listeners.get(type);
  414. if (!set) return;
  415. var event = { type: type, frame: frame, snapshot: snapshot };
  416. set.forEach(function (cb) {
  417. try { cb(event); } catch (e) { console.error('[statum] listener error', e); }
  418. });
  419. }
  420. /**
  421. * Broadcast a state change to other tabs. Only `session`/`device` scopes are
  422. * shared (flow/page/transient are per-tab).
  423. * @param {object} message
  424. * @param {string} [scope]
  425. */
  426. function broadcast(message, scope) {
  427. if (!channel) return;
  428. if (scope !== 'session' && scope !== 'device') return;
  429. try { channel.postMessage(message); } catch (e) {}
  430. }
  431. /**
  432. * Broadcast a message to other tabs regardless of slot scope. Used to relay
  433. * realtime (live) events so they reach every tab, not just session/device
  434. * ones.
  435. */
  436. function broadcastAlways(message) {
  437. if (!channel) return;
  438. try { channel.postMessage(message); } catch (e) {}
  439. }
  440. /** Open the BroadcastChannel and wire inbound messages. */
  441. function setupChannel() {
  442. if (typeof BroadcastChannel === 'undefined') return;
  443. try {
  444. channel = new BroadcastChannel(CHANNEL_NAME);
  445. } catch (e) {
  446. channel = null;
  447. return;
  448. }
  449. channel.onmessage = function (ev) {
  450. var msg = ev.data;
  451. if (!msg) return;
  452. if (msg.kind === 'set' && msg.frame) {
  453. try {
  454. var snapshot = JSON.parse(msg.frame.content);
  455. var type = snapshot.slot && snapshot.slot.type;
  456. if (type && commitSet(msg.frame, snapshot, type, false)) renderAll();
  457. } catch (e) { /* ignore malformed */ }
  458. } else if (msg.kind === 'clear') {
  459. var t = msg.type;
  460. if (!t && msg.key != null) {
  461. store.forEach(function (entry, type) {
  462. if (entry.snapshot.slot && entry.snapshot.slot.key === msg.key) t = type;
  463. });
  464. }
  465. if (t) clearType(t, { broadcast: false });
  466. else if (msg.key != null) signatures.delete(msg.key);
  467. renderAll();
  468. }
  469. };
  470. }
  471. // ---------------------------------------------------------------------------
  472. // State mutation
  473. // ---------------------------------------------------------------------------
  474. /**
  475. * Remove a type from the store, un-persist it, drop its idempotency entry,
  476. * fire listeners, and (optionally) broadcast the clear to other tabs.
  477. * @param {string} type
  478. * @param {{broadcast?: boolean}} [opts]
  479. */
  480. function clearType(type, opts) {
  481. var entry = store.get(type);
  482. if (!entry) return;
  483. var scope = entry.snapshot.slot && entry.snapshot.slot.scope;
  484. var key = entry.snapshot.slot && entry.snapshot.slot.key;
  485. store.delete(type);
  486. removePersisted(type);
  487. if (key) signatures.delete(key);
  488. fireListeners(type, null, null);
  489. if (opts && opts.broadcast) broadcast({ kind: 'clear', key: key, type: type }, scope);
  490. }
  491. /**
  492. * Resolve a slot key to its type and clear it. Used by the `clear` directive
  493. * (keyed) and by live `clear` events.
  494. * @param {string} key
  495. * @param {boolean} [broadcast]
  496. */
  497. function clearByKey(key, broadcast) {
  498. var typeToRemove = null;
  499. store.forEach(function (entry, type) {
  500. if (entry.snapshot.slot && entry.snapshot.slot.key === key) typeToRemove = type;
  501. });
  502. if (typeToRemove) clearType(typeToRemove, { broadcast: !!broadcast });
  503. else if (key) signatures.delete(key);
  504. }
  505. /**
  506. * Resolve a frame signature to its slot type and clear it. Used by the
  507. * `invalidate` directive, which references a signature (not a key) because the
  508. * server reports a bad signature without trusting the frame's content.
  509. * @param {string} signature
  510. * @param {boolean} [broadcast]
  511. */
  512. function clearBySignature(signature, broadcast) {
  513. var typeToRemove = null;
  514. store.forEach(function (entry, type) {
  515. if (entry.frame && entry.frame.signature === signature) typeToRemove = type;
  516. });
  517. if (typeToRemove) clearType(typeToRemove, { broadcast: !!broadcast });
  518. else if (signature) signatures.delete(signature);
  519. }
  520. /**
  521. * Commit a `set` to the in-memory store, persist it, and fire listeners.
  522. * Idempotent: if the frame's `signature` matches the last-applied signature
  523. * for this slot (tracked in {@link signatures}), nothing happens and `false`
  524. * is returned. When `doBroadcast` is true the set is forwarded to other tabs
  525. * via the scope-gated broadcast (session/device only).
  526. * @param {object} frame
  527. * @param {object} snapshot
  528. * @param {string} type
  529. * @param {boolean} doBroadcast
  530. * @returns {boolean} `true` if state changed.
  531. */
  532. function commitSet(frame, snapshot, type, doBroadcast) {
  533. var key = snapshot.slot && snapshot.slot.key;
  534. var sig = frame.signature;
  535. if (key && sig !== undefined && signatures.get(key) === sig) return false;
  536. var entry = { frame: frame, snapshot: snapshot };
  537. store.set(type, entry);
  538. persistEntry(type, entry);
  539. fireListeners(type, frame, snapshot);
  540. if (key && sig !== undefined) signatures.set(key, sig);
  541. if (doBroadcast) {
  542. var scope = snapshot.slot && snapshot.slot.scope;
  543. broadcast({ kind: 'set', frame: frame }, scope);
  544. }
  545. return true;
  546. }
  547. /**
  548. * Apply a `set` directive: store/refresh a slot's snapshot (idempotently),
  549. * broadcasting to other tabs.
  550. * @param {object} directive `{ snapshot_frame }`
  551. */
  552. function applySet(directive) {
  553. var frame = directive.snapshot_frame;
  554. if (!frame || !frame.content) return;
  555. var snapshot;
  556. try { snapshot = JSON.parse(frame.content); } catch (e) { return; }
  557. var type = snapshot.slot && snapshot.slot.type;
  558. if (!type) return;
  559. commitSet(frame, snapshot, type, true);
  560. }
  561. /**
  562. * Apply a `clear` directive. The directive carries a slot key, so the type
  563. * holding that key is resolved and removed.
  564. * @param {object} directive `{ key }`
  565. */
  566. function applyClear(directive) {
  567. if (directive.key == null) return;
  568. clearByKey(directive.key, true);
  569. }
  570. /**
  571. * Apply a live `set` event received over the realtime channel. The frame is
  572. * committed (idempotently) and, when new, broadcast to sibling tabs via the
  573. * scope-agnostic {@link broadcastAlways} so every tab sees it regardless of
  574. * which (or how many) tabs hold a subscription.
  575. * @param {object} frame
  576. */
  577. function applyRemoteSet(frame) {
  578. try {
  579. var snapshot = JSON.parse(frame.content);
  580. var type = snapshot.slot && snapshot.slot.type;
  581. if (!type) return;
  582. if (commitSet(frame, snapshot, type, false)) {
  583. broadcastAlways({ kind: 'set', frame: frame });
  584. renderAll();
  585. }
  586. } catch (e) { /* malformed frame */ }
  587. }
  588. /** Apply a live `clear` event received over the realtime channel. */
  589. function applyRemoteClear(key) {
  590. if (key == null) return;
  591. signatures.delete(key);
  592. clearByKey(key, false);
  593. broadcastAlways({ kind: 'clear', key: key });
  594. renderAll();
  595. }
  596. // ---------------------------------------------------------------------------
  597. // Snapshot freshness & request headers
  598. // ---------------------------------------------------------------------------
  599. /**
  600. * Categorize a held snapshot relative to the current time.
  601. * @returns {'silent'|'always'|'invalid'|'stale'|'fresh'}
  602. * - `silent`: unidirectional (`transmit_after` undefined) - never sent.
  603. * - `always`: `transmit_after === as_at` - send the base64 frame each time.
  604. * - `invalid`: past `invalid_after` - drop and refetch.
  605. * - `stale`: past `transmit_after` but not yet invalid - pre-flight first.
  606. * - `fresh`: still cached server-side - send the `as_at` reference.
  607. */
  608. function entryCategory(entry) {
  609. var snap = entry.snapshot;
  610. var transmit = snap.transmit_after;
  611. if (transmit === undefined || transmit === null) return 'silent';
  612. if (transmit === snap.as_at) return 'always';
  613. var now = Date.now();
  614. var invalidAt = parseTime(snap.invalid_after);
  615. var transmitAt = parseTime(transmit);
  616. if (invalidAt !== undefined && now >= invalidAt) return 'invalid';
  617. if (transmitAt !== undefined && now >= transmitAt) return 'stale';
  618. return 'fresh';
  619. }
  620. /**
  621. * Drop locally-invalid snapshots and pre-flight any stale ones. Pre-flighting
  622. * posts the stale frames to the slot endpoint and applies the returned `set`
  623. * directives, refreshing their `as_at` values before the real request runs.
  624. *
  625. * Single-flight: concurrent callers share one in-flight preparation.
  626. * @returns {Promise<void>}
  627. */
  628. var preparePromise = null;
  629. function prepareSlots() {
  630. if (preparePromise) return preparePromise;
  631. preparePromise = doPrepareSlots().then(function () {
  632. preparePromise = null;
  633. }, function () {
  634. preparePromise = null;
  635. });
  636. return preparePromise;
  637. }
  638. async function doPrepareSlots() {
  639. var stale = [];
  640. store.forEach(function (entry, type) {
  641. var cat = entryCategory(entry);
  642. if (cat === 'invalid') {
  643. clearType(type, { broadcast: false });
  644. } else if (cat === 'stale') {
  645. stale.push(entry.frame);
  646. }
  647. });
  648. if (stale.length) {
  649. var directives = await postSlots(stale);
  650. var pfResult = applyDirectives(directives, { originElement: null });
  651. executeTerminal(pfResult);
  652. }
  653. }
  654. /**
  655. * Build the `X-Statum-Slot` header values for every held slot, using the
  656. * freshness rules. Assumes {@link prepareSlots} has just run.
  657. * @returns {string[]}
  658. */
  659. function buildSlotHeaders() {
  660. var headers = [];
  661. store.forEach(function (entry) {
  662. var snap = entry.snapshot;
  663. var cat = entryCategory(entry);
  664. if (cat === 'silent' || cat === 'invalid') return;
  665. if (cat === 'always') {
  666. headers.push(b64encode(JSON.stringify(entry.frame)));
  667. } else {
  668. headers.push('key=' + snap.slot.key + '; as_at=' + snap.as_at);
  669. }
  670. });
  671. return headers;
  672. }
  673. /**
  674. * Collect the current frames held for the given slot keys, in the order the
  675. * keys are first supplied. Used by the `transmit` directive (which forces a
  676. * slot's frame to be posted as if its `transmit_after` had elapsed). Keys the
  677. * client does not currently hold are skipped.
  678. * @param {string[]} keys
  679. * @returns {object[]}
  680. */
  681. function collectFramesForKeys(keys) {
  682. var keySet = Object.create(null);
  683. for (var i = 0; i < keys.length; i++) keySet[keys[i]] = true;
  684. var frames = [];
  685. store.forEach(function (entry) {
  686. var key = entry.snapshot.slot && entry.snapshot.slot.key;
  687. if (key && keySet[key]) {
  688. frames.push(entry.frame);
  689. delete keySet[key];
  690. }
  691. });
  692. return frames;
  693. }
  694. // ---------------------------------------------------------------------------
  695. // HTTP helpers
  696. // ---------------------------------------------------------------------------
  697. /** True if a response is a Statum directive response. */
  698. function isStatum(res) {
  699. return (res.headers.get('Content-Type') || '').indexOf(STATUM_CONTENT_TYPE) !== -1;
  700. }
  701. /**
  702. * POST an array of frames to the slot endpoint (the pre-flight target) and
  703. * return its directive array. This request intentionally carries no slot
  704. * headers (it is itself the refresh).
  705. * @param {object[]} frames
  706. * @returns {Promise<object[]>}
  707. */
  708. async function postSlots(frames) {
  709. var headers = new Headers();
  710. headers.set('Accept', STATUM_CONTENT_TYPE);
  711. headers.set('Content-Type', 'application/json');
  712. var res = await fetch(config.slotsUrl, {
  713. method: 'POST',
  714. headers: headers,
  715. body: JSON.stringify(frames),
  716. credentials: 'same-origin'
  717. });
  718. if (!isStatum(res)) return [];
  719. try { return await res.json(); } catch (e) { return []; }
  720. }
  721. /**
  722. * Apply the non-terminal directives from a response (set/clear/subscribe/
  723. * unsubscribe/notify/transmit-collection), then `render`.
  724. *
  725. * Terminal directives (`navigate`, `post`, `reenter`), error directives, and
  726. * any transmit/retry intent are collected on the returned result rather than
  727. * executed here: {@link handleResponse} performs the asynchronous
  728. * transmission (and optional retry) and then runs terminal navigation, so
  729. * that a `transmit` with `retry` can re-issue the request before the page is
  730. * navigated away.
  731. * @param {object[]} directives
  732. * @param {{originElement?: HTMLElement}} [ctx]
  733. * @returns {{errors: StatumError[], transmitKeys: string[], retry: boolean, navigate: object|null, post: object|null, reenter: boolean}}
  734. */
  735. function applyDirectives(directives, ctx) {
  736. var result = { errors: [], transmitKeys: [], retry: false, navigate: null, post: null, reenter: false, formSubmits: [], formResets: [] };
  737. var list = Array.isArray(directives) ? directives : [];
  738. for (var i = 0; i < list.length; i++) {
  739. var d = list[i];
  740. if (!d || typeof d !== 'object') continue;
  741. switch (d.type) {
  742. case 'set': applySet(d); break;
  743. case 'clear': applyClear(d); break;
  744. case 'invalidate':
  745. if (d.signature != null) {
  746. console.warn('[statum] server could not verify slot frame ' + d.signature + '; clearing it');
  747. clearBySignature(d.signature, true);
  748. }
  749. break;
  750. case 'subscribe': channelSubscribe(d.key); break;
  751. case 'unsubscribe': channelUnsubscribe(d.key); break;
  752. case 'notify': triggerNotify(d.kind, d.message); break;
  753. case 'transmit':
  754. if (d.key != null) {
  755. if (result.transmitKeys.indexOf(d.key) === -1) result.transmitKeys.push(d.key);
  756. if (d.retry) result.retry = true;
  757. }
  758. break;
  759. case 'error':
  760. {
  761. var err = new StatumError(d.message, d.code, d.data);
  762. var detail = d.code ? (d.message + ' [' + d.code + ']') : d.message;
  763. console.error('[statum] error directive: ' + (detail || '(no message)'));
  764. result.errors.push(err);
  765. }
  766. break;
  767. case 'navigate': result.navigate = d; break;
  768. case 'reenter': result.reenter = true; break;
  769. case 'post': result.post = d; break;
  770. case 'form-submit': result.formSubmits.push(d); break;
  771. case 'form-reset': result.formResets.push(d); break;
  772. }
  773. }
  774. renderAll();
  775. return result;
  776. }
  777. /**
  778. * Execute the terminal directives collected by {@link applyDirectives}.
  779. *
  780. * Order: `form-reset`/`form-submit` first (their work is synchronous), then
  781. * a `reenter` (the entrypoint refresh is awaited so the action's promise
  782. * resolves only once the re-entered state has been applied), then
  783. * `navigate`/`post` — navigation wins over reenter's refresh when both are
  784. * present, running after it. Async because of reenter.
  785. */
  786. async function executeTerminal(result) {
  787. result.formResets.forEach(function (d) { domTerminal('form-reset', d); });
  788. result.formSubmits.forEach(function (d) { domTerminal('form-submit', d); });
  789. if (result.reenter) {
  790. try {
  791. await loadEntrypoint();
  792. } catch (err) {
  793. console.error('[statum] reenter: entrypoint refresh failed', err);
  794. }
  795. }
  796. if (result.navigate) domTerminal('navigate', result.navigate);
  797. if (result.post) domTerminal('post', result.post);
  798. }
  799. /**
  800. * Execute one DOM-shaped terminal directive. The executors themselves
  801. * (element lookup, form construction, navigation) live in
  802. * statum-document.js — core is headless and owns no DOM machinery — and
  803. * install themselves through the bridge. A headless page (document not
  804. * loaded) drops the directive with a warning: these directives only make
  805. * sense with a document.
  806. * @param {string} kind 'form-submit' | 'form-reset' | 'navigate' | 'post'
  807. * @param {object} d the directive
  808. */
  809. function domTerminal(kind, d) {
  810. console.warn('[statum] ' + kind + ' directive ignored: statum-document.js is not loaded');
  811. }
  812. /**
  813. * Handle a fetch response: if it is a Statum response, apply its directives;
  814. * otherwise throw on HTTP errors and no-op on other successful responses.
  815. *
  816. * Directive ordering: non-terminal directives are applied and the page
  817. * rendered; then any `transmit` directives force their slot frames to be
  818. * posted (applying the directives the slot endpoint returns); then, if a
  819. * transmit requested a retry, the originating request (`ctx.retry`) is
  820. * re-issued and this response's terminal directives and errors are skipped.
  821. * Otherwise terminal directives run — form resets/submits, then an awaited
  822. * `reenter` entrypoint refresh (a failure is logged, never propagated), then
  823. * `navigate`/`post` — and finally error directives are surfaced. Because
  824. * the terminal phase is awaited, an action promise resolves only after a
  825. * `reenter` refresh has been applied.
  826. * @param {Response} res
  827. * @param {{originElement?: HTMLElement, retry?: Function}} [ctx]
  828. */
  829. async function handleResponse(res, ctx) {
  830. var el = ctx && ctx.originElement;
  831. if (!isStatum(res)) {
  832. if (!res.ok) await reportFailure(new StatumError('HTTP ' + res.status), el);
  833. return;
  834. }
  835. var directives;
  836. try { directives = await res.json(); } catch (e) { directives = []; }
  837. var result = applyDirectives(directives, ctx);
  838. if (result.transmitKeys && result.transmitKeys.length) {
  839. var frames = collectFramesForKeys(result.transmitKeys);
  840. if (frames.length) {
  841. var txDirectives = await postSlots(frames);
  842. applyDirectives(txDirectives, { originElement: el });
  843. }
  844. }
  845. if (result.retry && ctx && typeof ctx.retry === 'function') {
  846. await ctx.retry();
  847. return;
  848. }
  849. await executeTerminal(result);
  850. var errors = result.errors || [];
  851. for (var i = 0; i < errors.length; i++) {
  852. await reportFailure(errors[i], el);
  853. }
  854. }
  855. // ---------------------------------------------------------------------------
  856. // Entrypoint & action requests
  857. // ---------------------------------------------------------------------------
  858. /**
  859. * Fetch the entrypoint for the current URL, applying its directives. Carries
  860. * slot headers (and pre-flights) like any other request. The request is
  861. * wrapped in an `attempt` closure passed as the response `retry` callback, so
  862. * a `transmit` directive with `retry` can re-issue it after transmission.
  863. * @returns {Promise<void>}
  864. */
  865. function loadEntrypoint() {
  866. // From this point on guards are "post-entrypoint" (all of them evaluate).
  867. entrypointLoaded = true;
  868. var attempt = async function () {
  869. await prepareSlots();
  870. var headers = new Headers();
  871. headers.set('Accept', STATUM_CONTENT_TYPE);
  872. buildSlotHeaders().forEach(function (v) { headers.append(SLOT_HEADER, v); });
  873. var url = config.entrypointUrl + '?uri=' + encodeURIComponent(window.location.href);
  874. var res = await fetch(url, { headers: headers, credentials: 'same-origin' });
  875. await handleResponse(res, { originElement: null, retry: attempt });
  876. };
  877. return attempt();
  878. }
  879. /**
  880. * Perform an action request.
  881. *
  882. * The request is wrapped in an `attempt` closure passed as the response
  883. * `retry` callback, so a `transmit` directive with `retry` can re-issue the
  884. * same action after the transmission (rebuilding headers from current state).
  885. *
  886. * @param {object} action `{ uri, method, private }`
  887. * @param {object} [data] Key/value parameters. Encoded as query string for
  888. * GET/HEAD, otherwise as `application/x-www-form-urlencoded`.
  889. * @param {HTMLElement} [originElement] Element that triggered the action, so
  890. * that `stm-on-error` classes can be applied on an `error` directive.
  891. * @returns {Promise<void>} Resolves once the request completes and its
  892. * directives have run; rejects on HTTP failure or an `error` directive.
  893. */
  894. function performAction(action, data, originElement) {
  895. var attempt = async function () {
  896. await prepareSlots();
  897. var headers = new Headers();
  898. headers.set('Accept', STATUM_CONTENT_TYPE);
  899. buildSlotHeaders().forEach(function (v) { headers.append(SLOT_HEADER, v); });
  900. if (action.private) headers.set(PRIVATE_HEADER, action.private);
  901. var method = (action.method || 'GET').toUpperCase();
  902. var params = data || {};
  903. var init = { method: method, headers: headers, credentials: 'same-origin' };
  904. var url = action.uri;
  905. if (method === 'GET' || method === 'HEAD') {
  906. var qs = new URLSearchParams(params).toString();
  907. if (qs) url += (url.indexOf('?') !== -1 ? '&' : '?') + qs;
  908. } else {
  909. init.body = new URLSearchParams(params).toString();
  910. headers.set('Content-Type', 'application/x-www-form-urlencoded');
  911. }
  912. var res;
  913. try {
  914. res = await fetch(url, init);
  915. } catch (networkErr) {
  916. await reportFailure(new StatumError('Network error'), originElement);
  917. return;
  918. }
  919. await handleResponse(res, { originElement: originElement, retry: attempt });
  920. };
  921. return attempt();
  922. }
  923. // ---------------------------------------------------------------------------
  924. // Realtime channel (subscribe/unsubscribe directives)
  925. // ---------------------------------------------------------------------------
  926. /**
  927. * Per-tab fallback engine, used when `SharedWorker` is unavailable. Owns an
  928. * `EventSource`, reads the channel id from the first `event: channel`,
  929. * reference-counts slot keys within this tab, PATCHes subscription changes,
  930. * applies incoming set/clear events (broadcasting them to siblings), and
  931. * closes the stream once it has no subscriptions.
  932. */
  933. function createFallbackEngine() {
  934. var es = null;
  935. var channelId = null;
  936. var refs = new Map(); // slotKey -> count
  937. var pending = { subscribe: [], unsubscribe: [] };
  938. var patchScheduled = false;
  939. function openStream() {
  940. if (es) return;
  941. try { es = new EventSource(config.channelUrl); }
  942. catch (e) { es = null; return; }
  943. es.addEventListener('channel', function (ev) { channelId = ev.data; flushPatch(); });
  944. es.addEventListener('set', function (ev) {
  945. try { applyRemoteSet(JSON.parse(ev.data)); } catch (e) {}
  946. });
  947. es.addEventListener('clear', function (ev) { applyRemoteClear(ev.data); });
  948. es.onerror = function () { /* EventSource auto-reconnects */ };
  949. }
  950. function closeStream() {
  951. if (es) { try { es.close(); } catch (e) {} es = null; }
  952. channelId = null;
  953. }
  954. function schedulePatch() {
  955. if (patchScheduled) return;
  956. patchScheduled = true;
  957. Promise.resolve().then(function () { patchScheduled = false; flushPatch(); });
  958. }
  959. function flushPatch() {
  960. if (!channelId) return;
  961. var sub = pending.subscribe, uns = pending.unsubscribe;
  962. if (!sub.length && !uns.length) return;
  963. pending = { subscribe: [], unsubscribe: [] };
  964. var body = {};
  965. if (sub.length) body.subscribe = sub;
  966. if (uns.length) body.unsubscribe = uns;
  967. try {
  968. fetch(config.channelUrl + '/' + encodeURIComponent(channelId), {
  969. method: 'PATCH',
  970. headers: { 'Content-Type': 'application/json' },
  971. credentials: 'same-origin',
  972. body: JSON.stringify(body)
  973. }).catch(function () {});
  974. } catch (e) {}
  975. }
  976. return {
  977. subscribe: function (key) {
  978. var c = refs.get(key) || 0;
  979. refs.set(key, c + 1);
  980. if (c === 0) { openStream(); pending.subscribe.push(key); schedulePatch(); }
  981. },
  982. unsubscribe: function (key) {
  983. var c = refs.get(key) || 0;
  984. if (c <= 0) return;
  985. if (c - 1 <= 0) {
  986. refs.delete(key);
  987. pending.unsubscribe.push(key);
  988. schedulePatch();
  989. if (refs.size === 0) setTimeout(function () { if (refs.size === 0) closeStream(); }, 0);
  990. } else {
  991. refs.set(key, c - 1);
  992. }
  993. }
  994. };
  995. }
  996. /**
  997. * Channel manager. Prefers a `SharedWorker` (which owns the single SSE
  998. * connection, reference-counts subscriptions across tabs, and relays events
  999. * through the cross-tab `BroadcastChannel`); falls back to a per-tab
  1000. * `EventSource` engine when `SharedWorker` is unavailable or the worker
  1001. * script fails to load. Tracks the true per-slot subscription count so it can
  1002. * replay active subscriptions into the fallback on a mid-session switch.
  1003. */
  1004. function createChannelManager() {
  1005. var port = null;
  1006. var workerObj = null;
  1007. var fallback = null;
  1008. var mode = null; // 'worker' | 'fallback' | null
  1009. var counts = new Map(); // slotKey -> count (true subscription state)
  1010. function switchToFallback() {
  1011. if (mode === 'fallback') return;
  1012. mode = 'fallback';
  1013. port = null;
  1014. workerObj = null;
  1015. fallback = createFallbackEngine();
  1016. counts.forEach(function (c, key) {
  1017. for (var i = 0; i < c; i++) fallback.subscribe(key);
  1018. });
  1019. }
  1020. function ensure() {
  1021. if (mode !== null) return;
  1022. if (typeof SharedWorker !== 'undefined') {
  1023. try {
  1024. workerObj = new SharedWorker(config.workerUrl);
  1025. port = workerObj.port;
  1026. port.start();
  1027. port.postMessage({ op: 'init', url: config.channelUrl });
  1028. // The worker delivers events via the BroadcastChannel, so nothing is
  1029. // required on this port beyond command messages.
  1030. port.onmessage = function () {};
  1031. workerObj.onerror = function () {
  1032. console.warn('[statum] realtime worker failed; falling back to per-tab channel');
  1033. switchToFallback();
  1034. };
  1035. window.addEventListener('pagehide', function () {
  1036. if (mode === 'worker' && port) {
  1037. try { port.postMessage({ op: 'disconnect' }); } catch (e) {}
  1038. }
  1039. });
  1040. mode = 'worker';
  1041. return;
  1042. } catch (e) { workerObj = null; port = null; }
  1043. }
  1044. switchToFallback();
  1045. }
  1046. return {
  1047. subscribe: function (key) {
  1048. if (key == null) return;
  1049. ensure();
  1050. counts.set(key, (counts.get(key) || 0) + 1);
  1051. if (mode === 'worker') { try { port.postMessage({ op: 'subscribe', key: key }); } catch (e) {} }
  1052. else fallback.subscribe(key);
  1053. },
  1054. unsubscribe: function (key) {
  1055. if (key == null) return;
  1056. var c = (counts.get(key) || 0) - 1;
  1057. if (c <= 0) counts.delete(key); else counts.set(key, c);
  1058. ensure();
  1059. if (mode === 'worker') { try { port.postMessage({ op: 'unsubscribe', key: key }); } catch (e) {} }
  1060. else fallback.unsubscribe(key);
  1061. }
  1062. };
  1063. }
  1064. var channelManager = createChannelManager();
  1065. function channelSubscribe(key) { channelManager.subscribe(key); }
  1066. function channelUnsubscribe(key) { channelManager.unsubscribe(key); }
  1067. // ---------------------------------------------------------------------------
  1068. // Document seam: the hooks statum-document.js installs
  1069. // ---------------------------------------------------------------------------
  1070. //
  1071. // Core never touches the DOM, so everything visual — the boot visibility
  1072. // toggle, the render walk, the guard evaluation, the error-state classes —
  1073. // reaches the DOM through these hooks, defaulting to no-ops. With
  1074. // statum-document.js loaded every hook is real; without it core still boots,
  1075. // fetches the entrypoint and applies directives to state (headless use).
  1076. /** @type {Function} stm-preloader / stm-content visibility; no-op headless. */
  1077. var setBootState = function () {};
  1078. /** @type {Function} The render walk driver `fn(scope)`; absent headless. */
  1079. var renderDriver = null;
  1080. /**
  1081. * @type {Function} Post-walk cycle hook `fn()`: stm-guard evaluation. Runs
  1082. * after the afterEvaluate hooks, exactly where evaluateGuards used to.
  1083. */
  1084. var afterRenderCycle = function () {};
  1085. /**
  1086. * @type {Function} `fn(el, on)`: apply or clear the stm-on-error /
  1087. * stm-on-success classes on an action element. No-op headless.
  1088. */
  1089. var applyErrorClasses = function () {};
  1090. // ---------------------------------------------------------------------------
  1091. // Evaluation hooks (no-arg callbacks before/after each render cycle)
  1092. // ---------------------------------------------------------------------------
  1093. var beforeHooks = [];
  1094. var afterHooks = [];
  1095. /**
  1096. * Run one render cycle: the beforeEvaluate hooks, the render walk (when a
  1097. * document module has installed its driver), the afterEvaluate hooks, then
  1098. * the document-side cycle hook (guard evaluation). Headless (no driver), the
  1099. * cycle still runs the hooks — they observe state changes, not DOM.
  1100. */
  1101. function renderAll() {
  1102. beforeHooks.forEach(function (fn) { try { fn(); } catch (e) { console.error('[statum] beforeEvaluate hook error', e); } });
  1103. try {
  1104. // The DOM walk lives in statum-document.js and is installed through
  1105. // the seam; without it the cycle still runs hooks and the cycle hook.
  1106. if (renderDriver) renderDriver(stateScope());
  1107. }
  1108. catch (e) { console.error('[statum] render error', e); }
  1109. afterHooks.forEach(function (fn) { try { fn(); } catch (e) { console.error('[statum] afterEvaluate hook error', e); } });
  1110. try {
  1111. afterRenderCycle();
  1112. }
  1113. catch (e) { console.error('[statum] render cycle hook error', e); }
  1114. }
  1115. // ---------------------------------------------------------------------------
  1116. // Public state accessors
  1117. // ---------------------------------------------------------------------------
  1118. /** Build the scope used for expression evaluation: `{ typeName: public }`. */
  1119. function stateScope() {
  1120. var scope = {};
  1121. store.forEach(function (entry, type) { scope[type] = entry.snapshot.public; });
  1122. return scope;
  1123. }
  1124. /** Read a type's `public` data (what the attributes bind to). */
  1125. function read(type) {
  1126. var entry = store.get(type);
  1127. return entry ? entry.snapshot.public : undefined;
  1128. }
  1129. /** Read a type's full snapshot object. */
  1130. function getSnapshot(type) {
  1131. var entry = store.get(type);
  1132. return entry ? entry.snapshot : undefined;
  1133. }
  1134. /** Read the frame wrapping a type's snapshot. */
  1135. function getFrame(type) {
  1136. var entry = store.get(type);
  1137. return entry ? entry.frame : undefined;
  1138. }
  1139. /** Build an object of `{ typeName: public }` for every held type. */
  1140. function state() {
  1141. var result = {};
  1142. store.forEach(function (entry, type) { result[type] = entry.snapshot.public; });
  1143. return result;
  1144. }
  1145. /**
  1146. * Clear state. With a type argument, clears that type only; without, clears
  1147. * all held state.
  1148. * @param {string} [type]
  1149. */
  1150. function clear(type) {
  1151. if (type === undefined) {
  1152. Array.from(store.keys()).forEach(function (t) { clearType(t, { broadcast: true }); });
  1153. } else {
  1154. clearType(type, { broadcast: true });
  1155. }
  1156. renderAll();
  1157. }
  1158. /**
  1159. * Perform an action. `action` may be an action object or a string path that
  1160. * resolves to an action object within the current state.
  1161. * @param {object|string} action
  1162. * @param {object} [data]
  1163. * @returns {Promise<void>}
  1164. */
  1165. function act(action, data) {
  1166. var resolved = action;
  1167. if (typeof action === 'string') resolved = evalExpr(action, stateScope());
  1168. if (!resolved || typeof resolved.uri !== 'string') {
  1169. return Promise.reject(new StatumError('Invalid action'));
  1170. }
  1171. return performAction(resolved, data, null);
  1172. }
  1173. /** Attach a state listener for a type. */
  1174. function addStateListener(type, callback) {
  1175. if (typeof callback !== 'function') return;
  1176. if (!listeners.has(type)) listeners.set(type, new Set());
  1177. listeners.get(type).add(callback);
  1178. }
  1179. /** Remove a previously-attached state listener. */
  1180. function removeStateListener(type, callback) {
  1181. var set = listeners.get(type);
  1182. if (!set) return;
  1183. set.delete(callback);
  1184. if (set.size === 0) listeners.delete(type);
  1185. }
  1186. // ---------------------------------------------------------------------------
  1187. // Initialization
  1188. // ---------------------------------------------------------------------------
  1189. /**
  1190. * Initialize Statum: run the session-cookie check, hydrate persisted state,
  1191. * open the cross-tab channel, fetch the entrypoint, and run render cycles.
  1192. * Safe to call multiple times.
  1193. *
  1194. * Headless by design: endpoint config comes from `statum.config` (pages —
  1195. * or statum-document.js, reading the `stm-*` body attributes — write it
  1196. * before init runs), and the boot-state toggle is the document hook. Nothing
  1197. * here scans the DOM.
  1198. * @returns {Promise<void>}
  1199. */
  1200. async function init() {
  1201. if (initialized) return;
  1202. initialized = true;
  1203. try { initSession(); } catch (e) {}
  1204. try { hydrate(); } catch (e) {}
  1205. setupChannel();
  1206. setBootState(false);
  1207. renderAll(); // bind against any persisted/hydrated state immediately
  1208. try {
  1209. await loadEntrypoint();
  1210. } catch (err) {
  1211. console.error('[statum] entrypoint load failed', err);
  1212. } finally {
  1213. setBootState(true);
  1214. }
  1215. }
  1216. function autoInit() {
  1217. var ready = 'loading';
  1218. try { ready = document.readyState; } catch (e) {}
  1219. if (ready === 'loading') {
  1220. try {
  1221. document.addEventListener('DOMContentLoaded', init);
  1222. } catch (e) {
  1223. init();
  1224. }
  1225. } else {
  1226. init();
  1227. }
  1228. }
  1229. // ---------------------------------------------------------------------------
  1230. // Public API
  1231. // ---------------------------------------------------------------------------
  1232. /** @namespace statum */
  1233. window.statum = {
  1234. /** Library version. */
  1235. version: '0.1.0',
  1236. /** Mutable endpoint configuration. */
  1237. config: config,
  1238. /** (Re)initialize the library. Usually runs automatically on load. */
  1239. init: init,
  1240. /** Read a type's `public` data. */
  1241. read: read,
  1242. /** Read a type's full snapshot object. */
  1243. getSnapshot: getSnapshot,
  1244. /** Read the frame wrapping a type's snapshot. */
  1245. getFrame: getFrame,
  1246. /** Build `{ typeName: public }` for every held type. */
  1247. state: state,
  1248. /** Clear one type (`clear("x")`) or all state (`clear()`). */
  1249. clear: clear,
  1250. /** Perform an action object or named action. */
  1251. act: act,
  1252. /** Attach a state-changed listener for a type. */
  1253. addStateListener: addStateListener,
  1254. /** Remove a previously-attached listener. */
  1255. removeStateListener: removeStateListener,
  1256. /** Register a callback that runs before each render cycle (no arguments). */
  1257. beforeEvaluate: function (fn) { if (typeof fn === 'function') beforeHooks.push(fn); },
  1258. /** Register a callback that runs after each render cycle (no arguments). */
  1259. afterEvaluate: function (fn) { if (typeof fn === 'function') afterHooks.push(fn); },
  1260. /** The Statum error type (thrown by `act` and surfaced to handlers). */
  1261. Error: StatumError,
  1262. get onConfirmHandler() { return handlers.onConfirm; },
  1263. set onConfirmHandler(v) { handlers.onConfirm = typeof v === 'function' ? v : null; },
  1264. get onNotifyHandler() { return handlers.onNotify; },
  1265. set onNotifyHandler(v) { handlers.onNotify = typeof v === 'function' ? v : null; },
  1266. get onErrorHandler() { return handlers.onError; },
  1267. set onErrorHandler(v) { handlers.onError = typeof v === 'function' ? v : null; }
  1268. };
  1269. // The seam must be exposed before any other module loads. autoInit only
  1270. // ever runs on DOMContentLoaded (or immediately at load); both happen
  1271. // outside this script tag, so the later module tags always see the bridge.
  1272. window.statum._bridge = {
  1273. /** Expression machinery (core-owned). */
  1274. evalExpr: evalExpr,
  1275. childScope: childScope,
  1276. stateScope: stateScope,
  1277. /** Whether the entrypoint has been fetched (guards gate on it). */
  1278. entrypointLoaded: function () { return entrypointLoaded; },
  1279. /** Render cycle + requests. */
  1280. renderAll: renderAll,
  1281. loadEntrypoint: loadEntrypoint,
  1282. performAction: performAction,
  1283. /** UI handler overrides (runElementAction gates on confirm). */
  1284. triggerConfirm: triggerConfirm,
  1285. triggerNotify: triggerNotify,
  1286. triggerError: triggerError,
  1287. handlers: handlers,
  1288. /**
  1289. * Attribute-handler registration. The registry itself lives in
  1290. * statum-document.js (it is DOM machinery: the walk iterates it); until
  1291. * that module loads, registrations are QUEUED here so a module can load
  1292. * against core alone (tests load core+markdown, for instance). Document
  1293. * drains the queue through the real registry when it installs it.
  1294. */
  1295. registerAttribute: function (name, fn, opts) {
  1296. pendingRegistrations.push([name, fn, opts]);
  1297. },
  1298. /** Take (and clear) the registrations queued before document loaded. */
  1299. takePendingRegistrations: function () {
  1300. var pending = pendingRegistrations;
  1301. pendingRegistrations = [];
  1302. return pending;
  1303. },
  1304. /**
  1305. * Hooks statum-document.js installs (core defaults the first to a no-op
  1306. * and the rest to "absent"; the setters keep the names stable for that
  1307. * module's installer).
  1308. */
  1309. setBootState: function (fn) { if (typeof fn === 'function') setBootState = fn; },
  1310. renderDriver: function (fn) { if (typeof fn === 'function') renderDriver = fn; },
  1311. afterRenderCycle: function (fn) { if (typeof fn === 'function') afterRenderCycle = fn; },
  1312. applyErrorClasses: function (fn) { if (typeof fn === 'function') applyErrorClasses = fn; },
  1313. /**
  1314. * Scope-construction and pass-start seams (statum-binding.js installs
  1315. * both; defaults are identity / no-op so the walk works without it).
  1316. * buildScope lets a module extend an element's scope before the
  1317. * registered handlers evaluate; onPassStart runs at walk start with the
  1318. * root scope (per-pass cache resets, page-wide sweeps).
  1319. */
  1320. buildScope: function (fn) { if (typeof fn === 'function') scopeBuilder = fn; },
  1321. onPassStart: function (fn) { if (typeof fn === 'function') passStartHook = fn; },
  1322. /**
  1323. * DOM-shaped terminal-directive executors (form-submit, form-reset,
  1324. * navigate, post). Core dispatches and orders them but is headless —
  1325. * document installs the implementations; the default warns and drops.
  1326. */
  1327. setDomTerminal: function (fn) { if (typeof fn === 'function') domTerminal = fn; },
  1328. /** Read back the installed seams (document's walk consumes these). */
  1329. getScopeBuilder: function () { return scopeBuilder; },
  1330. getPassStartHook: function () { return passStartHook; }
  1331. };
  1332. /** @type {Function} identity default; statum-binding.js replaces it. */
  1333. var scopeBuilder = function (el, scope) { return scope; };
  1334. /** @type {Function|null} no-op default; statum-binding.js replaces it. */
  1335. var passStartHook = null;
  1336. /** @type {Function} warn-and-drop default; statum-document.js replaces it. */
  1337. var domTerminal = function (kind, d) {
  1338. console.warn('[statum] ' + kind + ' directive ignored: statum-document.js is not loaded');
  1339. };
  1340. /** @type {Array<[string, Function, object|undefined]>} */
  1341. var pendingRegistrations = [];
  1342. autoInit();
  1343. })();