statum-core.js 54 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463
  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-def-*` 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(doFormReset);
  788. result.formSubmits.forEach(doFormSubmit);
  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) doNavigate(result.navigate);
  797. if (result.post) doPost(result.post);
  798. }
  799. /** Execute a `form-submit` directive: optionally override action/method, then submit. */
  800. function doFormSubmit(d) {
  801. var form = document.getElementById(d.form);
  802. if (form) {
  803. if (d.action) form.setAttribute('action', d.action);
  804. if (d.method) form.setAttribute('method', d.method);
  805. form.submit();
  806. }
  807. }
  808. /** Execute a `form-reset` directive. */
  809. function doFormReset(d) {
  810. var form = document.getElementById(d.form);
  811. if (form) form.reset();
  812. }
  813. /** Execute a `navigate` directive as a full page load. */
  814. function doNavigate(d) {
  815. if (d.uri) window.location.href = d.uri;
  816. }
  817. /** Execute a `post` directive as a real (navigating) form POST. */
  818. function doPost(d) {
  819. if (!d.uri) return;
  820. var form = document.createElement('form');
  821. form.method = 'POST';
  822. form.action = d.uri;
  823. form.style.display = 'none';
  824. var data = d.data || {};
  825. Object.keys(data).forEach(function (name) {
  826. var input = document.createElement('input');
  827. input.type = 'hidden';
  828. input.name = name;
  829. input.value = data[name] == null ? '' : String(data[name]);
  830. form.appendChild(input);
  831. });
  832. document.body.appendChild(form);
  833. form.submit();
  834. }
  835. /**
  836. * Handle a fetch response: if it is a Statum response, apply its directives;
  837. * otherwise throw on HTTP errors and no-op on other successful responses.
  838. *
  839. * Directive ordering: non-terminal directives are applied and the page
  840. * rendered; then any `transmit` directives force their slot frames to be
  841. * posted (applying the directives the slot endpoint returns); then, if a
  842. * transmit requested a retry, the originating request (`ctx.retry`) is
  843. * re-issued and this response's terminal directives and errors are skipped.
  844. * Otherwise terminal directives run — form resets/submits, then an awaited
  845. * `reenter` entrypoint refresh (a failure is logged, never propagated), then
  846. * `navigate`/`post` — and finally error directives are surfaced. Because
  847. * the terminal phase is awaited, an action promise resolves only after a
  848. * `reenter` refresh has been applied.
  849. * @param {Response} res
  850. * @param {{originElement?: HTMLElement, retry?: Function}} [ctx]
  851. */
  852. async function handleResponse(res, ctx) {
  853. var el = ctx && ctx.originElement;
  854. if (!isStatum(res)) {
  855. if (!res.ok) await reportFailure(new StatumError('HTTP ' + res.status), el);
  856. return;
  857. }
  858. var directives;
  859. try { directives = await res.json(); } catch (e) { directives = []; }
  860. var result = applyDirectives(directives, ctx);
  861. if (result.transmitKeys && result.transmitKeys.length) {
  862. var frames = collectFramesForKeys(result.transmitKeys);
  863. if (frames.length) {
  864. var txDirectives = await postSlots(frames);
  865. applyDirectives(txDirectives, { originElement: el });
  866. }
  867. }
  868. if (result.retry && ctx && typeof ctx.retry === 'function') {
  869. await ctx.retry();
  870. return;
  871. }
  872. await executeTerminal(result);
  873. var errors = result.errors || [];
  874. for (var i = 0; i < errors.length; i++) {
  875. await reportFailure(errors[i], el);
  876. }
  877. }
  878. // ---------------------------------------------------------------------------
  879. // Entrypoint & action requests
  880. // ---------------------------------------------------------------------------
  881. /**
  882. * Fetch the entrypoint for the current URL, applying its directives. Carries
  883. * slot headers (and pre-flights) like any other request. The request is
  884. * wrapped in an `attempt` closure passed as the response `retry` callback, so
  885. * a `transmit` directive with `retry` can re-issue it after transmission.
  886. * @returns {Promise<void>}
  887. */
  888. function loadEntrypoint() {
  889. // From this point on guards are "post-entrypoint" (all of them evaluate).
  890. entrypointLoaded = true;
  891. var attempt = async function () {
  892. await prepareSlots();
  893. var headers = new Headers();
  894. headers.set('Accept', STATUM_CONTENT_TYPE);
  895. buildSlotHeaders().forEach(function (v) { headers.append(SLOT_HEADER, v); });
  896. var url = config.entrypointUrl + '?uri=' + encodeURIComponent(window.location.href);
  897. var res = await fetch(url, { headers: headers, credentials: 'same-origin' });
  898. await handleResponse(res, { originElement: null, retry: attempt });
  899. };
  900. return attempt();
  901. }
  902. /**
  903. * Perform an action request.
  904. *
  905. * The request is wrapped in an `attempt` closure passed as the response
  906. * `retry` callback, so a `transmit` directive with `retry` can re-issue the
  907. * same action after the transmission (rebuilding headers from current state).
  908. *
  909. * @param {object} action `{ uri, method, private }`
  910. * @param {object} [data] Key/value parameters. Encoded as query string for
  911. * GET/HEAD, otherwise as `application/x-www-form-urlencoded`.
  912. * @param {HTMLElement} [originElement] Element that triggered the action, so
  913. * that `stm-on-error` classes can be applied on an `error` directive.
  914. * @returns {Promise<void>} Resolves once the request completes and its
  915. * directives have run; rejects on HTTP failure or an `error` directive.
  916. */
  917. function performAction(action, data, originElement) {
  918. var attempt = async function () {
  919. await prepareSlots();
  920. var headers = new Headers();
  921. headers.set('Accept', STATUM_CONTENT_TYPE);
  922. buildSlotHeaders().forEach(function (v) { headers.append(SLOT_HEADER, v); });
  923. if (action.private) headers.set(PRIVATE_HEADER, action.private);
  924. var method = (action.method || 'GET').toUpperCase();
  925. var params = data || {};
  926. var init = { method: method, headers: headers, credentials: 'same-origin' };
  927. var url = action.uri;
  928. if (method === 'GET' || method === 'HEAD') {
  929. var qs = new URLSearchParams(params).toString();
  930. if (qs) url += (url.indexOf('?') !== -1 ? '&' : '?') + qs;
  931. } else {
  932. init.body = new URLSearchParams(params).toString();
  933. headers.set('Content-Type', 'application/x-www-form-urlencoded');
  934. }
  935. var res;
  936. try {
  937. res = await fetch(url, init);
  938. } catch (networkErr) {
  939. await reportFailure(new StatumError('Network error'), originElement);
  940. return;
  941. }
  942. await handleResponse(res, { originElement: originElement, retry: attempt });
  943. };
  944. return attempt();
  945. }
  946. // ---------------------------------------------------------------------------
  947. // Realtime channel (subscribe/unsubscribe directives)
  948. // ---------------------------------------------------------------------------
  949. /**
  950. * Per-tab fallback engine, used when `SharedWorker` is unavailable. Owns an
  951. * `EventSource`, reads the channel id from the first `event: channel`,
  952. * reference-counts slot keys within this tab, PATCHes subscription changes,
  953. * applies incoming set/clear events (broadcasting them to siblings), and
  954. * closes the stream once it has no subscriptions.
  955. */
  956. function createFallbackEngine() {
  957. var es = null;
  958. var channelId = null;
  959. var refs = new Map(); // slotKey -> count
  960. var pending = { subscribe: [], unsubscribe: [] };
  961. var patchScheduled = false;
  962. function openStream() {
  963. if (es) return;
  964. try { es = new EventSource(config.channelUrl); }
  965. catch (e) { es = null; return; }
  966. es.addEventListener('channel', function (ev) { channelId = ev.data; flushPatch(); });
  967. es.addEventListener('set', function (ev) {
  968. try { applyRemoteSet(JSON.parse(ev.data)); } catch (e) {}
  969. });
  970. es.addEventListener('clear', function (ev) { applyRemoteClear(ev.data); });
  971. es.onerror = function () { /* EventSource auto-reconnects */ };
  972. }
  973. function closeStream() {
  974. if (es) { try { es.close(); } catch (e) {} es = null; }
  975. channelId = null;
  976. }
  977. function schedulePatch() {
  978. if (patchScheduled) return;
  979. patchScheduled = true;
  980. Promise.resolve().then(function () { patchScheduled = false; flushPatch(); });
  981. }
  982. function flushPatch() {
  983. if (!channelId) return;
  984. var sub = pending.subscribe, uns = pending.unsubscribe;
  985. if (!sub.length && !uns.length) return;
  986. pending = { subscribe: [], unsubscribe: [] };
  987. var body = {};
  988. if (sub.length) body.subscribe = sub;
  989. if (uns.length) body.unsubscribe = uns;
  990. try {
  991. fetch(config.channelUrl + '/' + encodeURIComponent(channelId), {
  992. method: 'PATCH',
  993. headers: { 'Content-Type': 'application/json' },
  994. credentials: 'same-origin',
  995. body: JSON.stringify(body)
  996. }).catch(function () {});
  997. } catch (e) {}
  998. }
  999. return {
  1000. subscribe: function (key) {
  1001. var c = refs.get(key) || 0;
  1002. refs.set(key, c + 1);
  1003. if (c === 0) { openStream(); pending.subscribe.push(key); schedulePatch(); }
  1004. },
  1005. unsubscribe: function (key) {
  1006. var c = refs.get(key) || 0;
  1007. if (c <= 0) return;
  1008. if (c - 1 <= 0) {
  1009. refs.delete(key);
  1010. pending.unsubscribe.push(key);
  1011. schedulePatch();
  1012. if (refs.size === 0) setTimeout(function () { if (refs.size === 0) closeStream(); }, 0);
  1013. } else {
  1014. refs.set(key, c - 1);
  1015. }
  1016. }
  1017. };
  1018. }
  1019. /**
  1020. * Channel manager. Prefers a `SharedWorker` (which owns the single SSE
  1021. * connection, reference-counts subscriptions across tabs, and relays events
  1022. * through the cross-tab `BroadcastChannel`); falls back to a per-tab
  1023. * `EventSource` engine when `SharedWorker` is unavailable or the worker
  1024. * script fails to load. Tracks the true per-slot subscription count so it can
  1025. * replay active subscriptions into the fallback on a mid-session switch.
  1026. */
  1027. function createChannelManager() {
  1028. var port = null;
  1029. var workerObj = null;
  1030. var fallback = null;
  1031. var mode = null; // 'worker' | 'fallback' | null
  1032. var counts = new Map(); // slotKey -> count (true subscription state)
  1033. function switchToFallback() {
  1034. if (mode === 'fallback') return;
  1035. mode = 'fallback';
  1036. port = null;
  1037. workerObj = null;
  1038. fallback = createFallbackEngine();
  1039. counts.forEach(function (c, key) {
  1040. for (var i = 0; i < c; i++) fallback.subscribe(key);
  1041. });
  1042. }
  1043. function ensure() {
  1044. if (mode !== null) return;
  1045. if (typeof SharedWorker !== 'undefined') {
  1046. try {
  1047. workerObj = new SharedWorker(config.workerUrl);
  1048. port = workerObj.port;
  1049. port.start();
  1050. port.postMessage({ op: 'init', url: config.channelUrl });
  1051. // The worker delivers events via the BroadcastChannel, so nothing is
  1052. // required on this port beyond command messages.
  1053. port.onmessage = function () {};
  1054. workerObj.onerror = function () {
  1055. console.warn('[statum] realtime worker failed; falling back to per-tab channel');
  1056. switchToFallback();
  1057. };
  1058. window.addEventListener('pagehide', function () {
  1059. if (mode === 'worker' && port) {
  1060. try { port.postMessage({ op: 'disconnect' }); } catch (e) {}
  1061. }
  1062. });
  1063. mode = 'worker';
  1064. return;
  1065. } catch (e) { workerObj = null; port = null; }
  1066. }
  1067. switchToFallback();
  1068. }
  1069. return {
  1070. subscribe: function (key) {
  1071. if (key == null) return;
  1072. ensure();
  1073. counts.set(key, (counts.get(key) || 0) + 1);
  1074. if (mode === 'worker') { try { port.postMessage({ op: 'subscribe', key: key }); } catch (e) {} }
  1075. else fallback.subscribe(key);
  1076. },
  1077. unsubscribe: function (key) {
  1078. if (key == null) return;
  1079. var c = (counts.get(key) || 0) - 1;
  1080. if (c <= 0) counts.delete(key); else counts.set(key, c);
  1081. ensure();
  1082. if (mode === 'worker') { try { port.postMessage({ op: 'unsubscribe', key: key }); } catch (e) {} }
  1083. else fallback.unsubscribe(key);
  1084. }
  1085. };
  1086. }
  1087. var channelManager = createChannelManager();
  1088. function channelSubscribe(key) { channelManager.subscribe(key); }
  1089. function channelUnsubscribe(key) { channelManager.unsubscribe(key); }
  1090. // ---------------------------------------------------------------------------
  1091. // Document seam: the hooks statum-document.js installs
  1092. // ---------------------------------------------------------------------------
  1093. //
  1094. // Core never touches the DOM, so everything visual — the boot visibility
  1095. // toggle, the render walk, the guard evaluation, the error-state classes —
  1096. // reaches the DOM through these hooks, defaulting to no-ops. With
  1097. // statum-document.js loaded every hook is real; without it core still boots,
  1098. // fetches the entrypoint and applies directives to state (headless use).
  1099. /** @type {Function} stm-preloader / stm-content visibility; no-op headless. */
  1100. var setBootState = function () {};
  1101. /** @type {Function} The render walk driver `fn(scope)`; absent headless. */
  1102. var renderDriver = null;
  1103. /**
  1104. * @type {Function} Post-walk cycle hook `fn()`: stm-guard evaluation. Runs
  1105. * after the afterEvaluate hooks, exactly where evaluateGuards used to.
  1106. */
  1107. var afterRenderCycle = function () {};
  1108. /**
  1109. * @type {Function} `fn(el, on)`: apply or clear the stm-on-error /
  1110. * stm-on-success classes on an action element. No-op headless.
  1111. */
  1112. var applyErrorClasses = function () {};
  1113. // ---------------------------------------------------------------------------
  1114. // Evaluation hooks (no-arg callbacks before/after each render cycle)
  1115. // ---------------------------------------------------------------------------
  1116. var beforeHooks = [];
  1117. var afterHooks = [];
  1118. /**
  1119. * Run one render cycle: the beforeEvaluate hooks, the render walk (when a
  1120. * document module has installed its driver), the afterEvaluate hooks, then
  1121. * the document-side cycle hook (guard evaluation). Headless (no driver), the
  1122. * cycle still runs the hooks — they observe state changes, not DOM.
  1123. */
  1124. function renderAll() {
  1125. beforeHooks.forEach(function (fn) { try { fn(); } catch (e) { console.error('[statum] beforeEvaluate hook error', e); } });
  1126. try {
  1127. // The DOM walk lives in statum-document.js and is installed through
  1128. // the seam; without it the cycle still runs hooks and the cycle hook.
  1129. if (renderDriver) renderDriver(stateScope());
  1130. }
  1131. catch (e) { console.error('[statum] render error', e); }
  1132. afterHooks.forEach(function (fn) { try { fn(); } catch (e) { console.error('[statum] afterEvaluate hook error', e); } });
  1133. try {
  1134. afterRenderCycle();
  1135. }
  1136. catch (e) { console.error('[statum] render cycle hook error', e); }
  1137. }
  1138. // ---------------------------------------------------------------------------
  1139. // Public state accessors
  1140. // ---------------------------------------------------------------------------
  1141. /** Build the scope used for expression evaluation: `{ typeName: public }`. */
  1142. function stateScope() {
  1143. var scope = {};
  1144. store.forEach(function (entry, type) { scope[type] = entry.snapshot.public; });
  1145. return scope;
  1146. }
  1147. /** Read a type's `public` data (what the attributes bind to). */
  1148. function read(type) {
  1149. var entry = store.get(type);
  1150. return entry ? entry.snapshot.public : undefined;
  1151. }
  1152. /** Read a type's full snapshot object. */
  1153. function getSnapshot(type) {
  1154. var entry = store.get(type);
  1155. return entry ? entry.snapshot : undefined;
  1156. }
  1157. /** Read the frame wrapping a type's snapshot. */
  1158. function getFrame(type) {
  1159. var entry = store.get(type);
  1160. return entry ? entry.frame : undefined;
  1161. }
  1162. /** Build an object of `{ typeName: public }` for every held type. */
  1163. function state() {
  1164. var result = {};
  1165. store.forEach(function (entry, type) { result[type] = entry.snapshot.public; });
  1166. return result;
  1167. }
  1168. /**
  1169. * Clear state. With a type argument, clears that type only; without, clears
  1170. * all held state.
  1171. * @param {string} [type]
  1172. */
  1173. function clear(type) {
  1174. if (type === undefined) {
  1175. Array.from(store.keys()).forEach(function (t) { clearType(t, { broadcast: true }); });
  1176. } else {
  1177. clearType(type, { broadcast: true });
  1178. }
  1179. renderAll();
  1180. }
  1181. /**
  1182. * Perform an action. `action` may be an action object or a string path that
  1183. * resolves to an action object within the current state.
  1184. * @param {object|string} action
  1185. * @param {object} [data]
  1186. * @returns {Promise<void>}
  1187. */
  1188. function act(action, data) {
  1189. var resolved = action;
  1190. if (typeof action === 'string') resolved = evalExpr(action, stateScope());
  1191. if (!resolved || typeof resolved.uri !== 'string') {
  1192. return Promise.reject(new StatumError('Invalid action'));
  1193. }
  1194. return performAction(resolved, data, null);
  1195. }
  1196. /** Attach a state listener for a type. */
  1197. function addStateListener(type, callback) {
  1198. if (typeof callback !== 'function') return;
  1199. if (!listeners.has(type)) listeners.set(type, new Set());
  1200. listeners.get(type).add(callback);
  1201. }
  1202. /** Remove a previously-attached state listener. */
  1203. function removeStateListener(type, callback) {
  1204. var set = listeners.get(type);
  1205. if (!set) return;
  1206. set.delete(callback);
  1207. if (set.size === 0) listeners.delete(type);
  1208. }
  1209. // ---------------------------------------------------------------------------
  1210. // Initialization
  1211. // ---------------------------------------------------------------------------
  1212. /**
  1213. * Initialize Statum: run the session-cookie check, hydrate persisted state,
  1214. * open the cross-tab channel, fetch the entrypoint, and run render cycles.
  1215. * Safe to call multiple times.
  1216. *
  1217. * Headless by design: endpoint config comes from `statum.config` (pages —
  1218. * or statum-document.js, reading the `stm-*` body attributes — write it
  1219. * before init runs), and the boot-state toggle is the document hook. Nothing
  1220. * here scans the DOM.
  1221. * @returns {Promise<void>}
  1222. */
  1223. async function init() {
  1224. if (initialized) return;
  1225. initialized = true;
  1226. try { initSession(); } catch (e) {}
  1227. try { hydrate(); } catch (e) {}
  1228. setupChannel();
  1229. setBootState(false);
  1230. renderAll(); // bind against any persisted/hydrated state immediately
  1231. try {
  1232. await loadEntrypoint();
  1233. } catch (err) {
  1234. console.error('[statum] entrypoint load failed', err);
  1235. } finally {
  1236. setBootState(true);
  1237. }
  1238. }
  1239. function autoInit() {
  1240. var ready = 'loading';
  1241. try { ready = document.readyState; } catch (e) {}
  1242. if (ready === 'loading') {
  1243. try {
  1244. document.addEventListener('DOMContentLoaded', init);
  1245. } catch (e) {
  1246. init();
  1247. }
  1248. } else {
  1249. init();
  1250. }
  1251. }
  1252. // ---------------------------------------------------------------------------
  1253. // Public API
  1254. // ---------------------------------------------------------------------------
  1255. /** @namespace statum */
  1256. window.statum = {
  1257. /** Library version. */
  1258. version: '0.1.0',
  1259. /** Mutable endpoint configuration. */
  1260. config: config,
  1261. /** (Re)initialize the library. Usually runs automatically on load. */
  1262. init: init,
  1263. /** Read a type's `public` data. */
  1264. read: read,
  1265. /** Read a type's full snapshot object. */
  1266. getSnapshot: getSnapshot,
  1267. /** Read the frame wrapping a type's snapshot. */
  1268. getFrame: getFrame,
  1269. /** Build `{ typeName: public }` for every held type. */
  1270. state: state,
  1271. /** Clear one type (`clear("x")`) or all state (`clear()`). */
  1272. clear: clear,
  1273. /** Perform an action object or named action. */
  1274. act: act,
  1275. /** Attach a state-changed listener for a type. */
  1276. addStateListener: addStateListener,
  1277. /** Remove a previously-attached listener. */
  1278. removeStateListener: removeStateListener,
  1279. /** Register a callback that runs before each render cycle (no arguments). */
  1280. beforeEvaluate: function (fn) { if (typeof fn === 'function') beforeHooks.push(fn); },
  1281. /** Register a callback that runs after each render cycle (no arguments). */
  1282. afterEvaluate: function (fn) { if (typeof fn === 'function') afterHooks.push(fn); },
  1283. /** The Statum error type (thrown by `act` and surfaced to handlers). */
  1284. Error: StatumError,
  1285. get onConfirmHandler() { return handlers.onConfirm; },
  1286. set onConfirmHandler(v) { handlers.onConfirm = typeof v === 'function' ? v : null; },
  1287. get onNotifyHandler() { return handlers.onNotify; },
  1288. set onNotifyHandler(v) { handlers.onNotify = typeof v === 'function' ? v : null; },
  1289. get onErrorHandler() { return handlers.onError; },
  1290. set onErrorHandler(v) { handlers.onError = typeof v === 'function' ? v : null; }
  1291. };
  1292. // The seam must be exposed before any other module loads. autoInit only
  1293. // ever runs on DOMContentLoaded (or immediately at load); both happen
  1294. // outside this script tag, so the later module tags always see the bridge.
  1295. window.statum._bridge = {
  1296. /** Expression machinery (core-owned). */
  1297. evalExpr: evalExpr,
  1298. childScope: childScope,
  1299. stateScope: stateScope,
  1300. /** Whether the entrypoint has been fetched (guards gate on it). */
  1301. entrypointLoaded: function () { return entrypointLoaded; },
  1302. /** Render cycle + requests. */
  1303. renderAll: renderAll,
  1304. loadEntrypoint: loadEntrypoint,
  1305. performAction: performAction,
  1306. /** UI handler overrides (runElementAction gates on confirm). */
  1307. triggerConfirm: triggerConfirm,
  1308. triggerNotify: triggerNotify,
  1309. triggerError: triggerError,
  1310. handlers: handlers,
  1311. /**
  1312. * Attribute-handler registration. The registry itself lives in
  1313. * statum-document.js (it is DOM machinery: the walk iterates it); until
  1314. * that module loads, registrations are QUEUED here so a module can load
  1315. * against core alone (tests load core+markdown, for instance). Document
  1316. * drains the queue through the real registry when it installs it.
  1317. */
  1318. registerAttribute: function (name, fn, opts) {
  1319. pendingRegistrations.push([name, fn, opts]);
  1320. },
  1321. /** Take (and clear) the registrations queued before document loaded. */
  1322. takePendingRegistrations: function () {
  1323. var pending = pendingRegistrations;
  1324. pendingRegistrations = [];
  1325. return pending;
  1326. },
  1327. /**
  1328. * Hooks statum-document.js installs (core defaults the first to a no-op
  1329. * and the rest to "absent"; the setters keep the names stable for that
  1330. * module's installer).
  1331. */
  1332. setBootState: function (fn) { if (typeof fn === 'function') setBootState = fn; },
  1333. renderDriver: function (fn) { if (typeof fn === 'function') renderDriver = fn; },
  1334. afterRenderCycle: function (fn) { if (typeof fn === 'function') afterRenderCycle = fn; },
  1335. applyErrorClasses: function (fn) { if (typeof fn === 'function') applyErrorClasses = fn; }
  1336. };
  1337. /** @type {Array<[string, Function, object|undefined]>} */
  1338. var pendingRegistrations = [];
  1339. autoInit();
  1340. })();