1// App Viewer â renders the production preview of a designed project inside the studio. 2import { APPBUILDER_LAYOUT_KEY, buildProductionHTML } from "../../../repo/productionBuilder.js"; 3import { ensureToolbarBaseStyles } from "../../common/toolbar.js"; 4import { callAdminProc } from "../../shared/zdbAdminClient.js"; 5 6let _stylesInjected = false; 7 8function ensureAppViewerStyles() { 9 if (_stylesInjected) return; 10 _stylesInjected = true; 11 const style = document.createElement("style"); 12 style.textContent = ` 13.av-shell { display:flex; flex-direction:column; width:100%; height:100%; overflow:hidden; background:#f1f5f9; } 14.av-toolbar { 15 display:flex; align-items:center; gap:6px; flex-shrink:0; 16 padding:6px 10px; background:#1e293b; border-bottom:1px solid #334155; 17} 18.av-label { color:#94a3b8; font:11px/1 system-ui; white-space:nowrap; } 19.av-input { 20 padding:4px 8px; border:1px solid #475569; border-radius:5px; 21 background:#0f172a; color:#e2e8f0; font:12px system-ui; width:110px; 22 outline:none; 23} 24.av-input:focus { border-color:#60a5fa; } 25.av-sep { width:1px; height:20px; background:#334155; margin:0 4px; } 26.av-btn { 27 padding:4px 10px; border:1px solid #475569; border-radius:5px; 28 background:#1e293b; color:#cbd5e1; font:12px/1.4 system-ui; 29 cursor:pointer; white-space:nowrap; 30} 31.av-btn:hover { background:#334155; color:#f1f5f9; } 32.av-btn-primary { background:#2563eb; border-color:#2563eb; color:#fff; } 33.av-btn-primary:hover { background:#1d4ed8; } 34.av-btn-active { background:#0f172a; border-color:#60a5fa; color:#60a5fa; } 35.av-viewport { 36 flex:1; overflow:auto; padding:12px; display:flex; 37 justify-content:center; background:#e2e8f0; 38} 39.av-iframe-wrap { width:100%; min-height:100%; flex-shrink:0; } 40.av-iframe { display:block; width:100%; height:100%; min-height:500px; border:none; background:#fff; } 41 `; 42 document.head.appendChild(style); 43} 44 45/** Device-width presets (null = full width) */ 46const DEVICES = [ 47 { id: "desktop", label: "Desktop", title: "Desktop â full width", width: null }, 48 { id: "tablet", label: "Tablet", title: "Tablet â 768 px", width: 768 }, 49 { id: "mobile", label: "Mobile", title: "Mobile â 390 px", width: 390 }, 50]; 51 52export function mount(el, body, opts = {}) { 53 ensureAppViewerStyles(); 54 55 // Run-mode inputs: when opened for a published app (apps.html â ?run=<docId>), 56 // load the SERVER-saved appbuilder NDJSON by id instead of localStorage. 57 const runDocId = (opts.docId || "").trim(); 58 let serverNdjson = opts.layoutNdjson || null; 59 60 // ââ Root layout wrapper ââââââââââââââââââââââââââââââââââââââââââââââââââââ 61 const shell = document.createElement("div"); 62 shell.className = "av-shell"; 63 64 // ââ Toolbar ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 65 const toolbar = document.createElement("div"); 66 toolbar.className = "av-toolbar"; 67 68 const projLabel = document.createElement("span"); 69 projLabel.className = "av-label"; 70 projLabel.textContent = "Project"; 71 72 const projInput = document.createElement("input"); 73 projInput.className = "av-input"; 74 projInput.placeholder = "MainProject"; 75 projInput.value = (document.getElementById("repoProject")?.value || "").trim() || "MainProject"; 76 projInput.title = "Project name"; 77 78 const layoutLabel = document.createElement("span"); 79 layoutLabel.className = "av-label"; 80 layoutLabel.textContent = "Layout"; 81 82 const layoutInput = document.createElement("input"); 83 layoutInput.className = "av-input"; 84 layoutInput.placeholder = "Main"; 85 layoutInput.value = (document.getElementById("repoLayout")?.value || "").trim() || "Main"; 86 layoutInput.title = "Layout name"; 87 88 const previewBtn = document.createElement("button"); 89 previewBtn.className = "av-btn av-btn-primary"; 90 previewBtn.textContent = "â¶ Preview"; 91 previewBtn.title = "Build and show the production preview"; 92 93 // Reload lived only in the (now-removed) sidebar; keep it on the top bar so no 94 // control is lost. For a srcdoc preview, rebuilding via loadPreview is the 95 // reliable refresh (re-fetches the doc and re-renders). 96 const reloadBtn = document.createElement("button"); 97 reloadBtn.className = "av-btn"; 98 reloadBtn.textContent = "â» Reload"; 99 reloadBtn.title = "Reload the preview"; 100 101 const sep = document.createElement("div"); 102 sep.className = "av-sep"; 103 104 // Device toggle buttons 105 const deviceBtns = DEVICES.map(d => { 106 const btn = document.createElement("button"); 107 btn.className = "av-btn" + (d.id === "desktop" ? " av-btn-active" : ""); 108 btn.textContent = d.label; 109 btn.title = d.title; 110 btn.dataset.device = d.id; 111 return btn; 112 }); 113 114 const sep2 = document.createElement("div"); 115 sep2.className = "av-sep"; 116 117 const helpBtn = document.createElement("button"); 118 helpBtn.className = "av-btn"; 119 helpBtn.textContent = "?"; 120 helpBtn.title = "App Viewer Help"; 121 helpBtn.setAttribute("aria-label", "App Viewer Help"); 122 helpBtn.addEventListener("click", () => { 123 document.dispatchEvent(new CustomEvent("assistant:help-topic", { detail: { topic: "appviewer" } })); 124 }); 125 126 toolbar.append(projLabel, projInput, layoutLabel, layoutInput, previewBtn, reloadBtn, sep, ...deviceBtns, sep2, helpBtn); 127 128 // ââ Viewport + iframe ââââââââââââââââââââââââââââââââââââââââââââââââââââââ 129 const viewport = document.createElement("div"); 130 viewport.className = "av-viewport"; 131 132 const iframeWrap = document.createElement("div"); 133 iframeWrap.className = "av-iframe-wrap"; 134 135 const iframe = document.createElement("iframe"); 136 iframe.className = "av-iframe"; 137 iframe.setAttribute("sandbox", "allow-scripts allow-modals allow-popups allow-forms"); 138 iframe.title = "App Preview"; 139 let previewRelayState = null; 140 let currentPreviewToken = ""; 141 let readRelay = { reads: [], timer: null }; 142 let streamsRegistryPromise = null; 143 const PREVIEW_CHILD_ORIGIN = "null"; 144 145 iframeWrap.appendChild(iframe); 146 viewport.appendChild(iframeWrap); 147
148 // ââ Body row (viewport only) ââââââââââââââââââââââââââââââââââââââââââââââ 149 // The left palette used to duplicate the top toolbar's Device (Desktop/Tablet/ 150 // Mobile) and Actions (Preview/Reload); it was removed so the top bar is the 151 // single control surface. 152 ensureToolbarBaseStyles(); 153 const avBodyRow = document.createElement("div"); 154 avBodyRow.style.cssText = "display:flex;flex-direction:row;flex:1 1 auto;overflow:hidden;min-height:0;"; 155 avBodyRow.append(viewport); 156 shell.append(toolbar, avBodyRow); 157 body.appendChild(shell); 158 159 const getStreamsRegistry = async () => { 160 if (!streamsRegistryPromise) streamsRegistryPromise = import("../../../streams/registry.js").catch(() => null); 161 return streamsRegistryPromise; 162 }; 163 const cleanupPreviewRelay = () => { 164 if (!previewRelayState) return; 165 (previewRelayState.unsubscribes || []).forEach((unsub) => { try { unsub(); } catch (_) {} }); 166 previewRelayState = null; 167 }; 168 const makePreviewRelayToken = () => { 169 try { 170 if (window.crypto?.randomUUID) return window.crypto.randomUUID(); 171 } catch (_) {} 172 return `preview-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; 173 }; 174 175 // ââ Read relay (auto-refresh) ââââââââââââââââââââââââââââââââââââââââââââââ 176 // The sandboxed preview can't reach the DB. Its read widgets announce what 177 // they're bound to (ab-preview-reads-init); the host polls the FeBe filter and 178 // pushes fresh counts/rows back. Also refetched immediately after a write, so 179 // adding a row updates the table without a manual reload. (WSS is the transport, 180 // but FeBe reads are request/response â this is polling, not server push.) 181 const stopReadRelay = () => { 182 if (readRelay.timer) { clearInterval(readRelay.timer); } 183 readRelay = { reads: [], timer: null }; 184 }; 185 // Relative-time tokens in a saved FeBe predicate: "$now" / "$now-<seconds>", 186 // resolved against the CLIENT clock at each poll. 187 // 188 // A time-series chart bound to a growing table has no other way to stay 189 // bounded: a literal epoch is frozen at save time, so the window it selects 190 // widens by a minute every minute and the aggregate's row count -- and the 191 // payload crossing the link on every 6s poll -- grows without limit. On a 192 // 60s-bucket sensor table at 48h retention that is ~2880 groups (~200KB) 193 // per poll, forever, to draw a chart nobody is reading past the right edge. 194 // "$now-10800" instead pins it to a rolling 3h / 180 groups. 195 // 196 // Client clock, not the engine's: the value only ever selects a display 197 // window. Nothing is stored from it, so a skewed browser clock shifts what 198 // the viewer sees and cannot corrupt a record. 199 const resolveWhereTokens = (where) => { 200 if (!where || typeof where !== "object") return where; 201 const raw = String(where.value ?? ""); 202 const m = /^\$now(?:\s*-\s*(\d+))?$/.exec(raw.trim()); 203 if (!m) return where; 204 const now = Math.floor(Date.now() / 1000); 205 return { ...where, value: String(now - (m[1] ? Number(m[1]) : 0)) }; 206 }; 207 // Same tokens inside a filter-lane predicate tree ({join, conditions:[â¦]}, 208 // nestable), so "last 3h" means the same thing to a table as to a chart. 209 const resolvePredicateTokens = (node) => { 210 if (!node || typeof node !== "object") return node; 211 if (!Array.isArray(node.conditions)) return resolveWhereTokens(node); 212 return { ...node, conditions: node.conditions.map(resolvePredicateTokens) }; 213 }; 214 // ââ The SCENE lane (video overlay) âââââââââââââââââââââââââââââââââââââââââ 215 // A per-frame detection corpus is the one read shape the FeBe lanes cannot serve, 216 // for two measured reasons rather than a preference: 217 // 218 // 1. pFeBeFilterHandler accepts ONE condition ("only one condition supported"), and a 219 // scene window needs two at minimum -- the run AND the frame range. Asking for just 220 // the run returns the whole corpus (204,935 rows on VidBrain_dev run 1). 221 // 2. The filter lane emits one JSON frame PER ROW. pViFeed emits CSV between a `begin` 222 // frame that DECLARES the column count and a `result` frame that DECLARES the row 223 // count, so streamCsvLane can reconcile received-vs-declared and REFUSE a
223short 224 // stream. A truncated overlay is a picture missing bodies, which is the one failure 225 // this path exists not to have. 226 // 227 // â THE PROC IS ALLOWLISTED, exactly like WRITE_HANDLERS. The reads come from a 228 // sandboxed iframe, so the compId/db/proc in a read config are UNTRUSTED input; without 229 // this the child could name any proc in any database and the host would run it holding 230 // the operator's JWT. 231 const READ_LANE_PROCS = new Set(["pViFeed"]); 232 233 // ââ The scene lane is a HELD SESSION with a CHUNK CACHE, not a fetch âââââââââââââââââ 234 // What this replaces: one `streamCsvLane` call per window, each opening a WebSocket 235 // (csvLane.js:102) that the relay answers by spawning a `zimprompt -e --stream` child, 236 // and closing it again (csvLane.js:78) once the window arrived. With `setInterval` 237 // re-driving it, that is a fresh engine child every couple of seconds â TLS handshake, 238 // fork/exec, ZIM session open, dictionary open and proc load, all to answer one query, 239 // and every one of them a session left for the transport to reap. zimserver is a session 240 // manager: the connection IS the session, so it is opened once and kept. 241 // 242 // â AND THE OLD SHAPE STALLED THE PICTURE BY CONSTRUCTION. A 1800-frame window is 243 // ~19,000 rows; the player consumed it, ran to the edge, and only THEN asked for the 244 // next one â so every window boundary was a burst, a wait, and an overlay drawing from 245 // nothing. Smaller chunks fetched AHEAD of the playhead cost the same rows in total and 246 // remove the wait: by the time the playhead crosses a boundary the chunk is already in 247 // memory. Chunks also bound the damage of a failure to one chunk instead of one window. 248 // â THE CHUNK SIZE IS MEASURED, NOT CHOSEN. On .200 2026-08-17, both halves of an obs 249 // query timed SEPARATELY, big window first, on disjoint regions so none pre-warmed another: 250 // window rows find emit (set strategy on: cost 0.4392, Index on ffRun, 251 // 3600 f 42,987 0.46 s 7.42 s IDENTICAL plan and cost at all three sizes) 252 // 300 f 3,030 0.45 s 0.55 s 253 // 1200 f 14,387 0.45 s 2.41 s 254 // find is CONSTANT â the frame span contributes nothing to ZIM's cost, because the ffRun 255 // equality index is what gets chosen and the span is a residual filter. emit is LINEAR at 256 // ~5,800 rows/s (72 B/row, ~418 KB/s), flat across a 14x range. So a chunk costs 257 // 0.45 + rows/5800, and that fixed 0.45 s is paid AGAIN for every extra query issued. 258 // Work per second of playback: 300 f = 0.100, 900 f = 0.067, 1200 f = 0.072, 3600 f = 0.066. 259 // â 300 WAS THE WORST OF THE FOUR â and 300 frames at 30 fps is exactly the 10-second 260 // stall that was reported. 900 takes the win (1.5x) without going near the two ceilings: 261 // pViFeed REFUSES a span over 1200, and `li all` is one unbreakable statement, so a 262 // 3600-frame chunk blocks the connection for 7.4 s with no way to be told to stop. 263 const SCENE_CHUNK = 900; // frames per chunk = 30 s of playback (~9,600 rows) 264 // 2 ahead + current = 90 s of lookahead. The engine emits 5,800 rows/s where playback 265 // consumes 321 (10.7 rows/frame x 30 fps) â 18x headroom, so depth buys nothing further. 266 const SCENE_AHEAD = 2; // chunks kept in front of the playhead 267 // â KEEP IS A MEMORY BUDGET, NOT A CACHE POLICY. 8 chunks of 900 would hold ~77,000 rows 268 // in the browser; 3 holds ~29,000 and still covers a 90 s scrub back. The smallest target 269 // this ships to is a watch, and rows retained here are rows that cannot be evicted. 270 const SCENE_KEEP = 3; // chunks retained before eviction 271 272 // One session per (db, proc) for the life of this view. 273 const sceneSessions = new Map(); 274 // key `${db}|${proc}|${run}|${chunkStart}` -> {state:"loading"|"ready"|"error", rows, err} 275 const sceneChunks = new Map(); 276 277 const sceneSessionFor = async (db, proc) => { 278 const key = `${db}|${proc}`; 279 if (sceneSessions.has(key)) return sceneSessions.get(key); 280 const { openSession }
280 = await import("../../../streams/zdbSession.js"); 281 const s = openSession("", { db, proc }); 282 sceneSessions.set(key, s); 283 return s; 284 }; 285 286 const closeSceneSessions = () => { 287 sceneSessions.forEach((s) => { try { s.close(); } catch (_) {} }); 288 sceneSessions.clear(); 289 sceneChunks.clear(); 290 }; 291 292 const chunkStartFor = (frame) => { 293 // â NEVER ASK FOR FRAME 0. MEASURED on .200 2026-08-16: pViFeed reads f0=0/f1=0 as 294 // "no window given" and answers with the ENTIRE corpus â 204,935 rows for run 1 where 295 // the caller asked for one chunk. Flooring at 1 keeps a chunk a chunk. 296 return Math.max(1, Math.floor(Math.max(1, frame) / SCENE_CHUNK) * SCENE_CHUNK); 297 }; 298 299 // Fetch one chunk over the held session and push it to the child the moment it lands. 300 // â EACH CHUNK IS POSTED ON ARRIVAL rather than gathered and posted together: the point 301 // of chunking is that the picture fills progressively, and batching them back up would 302 // reinstate the burst this replaces one layer higher. 303 const loadSceneChunk = async (rd, start) => { 304 const proc = String(rd.proc || "pViFeed"); 305 if (!READ_LANE_PROCS.has(proc)) throw new Error(`read lane not allowed: ${proc}`); 306 const key = `${rd.db}|${proc}|${rd.run}|${start}`; 307 const cached = sceneChunks.get(key); 308 if (cached && (cached.state === "loading" || cached.state === "ready")) return cached; 309 310 const entry = { state: "loading", rows: null, err: null }; 311 sceneChunks.set(key, entry); 312 const f0 = start; 313 const f1 = start + SCENE_CHUNK - 1; 314 try { 315 const session = await sceneSessionFor(rd.db, proc); 316 const rows = []; 317 const r = await session.query("obs", { run: rd.run, f0, f1 }, { 318 expectCols: Number(rd.cols) || 18, 319 onRow: (fields) => rows.push(fields), 320 timeoutMs: Number(rd.timeoutMs) || 30000, 321 }); 322 entry.state = "ready"; 323 entry.rows = rows; 324 postToChild({ 325 type: "ab-preview-read", token: currentPreviewToken, compId: rd.compId, 326 kind: "scene", chunk: start, rows, f0, f1, 327 declared: r.declared, received: r.received, 328 }); 329 } catch (err) { 330 // â A FAILED CHUNK IS REPORTED, NOT SWALLOWED. An absent overlay over moving video 331 // reads as "the engine looked and found nothing" â the most reassuring thing a 332 // safety display can say, and a lie. The child needs the difference between an empty 333 // scene and one nobody could fetch. The entry is left in "error" rather than deleted 334 // so a permanently bad chunk is not re-requested on every frame; a seek clears it. 335 entry.state = "error"; 336 entry.err = (err && err.message) || "scene lane failed"; 337 postToChild({ 338 type: "ab-preview-read", token: currentPreviewToken, compId: rd.compId, 339 kind: "scene", chunk: start, error: entry.err, f0, f1, 340 }); 341 } 342 return entry; 343 }; 344 345 // Evict chunks well behind the playhead. Bounded memory matters here: a 10-minute clip 346 // is 20 chunks and ~200,000 rows, which is not something to hold because nobody wrote 347 // the line that drops it. 348 // â ONLY CHUNKS BEHIND ARE EVICTED. `here - start` is negative for everything the 349 // prefetch just queued, so eviction can never race the prefetch and throw away a chunk 350 // that is still in flight â which would show up as a permanent stall, not a slow one. 351 const evictSceneChunks = (rd, here) => { 352 const keep = SCENE_KEEP * SCENE_CHUNK; 353 sceneChunks.forEach((_v, k) => { 354 const parts = k.split("|"); 355 if (parts[0] !== String(rd.db) || parts[2] !== String(rd.run)) return; 356 const start = Number(parts[3]); 357 if (here - start > keep) sceneChunks.delete(k); 358 }); 359 }; 360 361 // The prefetch itself: the chunk under the playhead first, then the ones ahead of it. 362 // Requests are issued in order and the session serialises them, so "first" really is 363 // first â the chunk being drawn is never queued behind speculative work. 364 // â THE POSITION COMES FROM rd.frame, WHICH THE CHILD WRITES. It used to be read from 365 // rd.f0 â the child's own idea of a window start â and that only worked while the two
366 // sides chunked identically. A child aligned to 1800 and a host chunking at 300 would 367 // both be internally consistent and up to 1,799 frames apart, which shows up as prefetch 368 // that is always behind the playhead rather than as anything that looks like a fault. 369 // rd.f0 is still honoured as a fallback so a page baked before this change still loads. 370 const primeSceneChunks = (rd) => { 371 const here = chunkStartFor(Number(rd.frame) || Number(rd.f0) || 1); 372 for (let i = 0; i <= SCENE_AHEAD; i += 1) { 373 // â THE AHEAD CHUNKS MUST BE RE-ALIGNED, NOT JUST OFFSET. chunkStartFor FLOORS AT 1 374 // (frame 0 makes pViFeed answer with the entire corpus), so `here` is 1 at the start of 375 // a run while every later chunk lands on a multiple of SCENE_CHUNK. Adding the stride 376 // to an unaligned `here` therefore primed 1, 901, 1801, 2701 while playback asked for 377 // 1, 900, 1800, 2700 â so EVERY boundary was a cache miss that fetched the chunk a 378 // SECOND time, synchronously, at the exact moment it was needed. That is a visible 379 // freeze once per chunk and a straight 2x on engine load, and neither reports an error: 380 // the rows are correct, the counts reconcile, it is just all done twice. Re-flooring 381 // each candidate through chunkStartFor puts the prefetch on the same grid as the ask. 382 const start = chunkStartFor(here + i * SCENE_CHUNK); 383 const key = `${rd.db}|${String(rd.proc || "pViFeed")}|${rd.run}|${start}`; 384 const c = sceneChunks.get(key); 385 if (c && (c.state === "loading" || c.state === "ready")) { 386 // Already have it (or it is coming) â re-push a ready chunk so a child that 387 // remounted or lost the message is not left waiting for data the host holds. 388 if (c.state === "ready") { 389 postToChild({ 390 type: "ab-preview-read", token: currentPreviewToken, compId: rd.compId, 391 kind: "scene", chunk: start, rows: c.rows, 392 f0: start, f1: start + SCENE_CHUNK - 1, 393 declared: c.rows.length, received: c.rows.length, 394 }); 395 } 396 continue; 397 } 398 loadSceneChunk(rd, start); // deliberately not awaited: chunks land as they land 399 } 400 evictSceneChunks(rd, here); 401 }; 402 403 const postToChild = (msg) => { 404 try { iframe.contentWindow && iframe.contentWindow.postMessage(msg, "*"); } catch (_) {} 405 }; 406 407 // â A PLAYHEAD MOVING IS NOT NEWS TO A TABLE. The child re-announces the SAME reads with 408 // only `frame` changed every time playback crosses a chunk boundary, and this handler used 409 // to answer that by re-running EVERY read on the page â each of which opened its own ws-dba 410 // connection, i.e. forked its own engine child. MEASURED on .200 2026-08-17 from the relay's 411 // own log during ONE video playing: `connId=4337` ⦠`connId=4365`, 28 children in 44 s, the 412 // last two exactly 30 s apart, which is exactly one chunk crossing. Nothing had changed but 413 // the playhead. Strip the playhead fields and compare: identical means "prime the scene, 414 // touch nothing else" â and priming is idempotent, so an already-held chunk costs nothing. 415 // 416 // Compare on a stripped copy rather than trusting a flag from the child: the child cannot be 417 // relied upon to say WHY it re-announced, and a widget genuinely rebinding must still get a 418 // full re-init. A spurious mismatch here falls back to the old full path, which is safe. 419 const readsSignature = (reads) => 420 JSON.stringify((Array.isArray(reads) ? reads : []).map((r) => { 421 const { frame, f0, f1, ...rest } = r || {}; 422 return rest; 423 })); 424 425 const refetchReads = async (opts = {}) => { 426 const sceneOnly = opts && opts.sceneOnly === true; 427 if (!iframe.contentWindow || !readRelay.reads.length) return; 428 for (const rd of readRelay.reads) { 429 try { 430 // The scene lane is windowed and the CHILD owns the window: it re-announces its 431 // reads (same ab-preview-reads-init message) with new f0/f1 as playback moves, so 432 // no new protocol is needed for it to page through a 10-minute clip. 433 if (rd.kind === "scene") { 434 // â THE SCENE IS NOT POLLED AND MUST NOT BE. It is driven by where the playhead 435 // is, which the child announces; a timer asking again for a corpus that only 436 // changes when the driver publishes is the REST reflex, and it was what turned
437 // a connect-per-call into a new engine child every couple of seconds. 438 // primeSceneChunks is idempotent â chunks already held or in flight are skipped, 439 // so arriving here from a re-announce costs nothing but the ones genuinely 440 // missing. It does not await: chunks are pushed to the child as they land. 441 primeSceneChunks(rd); 442 continue; 443 } 444 // sceneOnly = "the playhead moved and nothing else did". Every read past this line 445 // costs a round trip, so the playhead must not be allowed to buy one. 446 if (sceneOnly) continue; 447 // Series (charts) route to the aggregate/bucket lanes, which return a row 448 // SET (one row per group/bucket) rather than the filter lane's records. 449 // Without this a published chart is frozen at whatever was baked on save. 450 if (rd.kind === "series") { 451 const proc = rd.proc === "pFeBeBucket" ? "pFeBeBucket" : "pFeBeAggregate"; 452 let data; 453 let op; 454 if (proc === "pFeBeBucket") { 455 data = { entity: rd.entity, bucket: rd.bucket || {} }; 456 op = "bucket"; 457 } else { 458 data = { entity: rd.entity }; 459 if (rd.groupBy) data.groupBy = rd.groupBy; 460 if (Array.isArray(rd.aggregates) && rd.aggregates.length) data.aggregates = rd.aggregates; 461 if (rd.where) data.where = resolveWhereTokens(rd.where); 462 op = "aggregate"; 463 } 464 const sr = await callAdminProc("", { proc, op, data, db: rd.db || "ZimAdmin" }); 465 const srows = Array.isArray(sr && sr.rows) ? sr.rows : []; 466 const cm = rd.chartMap || {}; 467 const firstKeys = Object.keys(srows[0] || {}); 468 const xKey = cm.x || (proc === "pFeBeBucket" ? "bucket" : (rd.groupBy || firstKeys[0] || "")); 469 const yKey = cm.y || (proc === "pFeBeBucket" 470 ? "count" 471 : (firstKeys.find((k) => k !== xKey && Number.isFinite(Number((srows[0] || {})[k]))) || firstKeys[1] || firstKeys[0] || "")); 472 try { 473 iframe.contentWindow.postMessage( 474 { type: "ab-preview-read", token: currentPreviewToken, compId: rd.compId, kind: "series", rows: srows, xKey, yKey }, 475 "*" 476 ); 477 } catch (_) { /* iframe gone */ } 478 continue; 479 } 480 // Scalar counter served by the aggregate lane: one row out of the engine 481 // instead of the whole entity into the browser. Same "count" push shape, 482 // so the sandboxed runtime renders it with no change. 483 if (rd.kind === "count" && rd.proc === "pFeBeAggregate") { 484 const data = { entity: rd.entity, aggregates: rd.aggregates || [{ fn: "count", field: "", as: "n" }] }; 485 if (rd.where) data.where = resolveWhereTokens(rd.where); 486 const ar = await callAdminProc("", { proc: "pFeBeAggregate", op: "aggregate", data, db: rd.db || "ZimAdmin" }); 487 const row0 = (Array.isArray(ar && ar.rows) ? ar.rows : [])[0] || {}; 488 const picked = Number(row0[rd.pick]); 489 try { 490 iframe.contentWindow.postMessage( 491 { type: "ab-preview-read", token: currentPreviewToken, compId: rd.compId, kind: "count", value: Number.isFinite(picked) ? picked : 0 }, 492 "*" 493 ); 494 } catch (_) { /* iframe gone */ } 495 continue; 496 } 497 const fieldMap = rd.kind === "rows" 498 ? (rd.columns || []).filter((c) => String(c.key || "").includes(".")).map((c) => ({ dbField: c.key, formField: c.key })) 499 : (rd.fieldMap && rd.fieldMap.length ? rd.fieldMap : [{ dbField: `${rd.entity}.x`, formField: "field0" }]); 500 // Pass the saved query through. pFeBeFilterHandler reads predicate/sort/ 501 // limit/offset (sort is an ARRAY â "sort[1]" â not an object); dropping 502 // them makes every poll a full-entity scan whose rows are then discarded 503 // client-side. Relative-time tokens resolve here, same as the series lane. 504 const filterData = { entity: rd.entity, fieldMap, filter: {} }; 505 if (rd.predicate) filterData.predicate = resolvePredicateTokens(rd.predicate);
506 if (Array.isArray(rd.sort) && rd.sort.length) filterData.sort = rd.sort; 507 if (Number.isFinite(Number(rd.limit)) && Number(rd.limit) > 0) filterData.limit = Number(rd.limit); 508 if (Number.isFinite(Number(rd.offset)) && Number(rd.offset) > 0) filterData.offset = Number(rd.offset); 509 const r = await callAdminProc("", { proc: "pFeBeFilterHandler", op: "filter", data: filterData, db: rd.db || "ZimAdmin" }); 510 let msg; 511 if (rd.kind === "count") { 512 const n = (r && r.result && Number.isFinite(r.result.count)) ? r.result.count : (Array.isArray(r && r.rows) ? r.rows.length : 0); 513 msg = { type: "ab-preview-read", token: currentPreviewToken, compId: rd.compId, kind: "count", value: n }; 514 } else { 515 const rows = Array.isArray(r && r.rows) ? r.rows : []; 516 const cols = rd.columns || []; 517 const cells = rows.slice(0, 50).map((row) => cols.map((c) => { const v = row[c.key]; return v == null ? "" : v; })); 518 msg = { type: "ab-preview-read", token: currentPreviewToken, compId: rd.compId, kind: "rows", columns: cols.map((c) => c.label), cells }; 519 } 520 try { iframe.contentWindow.postMessage(msg, "*"); } catch (_) {} 521 } catch (err) { 522 // â A SWALLOWED READ IS THE WORST OF THE THREE OUTCOMES. Skipping silently leaves 523 // the baked save-time markup on screen, so a table whose fetch just failed still 524 // reads "No rows" -- i.e. it reports the entity as EMPTY, as a fact, when nobody 525 // got an answer. Tell the widget, and let it say which one happened. 526 try { 527 iframe.contentWindow.postMessage( 528 { type: "ab-preview-read", token: currentPreviewToken, compId: rd.compId, 529 kind: rd.kind, error: String((err && err.message) || err || "read failed") }, 530 "*" 531 ); 532 } catch (_) { /* iframe gone */ } 533 } 534 } 535 }; 536 const extractStreamIdsFromNdjson = (layoutNdjson) => { 537 if (!layoutNdjson || typeof layoutNdjson !== "string") return []; 538 const ids = new Set(); 539 layoutNdjson.split("\n").map((l) => l.trim()).filter(Boolean).forEach((line) => { 540 try { 541 const row = JSON.parse(line); 542 if (row?.type !== "component_config" || !row?.dataBindings || typeof row.dataBindings !== "object") return; 543 Object.values(row.dataBindings).forEach((bindingKey) => { 544 const streamId = String(bindingKey || "").trim().split(".")[0]; 545 if (streamId) ids.add(streamId); 546 }); 547 } catch (_) {} 548 }); 549 return [...ids]; 550 }; 551 const setupPreviewRelay = async (layoutNdjson, token) => { 552 cleanupPreviewRelay(); 553 const streamIds = extractStreamIdsFromNdjson(layoutNdjson); 554 if (!streamIds.length) return; 555 const registry = await getStreamsRegistry(); 556 if (!registry) return; 557 const state = { frameReady: false, latestByStream: new Map(), unsubscribes: [], token: token || "" }; 558 const postRecord = (streamId, record) => { 559 if (!state.frameReady || !iframe.contentWindow) return; 560 try { iframe.contentWindow.postMessage({ type: "ab-preview-stream-data", token: state.token, streamId, record }, PREVIEW_CHILD_ORIGIN); } catch (_) {} 561 }; 562 streamIds.forEach((streamId) => { 563 try { 564 const unsub = registry.subscribe(streamId, (records) => { 565 if (!records?.length) return; 566 const latest = records[records.length - 1]; 567 state.latestByStream.set(streamId, latest); 568 postRecord(streamId, latest); 569 }); 570 state.unsubscribes.push(unsub); 571 } catch (_) {} 572 try { 573 const cached = registry.getRecords?.(streamId); 574 if (Array.isArray(cached) && cached.length) state.latestByStream.set(streamId, cached[cached.length - 1]); 575 } catch (_) {} 576 }); 577 previewRelayState = state; 578 }; 579 // ââ Write relay ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 580 // A form in the sandboxed preview iframe can't reach the DB. It posts its 581 // payload here; the host (same-origin, holds the JWT) runs the FeBe write 582 // handler over ws-dba and posts the result back into the iframe. Only the two 583 // known write handlers are allowed, and the request is token-gated. 584 const WRITE_HANDLERS = new Set(["pFeBeSubmitHandler", "pFeBeSaveHandler"]); 585 window.addEventListener("message", async (event) => { 586 if (!iframe.contentWindow || event.source !== iframe.contentWindow) return; 587 if (event.origin !== PREVIEW_CHILD_ORIGIN) return; 588 const data = event.data; 589 if (!data || data.type !== "ab-preview-write") return; 590 if (!currentPreviewToken || data.token !== currentPreviewToken) return; 591 const w = (data && data.write) || {}; 592 const post = (ok, message) => {
593 try { 594 // The preview iframe is sandboxed â OPAQUE origin. You cannot target an 595 // opaque origin with the string "null"; postMessage silently drops it. 596 // "*" is required (the payload carries no secrets and the child re-checks 597 // the token). Posting with PREVIEW_CHILD_ORIGIN here was why the form hung 598 // on "Submittingâ¦" even though the row was written. 599 iframe.contentWindow.postMessage( 600 { type: "ab-preview-write-result", token: currentPreviewToken, formId: data.formId, ok, message }, 601 "*" 602 ); 603 } catch (_) {} 604 }; 605 if (!WRITE_HANDLERS.has(w.handler)) { post(false, "handler not allowed"); return; } 606 if (!w.entity || !Array.isArray(w.fieldMap)) { post(false, "invalid form config"); return; } 607 try { 608 const op = w.handler === "pFeBeSaveHandler" ? "save" : "submit"; 609 await callAdminProc("", { proc: w.handler, op, data: { entity: w.entity, fieldMap: w.fieldMap, payload: w.payload || {} }, db: w.db || "ZimAdmin" }); 610 post(true, `Saved to ${w.entity}`); 611 refetchReads(); // a new row landed â refresh bound tables/counters now 612 } catch (err) { 613 post(false, (err && err.message) || "Write failed"); 614 } 615 }); 616 617 // Read widgets announce their bindings once the preview script is up. Start the 618 // poll and do an immediate fetch so the widgets show fresh data promptly. 619 window.addEventListener("message", (event) => { 620 if (!iframe.contentWindow || event.source !== iframe.contentWindow) return; 621 if (event.origin !== PREVIEW_CHILD_ORIGIN) return; 622 const d = event.data; 623 if (!d || d.type !== "ab-preview-reads-init") return; 624 if (!currentPreviewToken || d.token !== currentPreviewToken) return; 625 const reads = Array.isArray(d.reads) ? d.reads : []; 626 627 // Playhead re-announce: same reads, new frame. Adopt the new frame and prime the scene. 628 // â THE TIMER IS DELIBERATELY LEFT RUNNING. stopReadRelay/setInterval here would restart 629 // the poll clock on every chunk crossing, so a page whose chunks cross faster than its 630 // poll period would never actually poll â a bug that hides itself by looking quiet. 631 if (readRelay.reads.length && readsSignature(reads) === readsSignature(readRelay.reads)) { 632 readRelay.reads = reads; 633 refetchReads({ sceneOnly: true }); 634 return; 635 } 636 637 stopReadRelay(); 638 readRelay = { reads, timer: null }; 639 refetchReads(); 640 // Poll no faster than the SLOWEST widget on the page asks for. 6s suits a 641 // filter-lane count; it is the wrong cadence for a grouped aggregate, whose 642 // group discovery walks every distinct key in the entity on every call 643 // (MEASURED on the EWT unit: ~6.3s of Pi CPU for a 202-group pass). Polling 644 // that every 6s pins a core permanently to redraw a chart â on a device whose 645 // whole design goal is not burning power on avoidable work. A lane declares 646 // its own cadence via febelane.pollMs; the floor stops a bad value from 647 // turning the viewer into a load generator. 648 // â SCENE READS ARE EXCLUDED FROM THE TIMER ENTIRELY. They are driven by the playhead 649 // (the child re-announces as it moves) and served from the held session's chunk cache, 650 // so a timer would re-ask for a corpus that changes only when the driver publishes. An 651 // app whose only read is a scene now starts NO interval at all, rather than one that 652 // does nothing but keep the page awake. 653 const polled = readRelay.reads.filter((r) => r && r.kind !== "scene"); 654 if (!polled.length) return; 655 const periods = polled 656 .map((r) => Number(r && r.pollMs)) 657 .filter((n) => Number.isFinite(n) && n > 0); 658 // ââ POLLING IS NOW OPT-IN, AND THIS IS A BEHAVIOUR CHANGE BEYOND VI. A read widget that 659 // does not DECLARE a pollMs used to be refreshed every 6 s forever; it now loads ONCE. 660 // The default was a REST reflex wearing a WebSocket: this engine is a session manager and 661 // the socket is bidirectional, so the correct shape for "the data changed" is the engine 662 // PUSHING, not the page asking on a clock. Until a given lane has that push, a widget that 663 // genuinely needs a repeating read must SAY SO (febelane.pollMs), which also makes every 664 // recurring cost on the page visible in the app definition instead of implicit here. 665 // The 2000 ms floor still stops a bad declared value turning the viewer into a load 666 // generator; the difference is that no value at all now means no timer at all. 667 if (!periods.length) return; 668 const period = Math.max(2000, Math.max(...periods)); 669 readRelay.timer = setInterval(refetchReads, period); 670 }); 671 672 window.addEventListener("message", (event) => { 673 if (!previewRelayState || !iframe.contentWindow || event.source !== iframe.contentWindow) return; 674 if (event.data?.type !== "ab-preview-ready") return; 675 if (event.origin !== PREVIEW_CHILD_ORIGIN) return; 676 if (!event.data?.token || event.data.token !== previewRelayState.token) return; 677 previewRelayState.frameReady = true; 678 for (const [streamId, latest] of previewRelayState.latestByStream.entries()) {
679 try { iframe.contentWindow.postMessage({ type: "ab-preview-stream-data", token: previewRelayState.token, streamId, record: latest }, PREVIEW_CHILD_ORIGIN); } catch (_) {} 680 } 681 }); 682 683 // ââ Device switching âââââââââââââââââââââââââââââââââââââââââââââââââââââââ 684 function applyDevice(deviceId) { 685 deviceBtns.forEach(b => b.classList.toggle("av-btn-active", b.dataset.device === deviceId)); 686 const preset = DEVICES.find(d => d.id === deviceId); 687 if (preset?.width) { 688 iframeWrap.style.width = `${preset.width}px`; 689 iframeWrap.style.maxWidth = "100%"; 690 iframeWrap.style.boxShadow = "0 4px 24px rgba(0,0,0,.2)"; 691 iframeWrap.style.borderRadius = "8px"; 692 iframeWrap.style.overflow = "hidden"; 693 } else { 694 iframeWrap.style.width = "100%"; 695 iframeWrap.style.maxWidth = ""; 696 iframeWrap.style.boxShadow = ""; 697 iframeWrap.style.borderRadius = ""; 698 iframeWrap.style.overflow = ""; 699 } 700 } 701 702 deviceBtns.forEach(btn => 703 btn.addEventListener("click", () => applyDevice(btn.dataset.device)) 704 ); 705 706 // ââ Preview builder ââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 707 async function loadPreview() { 708 stopReadRelay(); // clear any prior poll before the new frame announces itself 709 // â AND CLOSE THE HELD SESSIONS. Loading a different app into the same viewer replaces 710 // the iframe, so the old child's sessions have nobody left to serve â but nothing closes 711 // them: they are held BY DESIGN across queries, which is exactly why they cannot be left 712 // to an implicit teardown. Every session is an engine child holding a dictionary and a 713 // pool slot, so a session leaked per document swap is a leak an operator produces just by 714 // browsing. dispose() covers the page going away; this covers the far more common case 715 // of it staying and showing something else. 716 closeSceneSessions(); 717 const project = projInput.value.trim() || "MainProject"; 718 const layout = layoutInput.value.trim() || "Main"; 719 let layoutNdjson = serverNdjson;
720 if (!layoutNdjson && runDocId) { 721 try { 722 const { loadDoc } = await import("../../common/shellDocStore.js"); 723 layoutNdjson = await loadDoc({ shellType: "appbuilder", docId: runDocId }); 724 serverNdjson = layoutNdjson; // cache for device re-renders 725 } catch (e) { console.warn("[AppViewer] server load failed:", e); } 726 } 727 if (!layoutNdjson) { 728 try { 729 layoutNdjson = localStorage.getItem(APPBUILDER_LAYOUT_KEY(project, layout)); 730 } catch (_) {} 731 } 732 // Server-doc fallback: derive the appbuilder docId from the current 733 // project/layout and load it from the shared doc store. localStorage is 734 // per-browser-profile, so an app saved in another session/device is 735 // invisible to it â without this, opening the viewer on a fresh browser 736 // shows "No content to preview" even though the app IS saved on the server. 737 if (!layoutNdjson) { 738 try { 739 const { loadDoc, makeDocId } = await import("../../common/shellDocStore.js"); 740 const raw = `${project || "app"}-${layout || "main"}` 741 .toLowerCase().replace(/[^a-z0-9-]/g, "-").slice(0, 36); 742 layoutNdjson = await loadDoc({ shellType: "appbuilder", docId: makeDocId("appbuilder", raw) }); 743 serverNdjson = layoutNdjson; // cache for device re-renders 744 } catch (e) { console.warn("[AppViewer] derived server-doc load failed:", e); } 745 } 746 let html; 747 const previewRelayToken = makePreviewRelayToken(); 748 currentPreviewToken = previewRelayToken; // used by the write-relay handler 749 const previewParentOrigin = window.location.origin; 750 try { 751 ({ html } = buildProductionHTML(project, layout, { layoutNdjson, previewRelayToken, previewParentOrigin })); 752 } catch (err) { 753 console.error("[AppViewer] buildProductionHTML failed:", err); 754 html = `<body style="font:14px/1.6 system-ui;padding:2rem;color:#1e293b"> 755 <h2 style="color:#dc2626">Preview failed</h2> 756 <pre style="background:#fef2f2;padding:1rem;border-radius:6px;overflow:auto">${err.message}</pre> 757 <p>Make sure the Project and Layout names match what is on the canvas.</p> 758 </body>`; 759 } 760 // Use srcdoc so the iframe is reloaded cleanly without document.write quirks. 761 // The sandbox attribute (no allow-same-origin) already prevents the iframe 762 // from accessing the parent document context. 763 iframe.srcdoc = html; 764 setupPreviewRelay(layoutNdjson, previewRelayToken); 765 } 766 767 previewBtn.addEventListener("click", loadPreview); 768 reloadBtn.addEventListener("click", loadPreview); 769 770 // Allow pressing Enter in either input field to trigger preview 771 [projInput, layoutInput].forEach(input => 772 input.addEventListener("keydown", e => { if (e.key === "Enter") loadPreview(); }) 773 ); 774 775 // Auto-load on first mount 776 loadPreview(); 777 778 // â THE SESSION MUST BE CLOSED WITH THE VIEW. A held connection is the whole point of 779 // this lane, and it is also the whole risk: an unheld one leaked a child per query, an 780 // unclosed one leaks a child per VIEW and never gets it back. Every engine child holds a 781 // ZIM session, a dictionary and a pool slot, so a viewer opened and closed a dozen times 782 // would strand a dozen of them â a slower leak than the one this replaced, and a harder 783 // one to see, because it only shows up after a day of ordinary use. 784 return { api: { dispose: () => { cleanupPreviewRelay(); stopReadRelay(); closeSceneSessions(); } } }; 785} 786 787export function extendToolbar() { /* no-op */ } 788export function update() { /* no-op */ }
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.