statum.js 61 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706
  1. /*!
  2. * Statum - client-side state library.
  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 binds them to the DOM
  7. * through `stm-*` attributes. See model.md for the full specification.
  8. *
  9. * Distribution: a single IIFE that attaches `window.statum` (no dependencies,
  10. * no build step). Targets modern evergreen browsers (uses fetch,
  11. * BroadcastChannel, queueMicrotask, URLSearchParams, FormData).
  12. */
  13. (function () {
  14. 'use strict';
  15. /** Content-Type used by every Statum directive response. */
  16. var STATUM_CONTENT_TYPE = 'application/vnd.statum+json';
  17. /** Request/response header carrying one slot's reference (or base64 frame). */
  18. var SLOT_HEADER = 'X-Statum-Slot';
  19. /** Request header carrying an action's opaque `private` blob. */
  20. var PRIVATE_HEADER = 'X-Statum-Private';
  21. /** Session-cookie marker used to detect a fresh browser session. */
  22. var SESSION_COOKIE = '_statum_sdc';
  23. /** BroadcastChannel name used to sync session/device state across tabs. */
  24. var CHANNEL_NAME = 'statum';
  25. /** localStorage / sessionStorage key prefix for persisted frames. */
  26. var STORAGE_PREFIX = 'statum:';
  27. // ---------------------------------------------------------------------------
  28. // Configuration
  29. // ---------------------------------------------------------------------------
  30. /**
  31. * Endpoint URLs. These defaults may be overridden per-page with the
  32. * `stm-entrypoint` and `stm-slots` attributes on the `<body>` element.
  33. *
  34. * @type {{entrypointUrl: string, slotsUrl: string}}
  35. */
  36. var config = {
  37. entrypointUrl: '/_statum/entrypoint',
  38. slotsUrl: '/_statum/slots',
  39. channelUrl: '/_statum/channel',
  40. // The realtime worker is embedded in the library as a default resource
  41. // (served at /_statum/resource/statum-worker.js); override with stm-worker.
  42. workerUrl: '/_statum/resource/statum-worker.js'
  43. };
  44. // ---------------------------------------------------------------------------
  45. // State store
  46. // ---------------------------------------------------------------------------
  47. /**
  48. * In-memory store, keyed by slot type. Each value is `{frame, snapshot}`
  49. * where `snapshot` is the parsed `frame.content`. This map is the single
  50. * source of truth for rendering; persistent backends merely hydrate it.
  51. *
  52. * @type {Map<string, {frame: object, snapshot: object}>}
  53. */
  54. var store = new Map();
  55. /** Listeners, keyed by type name. @type {Map<string, Set<Function>>} */
  56. var listeners = new Map();
  57. /**
  58. * Idempotency tracking: slot key -> last-applied frame `signature`. Held in
  59. * memory only (per tab), so a frame already persisted by another tab still
  60. * re-draws a tab whose tracking lags. Entries are dropped on clear so a
  61. * replayed frame re-applies.
  62. * @type {Map<string, string>}
  63. */
  64. var signatures = new Map();
  65. /** Optional UI handlers (set via `statum.on*Handler`). */
  66. var handlers = { onConfirm: null, onNotify: null, onError: null };
  67. /** @type {BroadcastChannel|null} */
  68. var channel = null;
  69. /** Whether {@link init} has already run. */
  70. var initialized = false;
  71. // ---------------------------------------------------------------------------
  72. // Small utilities
  73. // ---------------------------------------------------------------------------
  74. /**
  75. * Parse an ISO 8601 timestamp to epoch millis, or `undefined`.
  76. * @param {string|undefined} s
  77. * @returns {number|undefined}
  78. */
  79. function parseTime(s) {
  80. if (!s) return undefined;
  81. var t = new Date(s).getTime();
  82. return isNaN(t) ? undefined : t;
  83. }
  84. /**
  85. * Base64-encode a UTF-8 string (safe for frames containing non-Latin1 data).
  86. * @param {string} str
  87. * @returns {string}
  88. */
  89. function b64encode(str) {
  90. var bytes = new TextEncoder().encode(str);
  91. var bin = '';
  92. var chunk = 0x8000;
  93. for (var i = 0; i < bytes.length; i += chunk) {
  94. bin += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk));
  95. }
  96. return btoa(bin);
  97. }
  98. /** Read a cookie value, or `null` if absent. @param {string} name */
  99. function getCookie(name) {
  100. var escaped = name.replace(/([.$?*|{}()[\]\\/+^])/g, '\\$1');
  101. var match = document.cookie.match(new RegExp('(?:^|; )' + escaped + '=([^;]*)'));
  102. return match ? decodeURIComponent(match[1]) : null;
  103. }
  104. /** Set a session cookie (no expiry => cleared when the browser closes). */
  105. function setCookie(name, value) {
  106. document.cookie = name + '=' + encodeURIComponent(value) + '; path=/; SameSite=Lax';
  107. }
  108. // ---------------------------------------------------------------------------
  109. // Expression evaluation
  110. // ---------------------------------------------------------------------------
  111. /**
  112. * Compiled-expression cache. Expressions are evaluated as JavaScript against
  113. * a scope object (the page's HTML is trusted, per the model).
  114. * @type {Map<string, Function>}
  115. */
  116. var exprCache = new Map();
  117. /**
  118. * Compile (and cache) an expression string into a function of `scope`.
  119. * Uses `with` so that arbitrary type names and loop variables are resolved
  120. * as bare identifiers. Returns a function that yields `undefined` on any
  121. * runtime or compile error.
  122. * @param {string} expr
  123. * @returns {Function}
  124. */
  125. function compileExpr(expr) {
  126. var cached = exprCache.get(expr);
  127. if (cached !== undefined) return cached;
  128. var fn;
  129. try {
  130. // Function-constructor bodies are non-strict by default, so `with` is
  131. // permitted. The inner try/catch turns reference errors into `undefined`
  132. // so a missing path simply yields no value.
  133. fn = new Function('scope', 'with(scope){try{return (' + expr + ');}catch(e){return undefined;}}');
  134. } catch (e) {
  135. fn = function () { return undefined; };
  136. }
  137. exprCache.set(expr, fn);
  138. return fn;
  139. }
  140. /**
  141. * Evaluate an expression against a scope.
  142. * @param {string} expr
  143. * @param {object} [scope]
  144. * @returns {*} `undefined` if the expression is empty or fails.
  145. */
  146. function evalExpr(expr, scope) {
  147. if (expr == null || expr === '') return undefined;
  148. try {
  149. return compileExpr(expr)(scope || {});
  150. } catch (e) {
  151. return undefined;
  152. }
  153. }
  154. /**
  155. * Build a child scope that inherits the parent (so type bindings remain
  156. * visible) and adds one own loop variable.
  157. * @param {object} parent
  158. * @param {string} name
  159. * @param {*} value
  160. * @returns {object}
  161. */
  162. function childScope(parent, name, value) {
  163. var scope = Object.create(parent || null);
  164. scope[name] = value;
  165. return scope;
  166. }
  167. // ---------------------------------------------------------------------------
  168. // StatumError
  169. // ---------------------------------------------------------------------------
  170. /**
  171. * Error type thrown for HTTP failures and `error` directives.
  172. * @param {string} [message]
  173. * @param {string} [code]
  174. * @param {object} [data]
  175. */
  176. function StatumError(message, code, data) {
  177. this.name = 'StatumError';
  178. this.message = message || '';
  179. this.code = code || undefined;
  180. this.data = data || undefined;
  181. }
  182. StatumError.prototype = Object.create(Error.prototype);
  183. // ---------------------------------------------------------------------------
  184. // UI handlers (confirm / notify / error)
  185. // ---------------------------------------------------------------------------
  186. /**
  187. * Run the `notify` directive's default behaviour or the custom
  188. * `statum.onNotifyHandler`. The default surfaces the message via `alert()`.
  189. * @param {string} [kind] Application-defined category (freeform).
  190. * @param {string} [message]
  191. */
  192. function triggerNotify(kind, message) {
  193. var fn = handlers.onNotify;
  194. try {
  195. if (fn) fn(kind, message);
  196. else if (message != null) window.alert(message);
  197. } catch (e) { console.error('[statum] notify handler error', e); }
  198. }
  199. /**
  200. * Run the `stm-confirm` gate: the custom `statum.onConfirmHandler` if set,
  201. * otherwise `window.confirm`. Supports async handlers (custom modals).
  202. * @param {string} message
  203. * @param {HTMLElement} element
  204. * @returns {Promise<boolean>} truthy if the action should proceed.
  205. */
  206. async function triggerConfirm(message, element) {
  207. var fn = handlers.onConfirm;
  208. try {
  209. if (fn) return !!(await fn(message, element));
  210. return !!window.confirm(message);
  211. } catch (e) {
  212. console.error('[statum] confirm handler error', e);
  213. return false;
  214. }
  215. }
  216. /**
  217. * Run the custom `statum.onErrorHandler`, if set. Returns whether the default
  218. * behaviour (promise rejection + `stm-on-error` classes) should proceed: the
  219. * handler suppresses the default by returning (or resolving) `false`.
  220. * @param {StatumError} error
  221. * @param {HTMLElement|null} element
  222. * @returns {Promise<boolean>}
  223. */
  224. async function triggerError(error, element) {
  225. var fn = handlers.onError;
  226. if (!fn) return true;
  227. try {
  228. var suppress = await fn(error, element);
  229. return suppress !== false;
  230. } catch (e) {
  231. console.error('[statum] error handler error', e);
  232. return true;
  233. }
  234. }
  235. /**
  236. * Surface a failure: run `triggerError`, and if the default is not
  237. * suppressed, apply `stm-on-error` classes (when an origin element exists)
  238. * and re-throw so the originating promise rejects.
  239. * @param {StatumError} error
  240. * @param {HTMLElement|null} element
  241. */
  242. async function reportFailure(error, element) {
  243. if (await triggerError(error, element)) {
  244. if (element) applyErrorClasses(element, true);
  245. throw error;
  246. }
  247. }
  248. // ---------------------------------------------------------------------------
  249. // Persistence
  250. // ---------------------------------------------------------------------------
  251. /**
  252. * Pick the storage backend for a scope.
  253. * @param {string} scope
  254. * @returns {Storage|null} `null` for in-memory scopes.
  255. */
  256. function storageFor(scope) {
  257. if (scope === 'device' || scope === 'session') return localStorage;
  258. if (scope === 'flow') return sessionStorage;
  259. return null; // page / transient
  260. }
  261. /**
  262. * Persist a slot's frame to the backend matching its scope, removing any
  263. * stale copy from the other backend first (in case the scope changed).
  264. * @param {string} type
  265. * @param {{frame: object, snapshot: object}} entry
  266. */
  267. function persistEntry(type, entry) {
  268. var scope = entry.snapshot.slot && entry.snapshot.slot.scope;
  269. removePersisted(type);
  270. var storage = storageFor(scope);
  271. if (!storage) return;
  272. try {
  273. storage.setItem(STORAGE_PREFIX + type, JSON.stringify(entry.frame));
  274. } catch (e) {
  275. /* storage full or unavailable; remain in-memory only */
  276. }
  277. }
  278. /** Remove a persisted frame from both backends. @param {string} type */
  279. function removePersisted(type) {
  280. try { localStorage.removeItem(STORAGE_PREFIX + type); } catch (e) {}
  281. try { sessionStorage.removeItem(STORAGE_PREFIX + type); } catch (e) {}
  282. }
  283. /** Load every persisted frame from a backend into the in-memory store. */
  284. function loadFrom(storage) {
  285. for (var i = 0; i < storage.length; i++) {
  286. var key = storage.key(i);
  287. if (!key || key.indexOf(STORAGE_PREFIX) !== 0) continue;
  288. try {
  289. var frame = JSON.parse(storage.getItem(key));
  290. var snapshot = JSON.parse(frame.content);
  291. var type = snapshot.slot && snapshot.slot.type;
  292. if (type && !store.has(type)) store.set(type, { frame: frame, snapshot: snapshot });
  293. } catch (e) {
  294. /* corrupt entry: ignore */
  295. }
  296. }
  297. }
  298. /** Hydrate the store from localStorage and sessionStorage. */
  299. function hydrate() {
  300. try { loadFrom(localStorage); } catch (e) {}
  301. try { loadFrom(sessionStorage); } catch (e) {}
  302. }
  303. // ---------------------------------------------------------------------------
  304. // Session-cookie initialization
  305. // ---------------------------------------------------------------------------
  306. /**
  307. * Detect a fresh browser session using the `_statum_sdc` session cookie.
  308. * On a fresh session, clear all `session`-scoped persisted frames, then set
  309. * the marker. Session cookies are shared across tabs, so a tab opened
  310. * mid-session sees the marker and leaves session data intact.
  311. */
  312. function initSession() {
  313. if (getCookie(SESSION_COOKIE) !== null) return;
  314. clearSessionSlotsFromStorage();
  315. setCookie(SESSION_COOKIE, '1');
  316. }
  317. /** Remove all `session`-scoped frames from localStorage. */
  318. function clearSessionSlotsFromStorage() {
  319. var toRemove = [];
  320. for (var i = 0; i < localStorage.length; i++) {
  321. var key = localStorage.key(i);
  322. if (!key || key.indexOf(STORAGE_PREFIX) !== 0) continue;
  323. try {
  324. var frame = JSON.parse(localStorage.getItem(key));
  325. var snapshot = JSON.parse(frame.content);
  326. if (snapshot.slot && snapshot.slot.scope === 'session') toRemove.push(key);
  327. } catch (e) {}
  328. }
  329. for (var j = 0; j < toRemove.length; j++) localStorage.removeItem(toRemove[j]);
  330. }
  331. // ---------------------------------------------------------------------------
  332. // Listeners & cross-tab broadcast
  333. // ---------------------------------------------------------------------------
  334. /**
  335. * Fire all listeners for a type. (Renamed from `notify` to avoid confusion
  336. * with the `notify` directive.)
  337. * @param {string} type
  338. * @param {object|null} frame
  339. * @param {object|null} snapshot
  340. */
  341. function fireListeners(type, frame, snapshot) {
  342. var set = listeners.get(type);
  343. if (!set) return;
  344. var event = { type: type, frame: frame, snapshot: snapshot };
  345. set.forEach(function (cb) {
  346. try { cb(event); } catch (e) { console.error('[statum] listener error', e); }
  347. });
  348. }
  349. /**
  350. * Broadcast a state change to other tabs. Only `session`/`device` scopes are
  351. * shared (flow/page/transient are per-tab).
  352. * @param {object} message
  353. * @param {string} [scope]
  354. */
  355. function broadcast(message, scope) {
  356. if (!channel) return;
  357. if (scope !== 'session' && scope !== 'device') return;
  358. try { channel.postMessage(message); } catch (e) {}
  359. }
  360. /**
  361. * Broadcast a message to other tabs regardless of slot scope. Used to relay
  362. * realtime (live) events so they reach every tab, not just session/device
  363. * ones.
  364. */
  365. function broadcastAlways(message) {
  366. if (!channel) return;
  367. try { channel.postMessage(message); } catch (e) {}
  368. }
  369. /** Open the BroadcastChannel and wire inbound messages. */
  370. function setupChannel() {
  371. if (typeof BroadcastChannel === 'undefined') return;
  372. try {
  373. channel = new BroadcastChannel(CHANNEL_NAME);
  374. } catch (e) {
  375. channel = null;
  376. return;
  377. }
  378. channel.onmessage = function (ev) {
  379. var msg = ev.data;
  380. if (!msg) return;
  381. if (msg.kind === 'set' && msg.frame) {
  382. try {
  383. var snapshot = JSON.parse(msg.frame.content);
  384. var type = snapshot.slot && snapshot.slot.type;
  385. if (type && commitSet(msg.frame, snapshot, type, false)) renderAll();
  386. } catch (e) { /* ignore malformed */ }
  387. } else if (msg.kind === 'clear') {
  388. var t = msg.type;
  389. if (!t && msg.key != null) {
  390. store.forEach(function (entry, type) {
  391. if (entry.snapshot.slot && entry.snapshot.slot.key === msg.key) t = type;
  392. });
  393. }
  394. if (t) clearType(t, { broadcast: false });
  395. else if (msg.key != null) signatures.delete(msg.key);
  396. renderAll();
  397. }
  398. };
  399. }
  400. // ---------------------------------------------------------------------------
  401. // State mutation
  402. // ---------------------------------------------------------------------------
  403. /**
  404. * Remove a type from the store, un-persist it, drop its idempotency entry,
  405. * fire listeners, and (optionally) broadcast the clear to other tabs.
  406. * @param {string} type
  407. * @param {{broadcast?: boolean}} [opts]
  408. */
  409. function clearType(type, opts) {
  410. var entry = store.get(type);
  411. if (!entry) return;
  412. var scope = entry.snapshot.slot && entry.snapshot.slot.scope;
  413. var key = entry.snapshot.slot && entry.snapshot.slot.key;
  414. store.delete(type);
  415. removePersisted(type);
  416. if (key) signatures.delete(key);
  417. fireListeners(type, null, null);
  418. if (opts && opts.broadcast) broadcast({ kind: 'clear', key: key, type: type }, scope);
  419. }
  420. /**
  421. * Resolve a slot key to its type and clear it. Used by the `clear` directive
  422. * (keyed) and by live `clear` events.
  423. * @param {string} key
  424. * @param {boolean} [broadcast]
  425. */
  426. function clearByKey(key, broadcast) {
  427. var typeToRemove = null;
  428. store.forEach(function (entry, type) {
  429. if (entry.snapshot.slot && entry.snapshot.slot.key === key) typeToRemove = type;
  430. });
  431. if (typeToRemove) clearType(typeToRemove, { broadcast: !!broadcast });
  432. else if (key) signatures.delete(key);
  433. }
  434. /**
  435. * Resolve a frame signature to its slot type and clear it. Used by the
  436. * `invalidate` directive, which references a signature (not a key) because the
  437. * server reports a bad signature without trusting the frame's content.
  438. * @param {string} signature
  439. * @param {boolean} [broadcast]
  440. */
  441. function clearBySignature(signature, broadcast) {
  442. var typeToRemove = null;
  443. store.forEach(function (entry, type) {
  444. if (entry.frame && entry.frame.signature === signature) typeToRemove = type;
  445. });
  446. if (typeToRemove) clearType(typeToRemove, { broadcast: !!broadcast });
  447. else if (signature) signatures.delete(signature);
  448. }
  449. /**
  450. * Commit a `set` to the in-memory store, persist it, and fire listeners.
  451. * Idempotent: if the frame's `signature` matches the last-applied signature
  452. * for this slot (tracked in {@link signatures}), nothing happens and `false`
  453. * is returned. When `doBroadcast` is true the set is forwarded to other tabs
  454. * via the scope-gated broadcast (session/device only).
  455. * @param {object} frame
  456. * @param {object} snapshot
  457. * @param {string} type
  458. * @param {boolean} doBroadcast
  459. * @returns {boolean} `true` if state changed.
  460. */
  461. function commitSet(frame, snapshot, type, doBroadcast) {
  462. var key = snapshot.slot && snapshot.slot.key;
  463. var sig = frame.signature;
  464. if (key && sig !== undefined && signatures.get(key) === sig) return false;
  465. var entry = { frame: frame, snapshot: snapshot };
  466. store.set(type, entry);
  467. persistEntry(type, entry);
  468. fireListeners(type, frame, snapshot);
  469. if (key && sig !== undefined) signatures.set(key, sig);
  470. if (doBroadcast) {
  471. var scope = snapshot.slot && snapshot.slot.scope;
  472. broadcast({ kind: 'set', frame: frame }, scope);
  473. }
  474. return true;
  475. }
  476. /**
  477. * Apply a `set` directive: store/refresh a slot's snapshot (idempotently),
  478. * broadcasting to other tabs.
  479. * @param {object} directive `{ snapshot_frame }`
  480. */
  481. function applySet(directive) {
  482. var frame = directive.snapshot_frame;
  483. if (!frame || !frame.content) return;
  484. var snapshot;
  485. try { snapshot = JSON.parse(frame.content); } catch (e) { return; }
  486. var type = snapshot.slot && snapshot.slot.type;
  487. if (!type) return;
  488. commitSet(frame, snapshot, type, true);
  489. }
  490. /**
  491. * Apply a `clear` directive. The directive carries a slot key, so the type
  492. * holding that key is resolved and removed.
  493. * @param {object} directive `{ key }`
  494. */
  495. function applyClear(directive) {
  496. if (directive.key == null) return;
  497. clearByKey(directive.key, true);
  498. }
  499. /**
  500. * Apply a live `set` event received over the realtime channel. The frame is
  501. * committed (idempotently) and, when new, broadcast to sibling tabs via the
  502. * scope-agnostic {@link broadcastAlways} so every tab sees it regardless of
  503. * which (or how many) tabs hold a subscription.
  504. * @param {object} frame
  505. */
  506. function applyRemoteSet(frame) {
  507. try {
  508. var snapshot = JSON.parse(frame.content);
  509. var type = snapshot.slot && snapshot.slot.type;
  510. if (!type) return;
  511. if (commitSet(frame, snapshot, type, false)) {
  512. broadcastAlways({ kind: 'set', frame: frame });
  513. renderAll();
  514. }
  515. } catch (e) { /* malformed frame */ }
  516. }
  517. /** Apply a live `clear` event received over the realtime channel. */
  518. function applyRemoteClear(key) {
  519. if (key == null) return;
  520. signatures.delete(key);
  521. clearByKey(key, false);
  522. broadcastAlways({ kind: 'clear', key: key });
  523. renderAll();
  524. }
  525. // ---------------------------------------------------------------------------
  526. // Snapshot freshness & request headers
  527. // ---------------------------------------------------------------------------
  528. /**
  529. * Categorize a held snapshot relative to the current time.
  530. * @returns {'silent'|'always'|'invalid'|'stale'|'fresh'}
  531. * - `silent`: unidirectional (`transmit_after` undefined) - never sent.
  532. * - `always`: `transmit_after === as_at` - send the base64 frame each time.
  533. * - `invalid`: past `invalid_after` - drop and refetch.
  534. * - `stale`: past `transmit_after` but not yet invalid - pre-flight first.
  535. * - `fresh`: still cached server-side - send the `as_at` reference.
  536. */
  537. function entryCategory(entry) {
  538. var snap = entry.snapshot;
  539. var transmit = snap.transmit_after;
  540. if (transmit === undefined || transmit === null) return 'silent';
  541. if (transmit === snap.as_at) return 'always';
  542. var now = Date.now();
  543. var invalidAt = parseTime(snap.invalid_after);
  544. var transmitAt = parseTime(transmit);
  545. if (invalidAt !== undefined && now >= invalidAt) return 'invalid';
  546. if (transmitAt !== undefined && now >= transmitAt) return 'stale';
  547. return 'fresh';
  548. }
  549. /**
  550. * Drop locally-invalid snapshots and pre-flight any stale ones. Pre-flighting
  551. * posts the stale frames to the slot endpoint and applies the returned `set`
  552. * directives, refreshing their `as_at` values before the real request runs.
  553. *
  554. * Single-flight: concurrent callers share one in-flight preparation.
  555. * @returns {Promise<void>}
  556. */
  557. var preparePromise = null;
  558. function prepareSlots() {
  559. if (preparePromise) return preparePromise;
  560. preparePromise = doPrepareSlots().then(function () {
  561. preparePromise = null;
  562. }, function () {
  563. preparePromise = null;
  564. });
  565. return preparePromise;
  566. }
  567. async function doPrepareSlots() {
  568. var stale = [];
  569. store.forEach(function (entry, type) {
  570. var cat = entryCategory(entry);
  571. if (cat === 'invalid') {
  572. clearType(type, { broadcast: false });
  573. } else if (cat === 'stale') {
  574. stale.push(entry.frame);
  575. }
  576. });
  577. if (stale.length) {
  578. var directives = await postSlots(stale);
  579. var pfResult = applyDirectives(directives, { originElement: null });
  580. executeTerminal(pfResult);
  581. }
  582. }
  583. /**
  584. * Build the `X-Statum-Slot` header values for every held slot, using the
  585. * freshness rules. Assumes {@link prepareSlots} has just run.
  586. * @returns {string[]}
  587. */
  588. function buildSlotHeaders() {
  589. var headers = [];
  590. store.forEach(function (entry) {
  591. var snap = entry.snapshot;
  592. var cat = entryCategory(entry);
  593. if (cat === 'silent' || cat === 'invalid') return;
  594. if (cat === 'always') {
  595. headers.push(b64encode(JSON.stringify(entry.frame)));
  596. } else {
  597. headers.push('key=' + snap.slot.key + '; as_at=' + snap.as_at);
  598. }
  599. });
  600. return headers;
  601. }
  602. /**
  603. * Collect the current frames held for the given slot keys, in the order the
  604. * keys are first supplied. Used by the `transmit` directive (which forces a
  605. * slot's frame to be posted as if its `transmit_after` had elapsed). Keys the
  606. * client does not currently hold are skipped.
  607. * @param {string[]} keys
  608. * @returns {object[]}
  609. */
  610. function collectFramesForKeys(keys) {
  611. var keySet = Object.create(null);
  612. for (var i = 0; i < keys.length; i++) keySet[keys[i]] = true;
  613. var frames = [];
  614. store.forEach(function (entry) {
  615. var key = entry.snapshot.slot && entry.snapshot.slot.key;
  616. if (key && keySet[key]) {
  617. frames.push(entry.frame);
  618. delete keySet[key];
  619. }
  620. });
  621. return frames;
  622. }
  623. // ---------------------------------------------------------------------------
  624. // HTTP helpers
  625. // ---------------------------------------------------------------------------
  626. /** True if a response is a Statum directive response. */
  627. function isStatum(res) {
  628. return (res.headers.get('Content-Type') || '').indexOf(STATUM_CONTENT_TYPE) !== -1;
  629. }
  630. /**
  631. * POST an array of frames to the slot endpoint (the pre-flight target) and
  632. * return its directive array. This request intentionally carries no slot
  633. * headers (it is itself the refresh).
  634. * @param {object[]} frames
  635. * @returns {Promise<object[]>}
  636. */
  637. async function postSlots(frames) {
  638. var headers = new Headers();
  639. headers.set('Accept', STATUM_CONTENT_TYPE);
  640. headers.set('Content-Type', 'application/json');
  641. var res = await fetch(config.slotsUrl, {
  642. method: 'POST',
  643. headers: headers,
  644. body: JSON.stringify(frames),
  645. credentials: 'same-origin'
  646. });
  647. if (!isStatum(res)) return [];
  648. try { return await res.json(); } catch (e) { return []; }
  649. }
  650. /**
  651. * Apply the non-terminal directives from a response (set/clear/subscribe/
  652. * unsubscribe/notify/transmit-collection), then `render`.
  653. *
  654. * Terminal directives (`navigate`, `post`), error directives, and any
  655. * transmit/retry intent are collected on the returned result rather than
  656. * executed here: {@link handleResponse} performs the asynchronous
  657. * transmission (and optional retry) and then runs terminal navigation, so
  658. * that a `transmit` with `retry` can re-issue the request before the page is
  659. * navigated away.
  660. * @param {object[]} directives
  661. * @param {{originElement?: HTMLElement}} [ctx]
  662. * @returns {{errors: StatumError[], transmitKeys: string[], retry: boolean, navigate: object|null, post: object|null}}
  663. */
  664. function applyDirectives(directives, ctx) {
  665. var result = { errors: [], transmitKeys: [], retry: false, navigate: null, post: null };
  666. var list = Array.isArray(directives) ? directives : [];
  667. for (var i = 0; i < list.length; i++) {
  668. var d = list[i];
  669. if (!d || typeof d !== 'object') continue;
  670. switch (d.type) {
  671. case 'set': applySet(d); break;
  672. case 'clear': applyClear(d); break;
  673. case 'invalidate':
  674. if (d.signature != null) {
  675. console.warn('[statum] server could not verify slot frame ' + d.signature + '; clearing it');
  676. clearBySignature(d.signature, true);
  677. }
  678. break;
  679. case 'subscribe': channelSubscribe(d.key); break;
  680. case 'unsubscribe': channelUnsubscribe(d.key); break;
  681. case 'notify': triggerNotify(d.kind, d.message); break;
  682. case 'transmit':
  683. if (d.key != null) {
  684. if (result.transmitKeys.indexOf(d.key) === -1) result.transmitKeys.push(d.key);
  685. if (d.retry) result.retry = true;
  686. }
  687. break;
  688. case 'error':
  689. {
  690. var err = new StatumError(d.message, d.code, d.data);
  691. var detail = d.code ? (d.message + ' [' + d.code + ']') : d.message;
  692. console.error('[statum] error directive: ' + (detail || '(no message)'));
  693. result.errors.push(err);
  694. }
  695. break;
  696. case 'navigate': result.navigate = d; break;
  697. case 'post': result.post = d; break;
  698. }
  699. }
  700. renderAll();
  701. return result;
  702. }
  703. /** Execute the terminal navigation directives collected by {@link applyDirectives}. */
  704. function executeTerminal(result) {
  705. if (result.navigate) doNavigate(result.navigate);
  706. if (result.post) doPost(result.post);
  707. }
  708. /** Execute a `navigate` directive as a full page load. */
  709. function doNavigate(d) {
  710. if (d.uri) window.location.href = d.uri;
  711. }
  712. /** Execute a `post` directive as a real (navigating) form POST. */
  713. function doPost(d) {
  714. if (!d.uri) return;
  715. var form = document.createElement('form');
  716. form.method = 'POST';
  717. form.action = d.uri;
  718. form.style.display = 'none';
  719. var data = d.data || {};
  720. Object.keys(data).forEach(function (name) {
  721. var input = document.createElement('input');
  722. input.type = 'hidden';
  723. input.name = name;
  724. input.value = data[name] == null ? '' : String(data[name]);
  725. form.appendChild(input);
  726. });
  727. document.body.appendChild(form);
  728. form.submit();
  729. }
  730. /**
  731. * Handle a fetch response: if it is a Statum response, apply its directives;
  732. * otherwise throw on HTTP errors and no-op on other successful responses.
  733. *
  734. * Directive ordering: non-terminal directives are applied and the page
  735. * rendered; then any `transmit` directives force their slot frames to be
  736. * posted (applying the directives the slot endpoint returns); then, if a
  737. * transmit requested a retry, the originating request (`ctx.retry`) is
  738. * re-issued and this response's terminal directives and errors are skipped.
  739. * Otherwise terminal navigation runs, then error directives are surfaced.
  740. * @param {Response} res
  741. * @param {{originElement?: HTMLElement, retry?: Function}} [ctx]
  742. */
  743. async function handleResponse(res, ctx) {
  744. var el = ctx && ctx.originElement;
  745. if (!isStatum(res)) {
  746. if (!res.ok) await reportFailure(new StatumError('HTTP ' + res.status), el);
  747. return;
  748. }
  749. var directives;
  750. try { directives = await res.json(); } catch (e) { directives = []; }
  751. var result = applyDirectives(directives, ctx);
  752. if (result.transmitKeys && result.transmitKeys.length) {
  753. var frames = collectFramesForKeys(result.transmitKeys);
  754. if (frames.length) {
  755. var txDirectives = await postSlots(frames);
  756. applyDirectives(txDirectives, { originElement: el });
  757. }
  758. }
  759. if (result.retry && ctx && typeof ctx.retry === 'function') {
  760. await ctx.retry();
  761. return;
  762. }
  763. executeTerminal(result);
  764. var errors = result.errors || [];
  765. for (var i = 0; i < errors.length; i++) {
  766. await reportFailure(errors[i], el);
  767. }
  768. }
  769. // ---------------------------------------------------------------------------
  770. // Entrypoint & action requests
  771. // ---------------------------------------------------------------------------
  772. /**
  773. * Fetch the entrypoint for the current URL, applying its directives. Carries
  774. * slot headers (and pre-flights) like any other request. The request is
  775. * wrapped in an `attempt` closure passed as the response `retry` callback, so
  776. * a `transmit` directive with `retry` can re-issue it after transmission.
  777. * @returns {Promise<void>}
  778. */
  779. function loadEntrypoint() {
  780. var attempt = async function () {
  781. await prepareSlots();
  782. var headers = new Headers();
  783. headers.set('Accept', STATUM_CONTENT_TYPE);
  784. buildSlotHeaders().forEach(function (v) { headers.append(SLOT_HEADER, v); });
  785. var url = config.entrypointUrl + '?uri=' + encodeURIComponent(window.location.href);
  786. var res = await fetch(url, { headers: headers, credentials: 'same-origin' });
  787. await handleResponse(res, { originElement: null, retry: attempt });
  788. };
  789. return attempt();
  790. }
  791. /**
  792. * Perform an action request.
  793. *
  794. * The request is wrapped in an `attempt` closure passed as the response
  795. * `retry` callback, so a `transmit` directive with `retry` can re-issue the
  796. * same action after the transmission (rebuilding headers from current state).
  797. *
  798. * @param {object} action `{ uri, method, private }`
  799. * @param {object} [data] Key/value parameters. Encoded as query string for
  800. * GET/HEAD, otherwise as `application/x-www-form-urlencoded`.
  801. * @param {HTMLElement} [originElement] Element that triggered the action, so
  802. * that `stm-on-error` classes can be applied on an `error` directive.
  803. * @returns {Promise<void>} Resolves once the request completes and its
  804. * directives have run; rejects on HTTP failure or an `error` directive.
  805. */
  806. function performAction(action, data, originElement) {
  807. var attempt = async function () {
  808. await prepareSlots();
  809. var headers = new Headers();
  810. headers.set('Accept', STATUM_CONTENT_TYPE);
  811. buildSlotHeaders().forEach(function (v) { headers.append(SLOT_HEADER, v); });
  812. if (action.private) headers.set(PRIVATE_HEADER, action.private);
  813. var method = (action.method || 'GET').toUpperCase();
  814. var params = data || {};
  815. var init = { method: method, headers: headers, credentials: 'same-origin' };
  816. var url = action.uri;
  817. if (method === 'GET' || method === 'HEAD') {
  818. var qs = new URLSearchParams(params).toString();
  819. if (qs) url += (url.indexOf('?') !== -1 ? '&' : '?') + qs;
  820. } else {
  821. init.body = new URLSearchParams(params).toString();
  822. headers.set('Content-Type', 'application/x-www-form-urlencoded');
  823. }
  824. var res;
  825. try {
  826. res = await fetch(url, init);
  827. } catch (networkErr) {
  828. await reportFailure(new StatumError('Network error'), originElement);
  829. return;
  830. }
  831. await handleResponse(res, { originElement: originElement, retry: attempt });
  832. };
  833. return attempt();
  834. }
  835. // ---------------------------------------------------------------------------
  836. // Realtime channel (subscribe/unsubscribe directives)
  837. // ---------------------------------------------------------------------------
  838. /**
  839. * Per-tab fallback engine, used when `SharedWorker` is unavailable. Owns an
  840. * `EventSource`, reads the channel id from the first `event: channel`,
  841. * reference-counts slot keys within this tab, PATCHes subscription changes,
  842. * applies incoming set/clear events (broadcasting them to siblings), and
  843. * closes the stream once it has no subscriptions.
  844. */
  845. function createFallbackEngine() {
  846. var es = null;
  847. var channelId = null;
  848. var refs = new Map(); // slotKey -> count
  849. var pending = { subscribe: [], unsubscribe: [] };
  850. var patchScheduled = false;
  851. function openStream() {
  852. if (es) return;
  853. try { es = new EventSource(config.channelUrl); }
  854. catch (e) { es = null; return; }
  855. es.addEventListener('channel', function (ev) { channelId = ev.data; flushPatch(); });
  856. es.addEventListener('set', function (ev) {
  857. try { applyRemoteSet(JSON.parse(ev.data)); } catch (e) {}
  858. });
  859. es.addEventListener('clear', function (ev) { applyRemoteClear(ev.data); });
  860. es.onerror = function () { /* EventSource auto-reconnects */ };
  861. }
  862. function closeStream() {
  863. if (es) { try { es.close(); } catch (e) {} es = null; }
  864. channelId = null;
  865. }
  866. function schedulePatch() {
  867. if (patchScheduled) return;
  868. patchScheduled = true;
  869. Promise.resolve().then(function () { patchScheduled = false; flushPatch(); });
  870. }
  871. function flushPatch() {
  872. if (!channelId) return;
  873. var sub = pending.subscribe, uns = pending.unsubscribe;
  874. if (!sub.length && !uns.length) return;
  875. pending = { subscribe: [], unsubscribe: [] };
  876. var body = {};
  877. if (sub.length) body.subscribe = sub;
  878. if (uns.length) body.unsubscribe = uns;
  879. try {
  880. fetch(config.channelUrl + '/' + encodeURIComponent(channelId), {
  881. method: 'PATCH',
  882. headers: { 'Content-Type': 'application/json' },
  883. credentials: 'same-origin',
  884. body: JSON.stringify(body)
  885. }).catch(function () {});
  886. } catch (e) {}
  887. }
  888. return {
  889. subscribe: function (key) {
  890. var c = refs.get(key) || 0;
  891. refs.set(key, c + 1);
  892. if (c === 0) { openStream(); pending.subscribe.push(key); schedulePatch(); }
  893. },
  894. unsubscribe: function (key) {
  895. var c = refs.get(key) || 0;
  896. if (c <= 0) return;
  897. if (c - 1 <= 0) {
  898. refs.delete(key);
  899. pending.unsubscribe.push(key);
  900. schedulePatch();
  901. if (refs.size === 0) setTimeout(function () { if (refs.size === 0) closeStream(); }, 0);
  902. } else {
  903. refs.set(key, c - 1);
  904. }
  905. }
  906. };
  907. }
  908. /**
  909. * Channel manager. Prefers a `SharedWorker` (which owns the single SSE
  910. * connection, reference-counts subscriptions across tabs, and relays events
  911. * through the cross-tab `BroadcastChannel`); falls back to a per-tab
  912. * `EventSource` engine when `SharedWorker` is unavailable or the worker
  913. * script fails to load. Tracks the true per-slot subscription count so it can
  914. * replay active subscriptions into the fallback on a mid-session switch.
  915. */
  916. function createChannelManager() {
  917. var port = null;
  918. var workerObj = null;
  919. var fallback = null;
  920. var mode = null; // 'worker' | 'fallback' | null
  921. var counts = new Map(); // slotKey -> count (true subscription state)
  922. function switchToFallback() {
  923. if (mode === 'fallback') return;
  924. mode = 'fallback';
  925. port = null;
  926. workerObj = null;
  927. fallback = createFallbackEngine();
  928. counts.forEach(function (c, key) {
  929. for (var i = 0; i < c; i++) fallback.subscribe(key);
  930. });
  931. }
  932. function ensure() {
  933. if (mode !== null) return;
  934. if (typeof SharedWorker !== 'undefined') {
  935. try {
  936. workerObj = new SharedWorker(config.workerUrl);
  937. port = workerObj.port;
  938. port.start();
  939. port.postMessage({ op: 'init', url: config.channelUrl });
  940. // The worker delivers events via the BroadcastChannel, so nothing is
  941. // required on this port beyond command messages.
  942. port.onmessage = function () {};
  943. workerObj.onerror = function () {
  944. console.warn('[statum] realtime worker failed; falling back to per-tab channel');
  945. switchToFallback();
  946. };
  947. window.addEventListener('pagehide', function () {
  948. if (mode === 'worker' && port) {
  949. try { port.postMessage({ op: 'disconnect' }); } catch (e) {}
  950. }
  951. });
  952. mode = 'worker';
  953. return;
  954. } catch (e) { workerObj = null; port = null; }
  955. }
  956. switchToFallback();
  957. }
  958. return {
  959. subscribe: function (key) {
  960. if (key == null) return;
  961. ensure();
  962. counts.set(key, (counts.get(key) || 0) + 1);
  963. if (mode === 'worker') { try { port.postMessage({ op: 'subscribe', key: key }); } catch (e) {} }
  964. else fallback.subscribe(key);
  965. },
  966. unsubscribe: function (key) {
  967. if (key == null) return;
  968. var c = (counts.get(key) || 0) - 1;
  969. if (c <= 0) counts.delete(key); else counts.set(key, c);
  970. ensure();
  971. if (mode === 'worker') { try { port.postMessage({ op: 'unsubscribe', key: key }); } catch (e) {} }
  972. else fallback.unsubscribe(key);
  973. }
  974. };
  975. }
  976. var channelManager = createChannelManager();
  977. function channelSubscribe(key) { channelManager.subscribe(key); }
  978. function channelUnsubscribe(key) { channelManager.unsubscribe(key); }
  979. // ---------------------------------------------------------------------------
  980. // Rendering: loops, conditionals, bindings
  981. // ---------------------------------------------------------------------------
  982. /** Templates captured from loop holders. @type {WeakMap<Element, Element>} */
  983. var templates = new WeakMap();
  984. /** Instance nodes managed by a loop holder. @type {WeakMap<Element, Element[]>} */
  985. var instancesByHolder = new WeakMap();
  986. /** Loop instance nodes; skipped by the render walk (owned by their holder). */
  987. var instanceNodes = new WeakSet();
  988. /** Elements whose `stm-action` has been wired, to avoid double-binding. */
  989. var wired = new WeakSet();
  990. /**
  991. * Find a `stm-for-{name}-in` attribute on an element.
  992. * @returns {{varName: string, expr: string}|null}
  993. */
  994. function getStmFor(el) {
  995. var attrs = el.attributes;
  996. for (var i = 0; i < attrs.length; i++) {
  997. var match = attrs[i].name.match(/^stm-for-(.+)-in$/);
  998. if (match) return { varName: match[1], expr: attrs[i].value };
  999. }
  1000. return null;
  1001. }
  1002. /** Remove any `stm-for-*` and `stm-key` attributes from an element. */
  1003. function stripLoopAttrs(el) {
  1004. var toRemove = [];
  1005. for (var i = 0; i < el.attributes.length; i++) {
  1006. var name = el.attributes[i].name;
  1007. if (/^stm-for-(.+)-in$/.test(name) || name === 'stm-key') toRemove.push(name);
  1008. }
  1009. toRemove.forEach(function (n) { el.removeAttribute(n); });
  1010. }
  1011. /**
  1012. * Evaluate an `stm-if` / `stm-else-if` / `stm-else` chain beginning at `el`,
  1013. * showing the matching branch and hiding the rest.
  1014. *
  1015. * Branches are toggled with the `hidden` attribute rather than detached, so
  1016. * every branch stays in the DOM and is re-evaluated on each render. (Detaching
  1017. * the `stm-if` element would remove it from the render walk, leaving an
  1018. * already-attached `stm-else` branch skipped and never refreshed.)
  1019. * @returns {Element|null} The active branch element (or `null`).
  1020. */
  1021. function handleIfChain(el, scope) {
  1022. var branches = [{ el: el, expr: el.getAttribute('stm-if') }];
  1023. var cur = el.nextElementSibling;
  1024. while (cur) {
  1025. if (cur.hasAttribute('stm-else-if')) {
  1026. branches.push({ el: cur, expr: cur.getAttribute('stm-else-if') });
  1027. cur = cur.nextElementSibling;
  1028. } else if (cur.hasAttribute('stm-else')) {
  1029. branches.push({ el: cur, expr: null });
  1030. break;
  1031. } else {
  1032. break;
  1033. }
  1034. }
  1035. var activeEl = null;
  1036. for (var i = 0; i < branches.length; i++) {
  1037. var b = branches[i];
  1038. if (b.expr === null || !!evalExpr(b.expr, scope)) { activeEl = b.el; break; }
  1039. }
  1040. branches.forEach(function (b) {
  1041. if (b.el === activeEl) b.el.removeAttribute('hidden');
  1042. else b.el.setAttribute('hidden', '');
  1043. });
  1044. return activeEl;
  1045. }
  1046. /** Toggle `stm-class.{name}` classes from their predicate expressions. */
  1047. function handleClass(el, scope) {
  1048. var attrs = el.attributes;
  1049. for (var i = 0; i < attrs.length; i++) {
  1050. var attr = attrs[i];
  1051. if (attr.name.indexOf('stm-class.') === 0) {
  1052. var cls = attr.name.slice('stm-class.'.length);
  1053. if (cls && !!evalExpr(attr.value, scope)) el.classList.add(cls);
  1054. else el.classList.remove(cls);
  1055. }
  1056. }
  1057. }
  1058. /**
  1059. * Bind `stm-attribute.{name}`: a boolean result toggles the attribute, any
  1060. * other defined value sets it, and `undefined` removes it.
  1061. *
  1062. * For booleans, prefer the element's IDL property when one exists (e.g.
  1063. * `checked`, `disabled`, `hidden`) — setting the content attribute is
  1064. * unreliable for these once the control is "dirty".
  1065. */
  1066. function handleAttribute(el, scope) {
  1067. var attrs = el.attributes;
  1068. for (var i = 0; i < attrs.length; i++) {
  1069. var attr = attrs[i];
  1070. if (attr.name.indexOf('stm-attribute.') === 0) {
  1071. var name = attr.name.slice('stm-attribute.'.length);
  1072. if (!name) continue;
  1073. var value = evalExpr(attr.value, scope);
  1074. if (value === undefined) {
  1075. el.removeAttribute(name);
  1076. } else if (typeof value === 'boolean') {
  1077. if (name in el && typeof el[name] === 'boolean') el[name] = value;
  1078. else if (value) el.setAttribute(name, '');
  1079. else el.removeAttribute(name);
  1080. } else {
  1081. el.setAttribute(name, value === null ? '' : String(value));
  1082. }
  1083. }
  1084. }
  1085. }
  1086. /**
  1087. * Apply `stm-disabled`: when the predicate is truthy, disable the element and
  1088. * its descendant form controls; otherwise enable them.
  1089. */
  1090. function handleDisabled(el, scope) {
  1091. if (!el.hasAttribute('stm-disabled')) return;
  1092. setDisabledCascade(el, !!evalExpr(el.getAttribute('stm-disabled'), scope));
  1093. }
  1094. /** Set the `disabled` attribute on an element and its descendant controls. */
  1095. function setDisabledCascade(el, on) {
  1096. var nodes = [el].concat(Array.prototype.slice.call(
  1097. el.querySelectorAll('button,input,select,textarea,fieldset')));
  1098. nodes.forEach(function (n) {
  1099. if (on) n.setAttribute('disabled', ''); else n.removeAttribute('disabled');
  1100. });
  1101. }
  1102. /**
  1103. * Bind `stm-text` / `stm-html`. Per the "leave original DOM content" policy,
  1104. * an unresolved value (`undefined`) leaves the element untouched; otherwise
  1105. * the value is stringified (`null` becomes the empty string).
  1106. */
  1107. function handleTextHtml(el, scope) {
  1108. if (el.hasAttribute('stm-text')) {
  1109. var text = evalExpr(el.getAttribute('stm-text'), scope);
  1110. if (text !== undefined) el.textContent = text === null ? '' : String(text);
  1111. }
  1112. if (el.hasAttribute('stm-html')) {
  1113. var html = evalExpr(el.getAttribute('stm-html'), scope);
  1114. if (html !== undefined) el.innerHTML = html === null ? '' : String(html);
  1115. }
  1116. }
  1117. /**
  1118. * Expand a loop holder: reconcile its instances against the current array.
  1119. * Keyed loops (`stm-key`) reuse DOM nodes for unchanged keys to preserve
  1120. * focus/selection/input state; unkeyed loops rebuild wholesale.
  1121. */
  1122. function handleLoop(el, scope) {
  1123. var info = getStmFor(el);
  1124. if (!info) return;
  1125. var template = templates.get(el);
  1126. if (!template) {
  1127. template = el.cloneNode(true);
  1128. stripLoopAttrs(template);
  1129. templates.set(el, template);
  1130. }
  1131. var raw = evalExpr(info.expr, scope);
  1132. if (raw === undefined) {
  1133. // No value for the source path yet: clear instances and restore the
  1134. // server-rendered holder content (progressive enhancement).
  1135. var prev = instancesByHolder.get(el) || [];
  1136. prev.forEach(function (n) { n.remove(); });
  1137. instancesByHolder.set(el, []);
  1138. if (el.getAttribute('data-stm-active') === '1') {
  1139. el.removeAttribute('data-stm-active');
  1140. el.removeAttribute('hidden');
  1141. }
  1142. return;
  1143. }
  1144. var arr = Array.isArray(raw) ? raw : [];
  1145. // Activate: hide the holder so only rendered instances are visible.
  1146. if (el.getAttribute('data-stm-active') !== '1') {
  1147. el.setAttribute('data-stm-active', '1');
  1148. el.setAttribute('hidden', '');
  1149. }
  1150. var keyExpr = el.getAttribute('stm-key');
  1151. var next = [];
  1152. if (keyExpr) {
  1153. var oldByKey = new Map();
  1154. (instancesByHolder.get(el) || []).forEach(function (n) {
  1155. if (n.__stmKey !== undefined) oldByKey.set(n.__stmKey, n);
  1156. });
  1157. var used = new Set();
  1158. arr.forEach(function (item) {
  1159. var child = childScope(scope, info.varName, item);
  1160. var key = evalExpr(keyExpr, child);
  1161. var node = (key !== undefined && !used.has(key)) ? oldByKey.get(key) : undefined;
  1162. if (node) {
  1163. used.add(key);
  1164. processElement(node, child);
  1165. } else {
  1166. node = template.cloneNode(true);
  1167. node.__stmKey = key;
  1168. instanceNodes.add(node);
  1169. processElement(node, child);
  1170. }
  1171. next.push(node);
  1172. });
  1173. oldByKey.forEach(function (n, key) { if (!used.has(key)) n.remove(); });
  1174. } else {
  1175. (instancesByHolder.get(el) || []).forEach(function (n) { n.remove(); });
  1176. arr.forEach(function (item) {
  1177. var child = childScope(scope, info.varName, item);
  1178. var node = template.cloneNode(true);
  1179. instanceNodes.add(node);
  1180. processElement(node, child);
  1181. next.push(node);
  1182. });
  1183. }
  1184. // Place instances in order, immediately after the holder.
  1185. var ref = el;
  1186. next.forEach(function (node) {
  1187. if (node !== ref.nextSibling) ref.parentNode.insertBefore(node, ref.nextSibling);
  1188. ref = node;
  1189. });
  1190. instancesByHolder.set(el, next);
  1191. }
  1192. /**
  1193. * Process one element's bindings (and recurse). Loop holders are handled by
  1194. * {@link handleLoop}; `stm-else-if` / `stm-else` are owned by their `stm-if`.
  1195. */
  1196. function processElement(el, scope) {
  1197. if (getStmFor(el)) { handleLoop(el, scope); return; }
  1198. if (el.hasAttribute('stm-else-if') || el.hasAttribute('stm-else')) return;
  1199. if (el.hasAttribute('stm-if')) {
  1200. var active = handleIfChain(el, scope);
  1201. if (active) processInner(active, scope);
  1202. return;
  1203. }
  1204. processInner(el, scope);
  1205. }
  1206. /**
  1207. * Collect `stm-def-{name}-as` attributes on an element into a child scope.
  1208. * Each expression is evaluated against the incoming scope (so the new
  1209. * variables cannot reference each other within the same element), and the
  1210. * results are added to a child scope visible to the element and its
  1211. * descendants. Multiple defs on one element are supported. If there are no
  1212. * defs, the original scope is returned unchanged.
  1213. * @param {Element} el
  1214. * @param {object} scope
  1215. * @returns {object}
  1216. */
  1217. function applyDefs(el, scope) {
  1218. var attrs = el.attributes;
  1219. var child = null;
  1220. for (var i = 0; i < attrs.length; i++) {
  1221. var name = attrs[i].name;
  1222. if (name.indexOf('stm-def-') === 0 && name.slice(-3) === '-as') {
  1223. var varName = name.slice(8, -3);
  1224. if (!varName) continue;
  1225. if (!child) child = Object.create(scope || null);
  1226. child[varName] = evalExpr(attrs[i].value, scope);
  1227. }
  1228. }
  1229. return child || scope;
  1230. }
  1231. /** Bind the non-control-flow attributes and recurse into children. */
  1232. function processInner(el, scope) {
  1233. var s = applyDefs(el, scope); // local definitions: element + descendants
  1234. el.__stmScope = s; // latest scope, used when an action fires
  1235. handleClass(el, s);
  1236. handleAttribute(el, s);
  1237. handleDisabled(el, s);
  1238. handleTextHtml(el, s);
  1239. wireAction(el);
  1240. render(el, s);
  1241. }
  1242. /**
  1243. * Walk an element's children, processing each. Loop instance nodes are
  1244. * skipped here (they are owned and processed by their holder).
  1245. */
  1246. function render(root, scope) {
  1247. if (!root || !root.children) return;
  1248. // Snapshot the children: processing mutates the DOM (detaching branches,
  1249. // inserting loop instances) and a live HTMLCollection would shift indices.
  1250. var children = Array.prototype.slice.call(root.children);
  1251. for (var i = 0; i < children.length; i++) {
  1252. var el = children[i];
  1253. if (instanceNodes.has(el)) continue;
  1254. processElement(el, scope);
  1255. }
  1256. }
  1257. /** Re-render the whole document against the current state. */
  1258. function renderAll() {
  1259. try { render(document.body, stateScope()); }
  1260. catch (e) { console.error('[statum] render error', e); }
  1261. }
  1262. // ---------------------------------------------------------------------------
  1263. // HTML API: actions
  1264. // ---------------------------------------------------------------------------
  1265. /**
  1266. * Collect data for an action trigger: `stm-data-{key}` attributes plus, for a
  1267. * form, its named inputs.
  1268. * @param {HTMLElement} el
  1269. * @returns {object}
  1270. */
  1271. function collectData(el) {
  1272. var data = {};
  1273. var attrs = el.attributes;
  1274. for (var i = 0; i < attrs.length; i++) {
  1275. var name = attrs[i].name;
  1276. if (name.indexOf('stm-data-') === 0) data[name.slice('stm-data-'.length)] = attrs[i].value;
  1277. }
  1278. if (el.tagName === 'FORM') {
  1279. var fd = new FormData(el);
  1280. fd.forEach(function (value, key) {
  1281. data[key] = value instanceof File ? value.name : String(value);
  1282. });
  1283. }
  1284. return data;
  1285. }
  1286. /**
  1287. * Toggle the busy state of an action element: disables the element and its
  1288. * descendant form controls and applies `stm-busy-class` classes while busy.
  1289. *
  1290. * On un-busy the disabled state is not "restored" to a captured snapshot
  1291. * (that would clobber a `stm-disabled` value that `renderAll` applied during
  1292. * the response); instead the nodes are re-enabled and `stm-disabled` is
  1293. * re-evaluated, which re-disables any that should remain disabled.
  1294. */
  1295. function setBusyState(el, busy) {
  1296. var nodes = [el].concat(Array.prototype.slice.call(
  1297. el.querySelectorAll('button,input,select,textarea,fieldset')));
  1298. var busyClasses = (el.getAttribute('stm-busy-class') || '').split(/\s+/).filter(Boolean);
  1299. if (busy) {
  1300. nodes.forEach(function (n) { n.setAttribute('disabled', ''); });
  1301. busyClasses.forEach(function (c) { el.classList.add(c); });
  1302. } else {
  1303. nodes.forEach(function (n) { n.removeAttribute('disabled'); });
  1304. busyClasses.forEach(function (c) { el.classList.remove(c); });
  1305. reapplyDisabled(el);
  1306. }
  1307. }
  1308. /** Re-apply `stm-disabled` to an element and any descendants that carry it. */
  1309. function reapplyDisabled(el) {
  1310. var scope = el.__stmScope || stateScope();
  1311. if (el.hasAttribute('stm-disabled')) handleDisabled(el, scope);
  1312. var scoped = el.querySelectorAll('[stm-disabled]');
  1313. Array.prototype.forEach.call(scoped, function (n) {
  1314. handleDisabled(n, n.__stmScope || scope);
  1315. });
  1316. }
  1317. /** Apply or remove `stm-on-error` classes on an action element. */
  1318. function applyErrorClasses(el, on) {
  1319. if (!el || !el.hasAttribute || !el.hasAttribute('stm-on-error')) return;
  1320. var classes = (el.getAttribute('stm-on-error') || '').split(/\s+/).filter(Boolean);
  1321. classes.forEach(function (c) { on ? el.classList.add(c) : el.classList.remove(c); });
  1322. }
  1323. /**
  1324. * Wire an element's primary action once. The listener resolves the action
  1325. * object against the element's latest render scope, so loop instances bind to
  1326. * their own item.
  1327. */
  1328. function wireAction(el) {
  1329. if (!el.hasAttribute('stm-action') || wired.has(el)) return;
  1330. wired.add(el);
  1331. var eventName = el.tagName === 'FORM' ? 'submit' : 'click';
  1332. el.addEventListener(eventName, function (event) {
  1333. event.preventDefault();
  1334. runElementAction(el);
  1335. });
  1336. }
  1337. /**
  1338. * Resolve and perform the action attached to an element, managing busy and
  1339. * error-state classes around the request.
  1340. * @param {HTMLElement} el
  1341. */
  1342. async function runElementAction(el) {
  1343. var scope = el.__stmScope || stateScope();
  1344. var action = evalExpr(el.getAttribute('stm-action'), scope);
  1345. if (!action || typeof action.uri !== 'string') return;
  1346. // stm-confirm: gate the action behind the confirm handler (async-capable,
  1347. // so custom modals are supported).
  1348. if (el.hasAttribute('stm-confirm')) {
  1349. var confirmed = await triggerConfirm(el.getAttribute('stm-confirm'), el);
  1350. if (!confirmed) return;
  1351. }
  1352. applyErrorClasses(el, false); // clear error state on a new attempt
  1353. var data = collectData(el);
  1354. setBusyState(el, true);
  1355. try {
  1356. await performAction(action, data, el);
  1357. } catch (err) {
  1358. // Already handled: stm-on-error classes and the onErrorHandler run inside
  1359. // performAction/reportFailure. Swallow so an HTML-triggered action does
  1360. // not surface as an unhandled rejection.
  1361. } finally {
  1362. setBusyState(el, false);
  1363. }
  1364. }
  1365. // ---------------------------------------------------------------------------
  1366. // Boot visibility (stm-preloader / stm-content)
  1367. // ---------------------------------------------------------------------------
  1368. /**
  1369. * Toggle pre/post-entrypoint visibility with the `hidden` attribute. While
  1370. * loading, `stm-preloader` elements are shown and `stm-content` elements
  1371. * hidden; once ready, the reverse.
  1372. *
  1373. * To avoid a flash of un-bound template before the script runs, authors may
  1374. * pre-set `hidden` directly on `stm-content` in the HTML (e.g.
  1375. * `<div stm-content hidden>`); the library removes it once ready. The library
  1376. * also sets `hidden` itself during loading as a fallback for authors who do
  1377. * not pre-set it.
  1378. * @param {boolean} ready
  1379. */
  1380. function setBootState(ready) {
  1381. var preloaders = document.querySelectorAll('[stm-preloader]');
  1382. var contents = document.querySelectorAll('[stm-content]');
  1383. for (var i = 0; i < preloaders.length; i++) {
  1384. ready ? preloaders[i].setAttribute('hidden', '') : preloaders[i].removeAttribute('hidden');
  1385. }
  1386. for (var j = 0; j < contents.length; j++) {
  1387. ready ? contents[j].removeAttribute('hidden') : contents[j].setAttribute('hidden', '');
  1388. }
  1389. }
  1390. // ---------------------------------------------------------------------------
  1391. // Public state accessors
  1392. // ---------------------------------------------------------------------------
  1393. /** Build the scope used for expression evaluation: `{ typeName: public }`. */
  1394. function stateScope() {
  1395. var scope = {};
  1396. store.forEach(function (entry, type) { scope[type] = entry.snapshot.public; });
  1397. return scope;
  1398. }
  1399. /** Read a type's `public` data (what the attributes bind to). */
  1400. function read(type) {
  1401. var entry = store.get(type);
  1402. return entry ? entry.snapshot.public : undefined;
  1403. }
  1404. /** Read a type's full snapshot object. */
  1405. function getSnapshot(type) {
  1406. var entry = store.get(type);
  1407. return entry ? entry.snapshot : undefined;
  1408. }
  1409. /** Read the frame wrapping a type's snapshot. */
  1410. function getFrame(type) {
  1411. var entry = store.get(type);
  1412. return entry ? entry.frame : undefined;
  1413. }
  1414. /** Build an object of `{ typeName: public }` for every held type. */
  1415. function state() {
  1416. var result = {};
  1417. store.forEach(function (entry, type) { result[type] = entry.snapshot.public; });
  1418. return result;
  1419. }
  1420. /**
  1421. * Clear state. With a type argument, clears that type only; without, clears
  1422. * all held state.
  1423. * @param {string} [type]
  1424. */
  1425. function clear(type) {
  1426. if (type === undefined) {
  1427. Array.from(store.keys()).forEach(function (t) { clearType(t, { broadcast: true }); });
  1428. } else {
  1429. clearType(type, { broadcast: true });
  1430. }
  1431. renderAll();
  1432. }
  1433. /**
  1434. * Perform an action. `action` may be an action object or a string path that
  1435. * resolves to an action object within the current state.
  1436. * @param {object|string} action
  1437. * @param {object} [data]
  1438. * @returns {Promise<void>}
  1439. */
  1440. function act(action, data) {
  1441. var resolved = action;
  1442. if (typeof action === 'string') resolved = evalExpr(action, stateScope());
  1443. if (!resolved || typeof resolved.uri !== 'string') {
  1444. return Promise.reject(new StatumError('Invalid action'));
  1445. }
  1446. return performAction(resolved, data, null);
  1447. }
  1448. /** Attach a state listener for a type. */
  1449. function addStateListener(type, callback) {
  1450. if (typeof callback !== 'function') return;
  1451. if (!listeners.has(type)) listeners.set(type, new Set());
  1452. listeners.get(type).add(callback);
  1453. }
  1454. /** Remove a previously-attached state listener. */
  1455. function removeStateListener(type, callback) {
  1456. var set = listeners.get(type);
  1457. if (!set) return;
  1458. set.delete(callback);
  1459. if (set.size === 0) listeners.delete(type);
  1460. }
  1461. // ---------------------------------------------------------------------------
  1462. // Initialization
  1463. // ---------------------------------------------------------------------------
  1464. /**
  1465. * Scan the document for `pstm-*` attributes and `<pstm-*>` elements, which
  1466. * are intended for server-side pre-rendering and should never reach the
  1467. * browser. Emits a single `console.warn` summarizing them.
  1468. */
  1469. function warnServerSideMarkup() {
  1470. var found = [];
  1471. var els = document.querySelectorAll('*');
  1472. for (var i = 0; i < els.length; i++) {
  1473. var el = els[i];
  1474. var tag = el.tagName.toLowerCase();
  1475. if (tag.indexOf('pstm-') === 0) found.push('<' + tag + '>');
  1476. var attrs = el.attributes;
  1477. for (var j = 0; j < attrs.length; j++) {
  1478. var name = attrs[j].name;
  1479. if (name.indexOf('pstm-') === 0) found.push('<' + tag + '> ' + name);
  1480. }
  1481. }
  1482. if (found.length) {
  1483. console.warn('[statum] ' + found.length + ' server-side pstm-* attribute/element(s) ' +
  1484. 'found in the DOM; they should have been resolved during pre-rendering: ' +
  1485. found.slice(0, 10).join(', ') + (found.length > 10 ? ' …' : ''));
  1486. }
  1487. }
  1488. /**
  1489. * Initialize Statum: read URL overrides, run the session-cookie check,
  1490. * hydrate persisted state, open the cross-tab channel, fetch the entrypoint,
  1491. * and render. Safe to call multiple times.
  1492. * @returns {Promise<void>}
  1493. */
  1494. async function init() {
  1495. if (initialized) return;
  1496. initialized = true;
  1497. var body = document.body;
  1498. if (body) {
  1499. if (body.hasAttribute('stm-entrypoint')) config.entrypointUrl = body.getAttribute('stm-entrypoint');
  1500. if (body.hasAttribute('stm-slots')) config.slotsUrl = body.getAttribute('stm-slots');
  1501. if (body.hasAttribute('stm-channel')) config.channelUrl = body.getAttribute('stm-channel');
  1502. if (body.hasAttribute('stm-worker')) config.workerUrl = body.getAttribute('stm-worker');
  1503. }
  1504. try { warnServerSideMarkup(); } catch (e) {}
  1505. try { initSession(); } catch (e) {}
  1506. try { hydrate(); } catch (e) {}
  1507. setupChannel();
  1508. setBootState(false);
  1509. renderAll(); // bind against any persisted/hydrated state immediately
  1510. try {
  1511. await loadEntrypoint();
  1512. } catch (err) {
  1513. console.error('[statum] entrypoint load failed', err);
  1514. } finally {
  1515. setBootState(true);
  1516. }
  1517. }
  1518. function autoInit() {
  1519. if (document.readyState === 'loading') {
  1520. document.addEventListener('DOMContentLoaded', init);
  1521. } else {
  1522. init();
  1523. }
  1524. }
  1525. // ---------------------------------------------------------------------------
  1526. // Public API
  1527. // ---------------------------------------------------------------------------
  1528. /** @namespace statum */
  1529. window.statum = {
  1530. /** Library version. */
  1531. version: '0.1.0',
  1532. /** Mutable endpoint configuration. */
  1533. config: config,
  1534. /** (Re)initialize the library. Usually runs automatically on load. */
  1535. init: init,
  1536. /** Read a type's `public` data. */
  1537. read: read,
  1538. /** Read a type's full snapshot object. */
  1539. getSnapshot: getSnapshot,
  1540. /** Read the frame wrapping a type's snapshot. */
  1541. getFrame: getFrame,
  1542. /** Build `{ typeName: public }` for every held type. */
  1543. state: state,
  1544. /** Clear one type (`clear("x")`) or all state (`clear()`). */
  1545. clear: clear,
  1546. /** Perform an action object or named action. */
  1547. act: act,
  1548. /** Attach a state-changed listener for a type. */
  1549. addStateListener: addStateListener,
  1550. /** Remove a previously-attached listener. */
  1551. removeStateListener: removeStateListener,
  1552. /** The Statum error type (thrown by `act` and surfaced to handlers). */
  1553. Error: StatumError,
  1554. get onConfirmHandler() { return handlers.onConfirm; },
  1555. set onConfirmHandler(v) { handlers.onConfirm = typeof v === 'function' ? v : null; },
  1556. get onNotifyHandler() { return handlers.onNotify; },
  1557. set onNotifyHandler(v) { handlers.onNotify = typeof v === 'function' ? v : null; },
  1558. get onErrorHandler() { return handlers.onError; },
  1559. set onErrorHandler(v) { handlers.onError = typeof v === 'function' ? v : null; }
  1560. };
  1561. autoInit();
  1562. })();