@christianirack/brainllm-runtime 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,36 @@
1
+ // Pure bounded transport shared by the page fallback and its dedicated module worker.
2
+ // No owners, DOM hooks, listeners or app modules execute when this module is imported.
3
+ export async function prefetchPage(url, signal) {
4
+ try {
5
+ const r = await fetch(url, { headers: { 'x-brain-nav': '1', 'x-brain-prefetch': '1' }, credentials: 'same-origin', priority: 'low', redirect: 'error', signal })
6
+ const limit = 512 * 1024
7
+ if (!r.ok || r.redirected || !/text\/html/i.test(r.headers?.get('content-type') || '') || +(r.headers.get('content-length') || 0) > limit) {
8
+ await r.body?.cancel?.(); return null
9
+ }
10
+ let text, bytes = 0
11
+ if (r.body?.getReader) {
12
+ const reader = r.body.getReader(), decoder = new TextDecoder(), parts = []
13
+ try {
14
+ for (;;) {
15
+ const { done, value } = await reader.read()
16
+ if (done) break
17
+ bytes += value.byteLength
18
+ if (bytes > limit) { await reader.cancel(); return null }
19
+ parts.push(decoder.decode(value, { stream: true }))
20
+ }
21
+ parts.push(decoder.decode()); text = parts.join('')
22
+ } finally { reader.releaseLock?.() }
23
+ } else {
24
+ text = await r.text(); bytes = text.length * 2
25
+ if (bytes > limit) return null
26
+ }
27
+ const assets = [], base = new URL(url)
28
+ const pattern = /<script[^>]+src=["']([^"']+)["']|\bfrom\s*["']([^"']+\.(?:js|jsbrain))["']|\bimport\(\s*["']([^"']+\.(?:js|jsbrain))["']/g
29
+ let hit
30
+ while ((hit = pattern.exec(text)) && assets.length < 16) {
31
+ const asset = new URL(hit[1] || hit[2] || hit[3], base)
32
+ if (asset.origin === base.origin && asset.pathname.includes('/_b/')) assets.push(asset.href)
33
+ }
34
+ return { ok: true, status: r.status, url: r.url, headers: r.headers, text, bytes, assets }
35
+ } catch { return null }
36
+ }
@@ -0,0 +1,19 @@
1
+ // Dedicated speculative-fetch worker. Only bounded HTML/assets cross the bridge; app scripts never run.
2
+ import { prefetchPage } from './brain-prefetch-shared.js'
3
+ const jobs = new Map()
4
+ self.onmessage = async e => {
5
+ const m = e.data
6
+ if (!m || !Number.isSafeInteger(m.id)) return
7
+ if (m.cancel) { jobs.get(m.id)?.abort(); return }
8
+ if (jobs.size >= 2 || jobs.has(m.id) || typeof m.url !== 'string') return
9
+ let url
10
+ try { url = new URL(m.url); if (url.origin !== self.location.origin) return } catch { return }
11
+ const controller = new AbortController()
12
+ jobs.set(m.id, controller)
13
+ const timer = setTimeout(() => controller.abort(), 6000)
14
+ try {
15
+ const result = await prefetchPage(url.href, controller.signal)
16
+ self.postMessage(result ? { id: m.id, ...result, headers: [...result.headers] } : { id: m.id, ok: false })
17
+ } catch { self.postMessage({ id: m.id, ok: false }) }
18
+ finally { clearTimeout(timer); controller.abort(); jobs.delete(m.id) }
19
+ }
package/brain-router.js CHANGED
@@ -13,6 +13,7 @@
13
13
  // `false` and redirects with `redirect('/login')` (thrown or returned) or a string. The server runs the same guard,
14
14
  // so a redirect it answers (fetch ends on another URL) is reflected in the address bar. A guard that throws anything
15
15
  // else → full page load: the server decides.
16
+ import { prefetchPage } from './brain-prefetch-shared.js'
16
17
  const G = globalThis
17
18
  // Base path (<meta name="brain-base" content="/app/">, §23 #148): links in the HTML already carry it; guards and
18
19
  // routes work with app paths (strip), and app paths given to go()/navigate()/redirect() get it (app). '/' when absent.
@@ -33,7 +34,9 @@ const app = u => {
33
34
  // guards run); without a router it is a normal page load.
34
35
  let active = null
35
36
  export function navigate(to, o = {}) {
36
- const u = app(String(to))
37
+ const raw = String(to)
38
+ if (/[\\\x00-\x20\x7f]/.test(raw)) throw Error('Invalid navigation target')
39
+ const u = app(raw)
37
40
  if (active) return active.go(u, o.replace ? 'replace' : true)
38
41
  o.replace ? location.replace(u) : location.assign(u)
39
42
  }
@@ -89,8 +92,9 @@ const outcome = v =>
89
92
  export function router(o = {}) {
90
93
  const region = o.region || 'main'
91
94
  const parse = o.parse || (h => new DOMParser().parseFromString(h, 'text/html'))
95
+ const fullLoad = new Set(Array.isArray(o.fullLoad) ? o.fullLoad.map(p => { try { return new URL(p, location.href).pathname.replace(/\/+$/, '') || '/' } catch { return null } }).filter(Boolean) : [])
92
96
  let seq = 0, through = null, stale = false
93
- const ahead = predict(o, u => same(u))
97
+ const ahead = predict(o, u => same(u, true))
94
98
  // Build signature (§35.7): the page's <meta name="brain-build">. A navigation response from another build, or the
95
99
  // service worker announcing one, turns client navigation into full loads so old and new code never mix.
96
100
  const build = () => {
@@ -98,7 +102,7 @@ export function router(o = {}) {
98
102
  return m ? m.getAttribute('content') : null
99
103
  }
100
104
  const sw = G.navigator && navigator.serviceWorker
101
- const onMsg = e => typeof e.data === 'string' && e.data.startsWith('brain:build ') && build() && e.data.slice(12) !== build() && (stale = true)
105
+ const onMsg = e => typeof e.data === 'string' && e.data.startsWith('brain:build ') && build() && e.data.slice(12) !== build() && (stale = true, ahead.stop())
102
106
  if (sw && sw.addEventListener) sw.addEventListener('message', onMsg)
103
107
  // a guard is a function, or the URL of its `_guard.jsbrain` chunk (BUILD #220): loaded the first time a navigation
104
108
  // reaches that folder, then cached by the module map; its default export runs with the same ctx
@@ -130,11 +134,11 @@ export function router(o = {}) {
130
134
  }
131
135
 
132
136
  // _loading.brain (#235): when the next page takes longer than 150 ms, the nearest loading view of the target route
133
- // (`loading: [{ rx, view }]`, most specific first, emitted by the build with the `core` URL) is mounted in the
137
+ // (`loading: [{ rx: string | string[], view }]`, most specific first, emitted by the build with the `core` URL) is mounted in the
134
138
  // region until the page arrives; fast navigations never show it, so nothing flickers.
135
139
  const loads = o.loading || []
136
140
  function waiting(url, my) {
137
- const path = strip(new URL(url, location.href).pathname), l = loads.find(x => new RegExp(x.rx).test(path))
141
+ const path = strip(new URL(url, location.href).pathname), l = loads.find(x => (Array.isArray(x.rx) ? x.rx : [x.rx]).some(rx => new RegExp(rx).test(path)))
138
142
  const w = { done: false, t: 0 }
139
143
  if (!l || !o.core) return w
140
144
  w.t = setTimeout(() => {
@@ -223,6 +227,9 @@ export function router(o = {}) {
223
227
  if (pb && document.head) document.head.appendChild(document.adoptNode ? document.adoptNode(pb) : pb)
224
228
  routed()
225
229
  current()
230
+ const oldPlan = document.getElementById('brain-next'), newPlan = doc.querySelector('#brain-next')
231
+ if (oldPlan) oldPlan.remove()
232
+ if (newPlan) document.body.appendChild(document.adoptNode ? document.adoptNode(newPlan) : newPlan)
226
233
  ahead.scan()
227
234
  }
228
235
  if (document.startViewTransition) {
@@ -233,11 +240,11 @@ export function router(o = {}) {
233
240
  }
234
241
  // a full load must not be intercepted again (Navigation API): `through` lets that one navigation pass
235
242
  const hard = url => ((through = new URL(url, location.href).href), (o.hard || (u => location.assign(u)))(url))
236
- const same = u => {
243
+ const same = (u, prefetch = false) => {
237
244
  try {
238
245
  const x = new URL(u, location.href), b = base()
239
246
  // same origin and inside the app's base path (§23 #148); anything else is a full load
240
- return x.origin === location.origin && (x.pathname + '/').startsWith(b) && !(o.skip && o.skip(x))
247
+ return x.origin === location.origin && (x.pathname + '/').startsWith(b) && !(o.skip && o.skip(x)) && (prefetch || !fullLoad.has(x.pathname.replace(/\/+$/, '') || '/'))
241
248
  } catch {
242
249
  return false
243
250
  }
@@ -251,6 +258,7 @@ export function router(o = {}) {
251
258
  if (!e.canIntercept || e.hashChange || e.downloadRequest || e.formData || !same(e.destination.url)) return
252
259
  if (e.cancelable && !leave()) return e.preventDefault()
253
260
  const to = e.destination.url, from = nav.currentEntry && nav.currentEntry.url
261
+ if (G.__brainURLOnly?.(from || location.href, to)) return e.intercept({ handler: () => G.__brainURLRestore?.() })
254
262
  if (!gs.length) return e.intercept({ handler: () => load(to) }) // scroll + focus reset handled by the browser
255
263
  let g
256
264
  e.intercept({
@@ -282,6 +290,7 @@ export function router(o = {}) {
282
290
  let here = location.href
283
291
  const go = async (u, push = true, hops = 0) => {
284
292
  if (!hops && !leave()) return push || history.pushState(history.state, '', here) // stay (back/forward: restore the URL)
293
+ if (!same(u)) return hard(u)
285
294
  const g = gs.length ? await check(u) : true
286
295
  if (g === null || hops > 5) return hard(u)
287
296
  if (g === false) return push || history.pushState(history.state, '', here) // back/forward blocked: stay
@@ -304,7 +313,10 @@ export function router(o = {}) {
304
313
  e.preventDefault()
305
314
  go(u.href)
306
315
  }
307
- const pop = () => go(location.href, false)
316
+ const pop = () => {
317
+ if (G.__brainURLOnly?.(here, location.href)) { here = location.href; G.__brainURLRestore?.(); return }
318
+ return go(location.href, false)
319
+ }
308
320
  const focusMain = () => {
309
321
  const m = document.querySelector(region), h = m && (m.querySelector('h1') || m)
310
322
  if (h && h.focus) {
@@ -330,12 +342,13 @@ export function router(o = {}) {
330
342
  // intent prefetches there, and never with Save-Data), `data-prefetch="false"` on the link or an ancestor, download or
331
343
  // target links, files (`.pdf`, `.zip`…), `/api/` and `/auth/`, action-style paths (log out, unsubscribe, confirm,
332
344
  // verify), URLs carrying a token in the query, and everything with `router({ prefetch: false })` or
333
- // `<meta name="brain-prefetch" content="off">`. A page served `private`/`no-store` is reused for 30 s, others for 5 min,
345
+ // `<meta name="brain-prefetch" content="off">`. Private/no-store and dynamic no-cache HTML is never reused; static
346
+ // pages and explicitly cacheable public HTML stay in bounded tab memory for at most 5 min,
334
347
  // and any write empties the memory first: a form submit, a `brain:write` (brain-data writes, every server function
335
348
  // call, log-out included), so the next view never shows data from before the write or another session. Each fetch says
336
349
  // `x-brain-prefetch: 1` (`ctx.prefetch` in the server's `load`), so a server can tell it apart from a click.
337
- const FRESH = 300000, FRESH_PRIVATE = 30000, MAX_PAGE = 12, MAX_KEPT = 24, PARALLEL = 2
338
- const NOPE = /(^|\/)(log-?out|sign-?out|logoff|unsubscribe|confirm|verify|api|auth)(\.html?)?(\/|$)/i
350
+ const FRESH = 300000, MAX_PAGE = 12, MAX_KEPT = 24, PARALLEL = 2
351
+ const NOPE = /(^|\/)(log-?out|sign-?out|logoff|unsubscribe|confirm|verify|api|auth|_brain|_r)(\.html?)?(\/|$)/i
339
352
  const FILE = /\.(?!html?$)[a-z0-9]{2,5}$/i
340
353
  const SECRET = /(^|[?&])(token|code|key|sig|signature|auth|otp|state)=/i
341
354
  export function predict(o = {}, same = () => false) {
@@ -344,79 +357,114 @@ export function predict(o = {}, same = () => false) {
344
357
  if (o.prefetch === false || (meta && /^(off|false|0)$/i.test(meta.getAttribute('content') || '')) || !G.document || !G.fetch) return off
345
358
  const net = G.navigator && navigator.connection
346
359
  const thin = () => !!net && (net.saveData || /(^|-)2g$/.test(net.effectiveType || ''))
347
- const cache = new Map(), queue = [], seen = new WeakSet(), pre = new Set()
348
- let busy = 0, count = 0, io = null, idle = 0
360
+ const cache = new Map(), queue = [], pre = new Map(), running = new Map(), pending = new Map(), planned = new Map()
361
+ let seen = new WeakSet(), busy = 0, count = 0, io = null, idle = 0, epoch = 0, dead = false, bytes = 0, worker = null, serial = 0
349
362
  const key = h => {
350
- try {
351
- const u = new URL(h, location.href)
352
- u.hash = ''
353
- return u.href
354
- } catch {
355
- return null
356
- }
363
+ try { const u = new URL(h, location.href); u.hash = ''; return u.href } catch { return null }
364
+ }
365
+ const safe = k => {
366
+ if (!k || !same(k) || k === key(location.href)) return false
367
+ const u = new URL(k)
368
+ return !NOPE.test(u.pathname) && !FILE.test(u.pathname) && !SECRET.test(u.search)
357
369
  }
358
370
  const ok = a => {
359
371
  if (!a || !a.getAttribute || !a.getAttribute('href') || a.getAttribute('target') || a.hasAttribute('download')) return null
360
372
  for (let n = a; n && n.getAttribute; n = n.parentNode) if (n.getAttribute('data-prefetch') === 'false') return null
361
373
  const k = key(a.href || a.getAttribute('href'))
362
- if (!k || !same(k) || k === key(location.href)) return null
363
- const u = new URL(k)
364
- if (NOPE.test(u.pathname) || FILE.test(u.pathname) || SECRET.test(u.search)) return null
365
- return k
374
+ return safe(k) ? k : null
366
375
  }
367
- // the next page's chunks: module scripts and static imports in its HTML
368
- const chunks = html => {
369
- if (!document.head) return
370
- const re = /<script[^>]+type="module"[^>]+src="([^"]+)"|\bfrom\s*"([^"]+\.js)"|\bimport\(\s*"([^"]+\.js)"\s*\)/g
371
- let m, n = 0
372
- while ((m = re.exec(html)) && n < 8) {
373
- const u = key(m[1] || m[2] || m[3])
374
- if (!u || pre.has(u) || !same(u)) continue
375
- pre.add(u), n++
376
+ const warm = list => {
377
+ if (!document.head || dead) return
378
+ let n = 0
379
+ for (const href of list) {
380
+ const u = key(href)
381
+ if (!u || pre.has(u) || !same(u) || !new URL(u).pathname.includes('/_b/') || ++n > 8) continue
376
382
  const l = document.createElement('link')
377
- l.setAttribute('rel', 'modulepreload')
383
+ l.setAttribute('rel', /\.(js|jsbrain)(\?|$)/.test(u) ? 'modulepreload' : 'prefetch')
378
384
  l.setAttribute('href', u)
385
+ l.setAttribute('fetchpriority', 'low')
379
386
  document.head.appendChild(l)
387
+ pre.set(u, l)
388
+ while (pre.size > 48) { const [k, node] = pre.entries().next().value; node.remove(); pre.delete(k) }
380
389
  }
381
390
  }
382
- // drop what expired, then the oldest past MAX_KEPT (a Map keeps insertion order)
391
+ const remove = k => { const c = cache.get(k); if (c) bytes -= c.bytes || 0; cache.delete(k) }
383
392
  const prune = () => {
384
- const now = Date.now()
385
- for (const [k, c] of cache) if (now - c.at > FRESH) cache.delete(k)
386
- for (const k of cache.keys()) if (cache.size >= MAX_KEPT) cache.delete(k)
393
+ for (const [k, c] of cache) if (Date.now() - c.at > FRESH) remove(k)
394
+ while (cache.size > MAX_KEPT || bytes > 4 * 1024 * 1024) remove(cache.keys().next().value)
387
395
  }
396
+ const crashed = () => {
397
+ worker && worker.terminate(); worker = null
398
+ for (const p of pending.values()) { clearTimeout(p.timer); p.resolve(null) }
399
+ pending.clear()
400
+ }
401
+ if (G.Worker && typeof o.prefetchWorker === 'string') {
402
+ try {
403
+ const url = new URL(o.prefetchWorker, location.href)
404
+ if (url.origin === location.origin) {
405
+ worker = new Worker(url.href, { type: 'module' })
406
+ worker.onmessage = e => {
407
+ const m = e.data, p = m && pending.get(m.id)
408
+ if (!p) return
409
+ clearTimeout(p.timer); pending.delete(m.id); p.resolve(m.ok ? m : null)
410
+ }
411
+ worker.onerror = crashed
412
+ worker.onmessageerror = crashed
413
+ }
414
+ } catch { crashed() }
415
+ }
416
+ const background = (k, controller) => {
417
+ if (worker) return new Promise(resolve => {
418
+ const id = ++serial
419
+ const done = () => { const p = pending.get(id); if (p) { clearTimeout(p.timer); pending.delete(id); resolve(null); worker && worker.postMessage({ id, cancel: true }) } }
420
+ const timer = setTimeout(done, 6500)
421
+ pending.set(id, { resolve, timer })
422
+ controller.signal.addEventListener('abort', done, { once: true })
423
+ try { worker.postMessage({ id, url: k }) } catch { done(); crashed() }
424
+ })
425
+ return prefetchPage(k, controller.signal)
426
+ }
427
+
388
428
  const get = k => {
389
- prune()
390
- const at = Date.now()
391
- const p = fetch(k, { headers: { 'x-brain-nav': '1', 'x-brain-prefetch': '1' }, priority: 'low', credentials: 'same-origin' })
392
- .then(async r => {
393
- const ct = (r.headers && r.headers.get && r.headers.get('content-type')) || 'text/html'
394
- if (!r.ok || !/html/.test(ct)) return null
395
- const text = await r.text()
396
- chunks(text)
397
- const cc = (r.headers.get && r.headers.get('cache-control')) || ''
398
- // a stand-in for the Response that load() reads: headers, status, redirect and the body once per use
399
- return { ok: r.ok, status: r.status, redirected: r.redirected, url: r.url, headers: r.headers, text: async () => text, ttl: /private|no-store/i.test(cc) ? FRESH_PRIVATE : FRESH }
400
- })
401
- .catch(() => null)
402
- cache.set(k, { at, p })
403
- return p
429
+ const own = epoch, controller = new AbortController(), at = Date.now()
430
+ running.set(k, controller)
431
+ const timer = setTimeout(() => controller.abort(), 6000)
432
+ const c = { at, epoch: own, bytes: 0, p: null }
433
+ c.p = background(k, controller).then(m => {
434
+ if (!m || dead || own !== epoch || controller.signal.aborted) { if (cache.get(k) === c) remove(k); return null }
435
+ const headers = Array.isArray(m.headers) ? new Headers(m.headers) : m.headers
436
+ const cc = headers.get('cache-control') || '', freshness = /(?:^|,)\s*(?:s-maxage|max-age)=(\d+)/i.exec(cc)
437
+ if (m.assets) warm(m.assets)
438
+ if (planned.has(k)) warm(planned.get(k))
439
+ // Personalized and dynamically revalidated HTML always goes back through the server on navigation.
440
+ // Speculation still warms its immutable code/assets; it never reuses another tab's prior session data.
441
+ if (/private|no-store/i.test(cc) || /no-cache/i.test(cc) && headers.get('x-brain-static') !== '1') { if (cache.get(k) === c) remove(k); return null }
442
+ // Transient page memory belongs to this tab and write epoch, never to CacheStorage or the worker.
443
+ c.bytes = m.text.length * 2
444
+ if (cache.get(k) === c) bytes += c.bytes
445
+ prune()
446
+ return { ok: m.ok, status: m.status, redirected: false, url: m.url, headers, text: async () => m.text, ttl: Math.min(FRESH, freshness ? +freshness[1] * 1000 : FRESH) }
447
+ }).catch(() => null).finally(() => { clearTimeout(timer); if (running.get(k) === controller) running.delete(k) })
448
+ cache.set(k, c); prune()
449
+ return c.p
404
450
  }
405
451
  const pump = () => {
452
+ if (dead || document.hidden || net && net.saveData) return
406
453
  while (busy < PARALLEL && queue.length) {
407
454
  const k = queue.shift()
408
455
  if (cache.has(k)) continue
409
456
  busy++
410
- get(k).finally(() => (busy--, pump()))
457
+ get(k).finally(() => { busy--; pump() })
411
458
  }
412
459
  }
413
- const later = f => (G.requestIdleCallback ? G.requestIdleCallback(f, { timeout: 2000 }) : setTimeout(f, 200))
460
+ const later = f => G.requestIdleCallback ? G.requestIdleCallback(f, { timeout: 2000 }) : setTimeout(f, 200)
461
+ const cancelIdle = () => { if (idle) { G.cancelIdleCallback ? G.cancelIdleCallback(idle) : clearTimeout(idle); idle = 0 } }
414
462
  const want = (k, now) => {
415
- if (!k || cache.has(k)) return
463
+ if (!k || dead || cache.has(k) || count >= (now ? MAX_KEPT : MAX_PAGE)) return
416
464
  const i = queue.indexOf(k)
417
- if (i >= 0) queue.splice(i, 1)
418
- if (now) queue.unshift(k), pump()
419
- else if (count < MAX_PAGE) count++, queue.push(k), idle || (idle = 1) && later(() => ((idle = 0), pump()))
465
+ if (i >= 0) queue.splice(i, 1); else count++
466
+ if (now) { queue.unshift(k); pump() }
467
+ else { queue.push(k); if (!idle) idle = later(() => { idle = 0; pump() }) }
420
468
  }
421
469
  const intent = e => {
422
470
  if (net && net.saveData) return
@@ -426,38 +474,56 @@ export function predict(o = {}, same = () => false) {
426
474
  }
427
475
  const evs = ['pointerover', 'focusin', 'touchstart']
428
476
  for (const t of evs) document.addEventListener(t, intent, { passive: true, capture: true })
429
- // a write makes every fetched page possibly stale (or another session's after log-out): forget them all
430
- const forget = () => ((queue.length = 0), cache.clear())
477
+ const forget = () => {
478
+ epoch++; cancelIdle(); queue.length = 0; cache.clear(); bytes = 0; count = 0
479
+ for (const c of running.values()) c.abort()
480
+ running.clear()
481
+ }
431
482
  document.addEventListener('submit', forget, { capture: true })
432
483
  if (G.addEventListener) G.addEventListener('brain:write', forget)
433
- if (G.IntersectionObserver)
434
- io = new IntersectionObserver(
435
- es => {
436
- for (const e of es) if (e.isIntersecting) io.unobserve(e.target), thin() || want(ok(e.target), false)
437
- },
438
- { rootMargin: '200px' }
439
- )
484
+ const visible = () => { if (!document.hidden) pump() }
485
+ document.addEventListener('visibilitychange', visible)
486
+ if (G.IntersectionObserver) io = new IntersectionObserver(es => {
487
+ for (const e of es) if (e.isIntersecting) { io.unobserve(e.target); if (!thin()) want(ok(e.target), false) }
488
+ }, { rootMargin: '200px' })
440
489
  return {
441
490
  scan() {
442
- count = 0
443
- queue.length = 0 // the previous view's links are no longer the likely next step
444
- if (!io) return
445
- for (const a of document.querySelectorAll('a[href]')) if (!seen.has(a)) seen.add(a), io.observe(a)
491
+ if (dead) return
492
+ count = 0; cancelIdle(); queue.length = 0; planned.clear(); seen = new WeakSet(); io && io.disconnect()
493
+ const anchors = document.querySelectorAll('a[href]'), allowed = new Set()
494
+ for (const a of anchors) { const k = ok(a); if (k) allowed.add(k); if (io && !seen.has(a)) { seen.add(a); io.observe(a) } }
495
+ // Compiler-generated graph, validated against the current DOM: hidden/opted-out links cannot sneak in.
496
+ const plan = document.getElementById && document.getElementById('brain-next')
497
+ if (plan && !thin()) {
498
+ try {
499
+ const map = JSON.parse(plan.textContent)
500
+ if (map.v === 1 && Array.isArray(map.next)) for (const item of map.next.slice(0, MAX_PAGE)) {
501
+ const k = key(item.url)
502
+ if (allowed.has(k) && safe(k)) { if (Array.isArray(item.assets)) planned.set(k, item.assets.slice(0, 16)); want(k, false) }
503
+ }
504
+ } catch {}
505
+ }
446
506
  },
447
507
  stop() {
448
- io && io.disconnect()
508
+ if (dead) return
509
+ forget(); dead = true; crashed(); io && io.disconnect()
449
510
  for (const t of evs) document.removeEventListener(t, intent, { capture: true })
450
511
  document.removeEventListener('submit', forget, { capture: true })
512
+ document.removeEventListener('visibilitychange', visible)
451
513
  G.removeEventListener && G.removeEventListener('brain:write', forget)
452
- cache.clear()
514
+ for (const l of pre.values()) l.remove()
515
+ pre.clear()
453
516
  },
454
- // the prefetched page for this URL when it is still fresh (used once: a later visit fetches again)
455
517
  async take(u) {
456
518
  const k = key(u), c = k && cache.get(k)
457
519
  if (!c) return null
458
- cache.delete(k)
459
- const r = await c.p
460
- return r && Date.now() - c.at < r.ttl ? r : null
520
+ remove(k)
521
+ // A click must not inherit a slow speculative request's six-second deadline.
522
+ let timer
523
+ const r = await Promise.race([c.p, new Promise(resolve => { timer = setTimeout(() => resolve(null), 150) })])
524
+ clearTimeout(timer)
525
+ if (!r) { const controller = running.get(k); controller && controller.abort() }
526
+ return r && !dead && c.epoch === epoch && Date.now() - c.at < r.ttl ? r : null
461
527
  },
462
528
  }
463
529
  }
package/brain-schema.js CHANGED
@@ -9,7 +9,7 @@ const G = globalThis
9
9
  // i18n/<locale>.json; plural objects allowed for keys with `{n}`), else the English text. The table is read directly
10
10
  // (globalThis.__brainDict, filled by brain-i18n.js `add`) so a page that only validates does not load brain-i18n.js.
11
11
  // Keys: form.required form.list form.min_items form.max_items form.item form.number form.min form.max form.email
12
- // form.url form.date form.min_chars form.max_chars.
12
+ // form.url form.date form.text form.boolean form.min_chars form.max_chars.
13
13
  export function msg(key, en, vars) {
14
14
  const D = G.__brainDict
15
15
  if (D) {
@@ -29,50 +29,100 @@ export function msg(key, en, vars) {
29
29
  }
30
30
 
31
31
  // 'str!<=80' → { t: 'str', req: true, min, max, list: false }
32
- export function rule(s) {
33
- if (typeof s === 'object') return { t: 'obj', s, req: false }
34
- const req = s.includes('!'), r = { t: '', req, list: false, min: null, max: null }
35
- let x = s.replace(/!/g, '')
36
- const m = /^(\[?)(\w+)\]?((?:[<>]=-?\d+(?:\.\d+)?)*)$/.exec(x)
37
- if (!m) throw Error(`form: invalid rule '${s}' (use str num bool email url date, ! required, <=N >=N, [T])`)
38
- r.list = !!m[1]
39
- r.t = m[2]
40
- for (const [, op, n] of m[3].matchAll(/([<>])=(-?\d+(?:\.\d+)?)/g)) r[op === '<' ? 'max' : 'min'] = +n
32
+ const TYPES = new Set(['str', 'num', 'bool', 'email', 'url', 'date'])
33
+ function scalarRule(s) {
34
+ if (typeof s !== 'string' || s.length > 256) throw Error('form: invalid rule (use str num bool email url date, ! required, <=N >=N, [T])')
35
+ const m = /^(?:([a-z]+)(!)?|\[([a-z]+)(!)?\](!)?)((?:[<>]=-?\d+(?:\.\d+)?)*)$/.exec(s)
36
+ if (!m || m[4] && m[5]) throw Error(`form: invalid rule '${s}' (use str num bool email url date, ! required, <=N >=N, [T])`)
37
+ const r = { t: m[1] || m[3], req: !!(m[2] || m[4] || m[5]), list: !!m[3], min: null, max: null }
38
+ if (!TYPES.has(r.t)) throw Error(`form: unknown type '${r.t}'`)
39
+ for (const [, op, n] of m[6].matchAll(/([<>])=(-?\d+(?:\.\d+)?)/g)) {
40
+ const key = op === '<' ? 'max' : 'min', value = Number(n)
41
+ if (r[key] != null || !Number.isFinite(value) || (!r.list && r.t === 'bool') || ((r.list || r.t !== 'num') && (!Number.isSafeInteger(value) || value < 0))) throw Error(`form: invalid bounds in rule '${s}'`)
42
+ r[key] = value
43
+ }
44
+ if (r.min != null && r.max != null && r.min > r.max) throw Error(`form: invalid bounds in rule '${s}'`)
41
45
  return r
42
46
  }
47
+ function schemaRules(schema, seen, budget, depth) {
48
+ if (!schema || Array.isArray(schema) || typeof schema !== 'object' || ![null, Object.prototype].includes(Object.getPrototypeOf(schema)) || seen.has(schema) || depth > 16) throw Error('form: invalid nested schema')
49
+ seen.add(schema)
50
+ for (const key of Object.keys(schema)) {
51
+ const field = Object.getOwnPropertyDescriptor(schema, key)
52
+ if (++budget.n > 4096 || ['__proto__', 'prototype', 'constructor'].includes(key) || !field || !('value' in field)) throw Error('form: invalid schema field')
53
+ if (field.value && typeof field.value === 'object') schemaRules(field.value, seen, budget, depth + 1)
54
+ else scalarRule(field.value)
55
+ }
56
+ seen.delete(schema)
57
+ }
58
+ export function rule(s) {
59
+ if (s && typeof s === 'object') {
60
+ schemaRules(s, new Set(), { n: 0 }, 0)
61
+ return { t: 'obj', s, req: false }
62
+ }
63
+ return scalarRule(s)
64
+ }
65
+ const empty = v => v == null || typeof v === 'string' && v.trim() === '' || Array.isArray(v) && !v.length
66
+ const NUMBER = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/
67
+ // Check calendar fields directly; Date.parse may normalize impossible days or accept locale-specific formats.
68
+ function calendarDate(s) {
69
+ const m = /^(\d{4})-(\d{2})-(\d{2})(?:T([01]\d|2[0-3]):([0-5]\d)(?::([0-5]\d)(?:\.\d{1,9})?)?(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)?)?$/.exec(s)
70
+ if (!m) return false
71
+ const year = +m[1], month = +m[2], day = +m[3]
72
+ const days = [31, year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
73
+ return year > 0 && month > 0 && month <= 12 && day > 0 && day <= days[month - 1]
74
+ }
43
75
 
44
- const empty = v => v == null || v === '' || (Array.isArray(v) && !v.length)
45
76
  const plural = (n, w) => `${n} ${w}${n === 1 ? '' : 's'}`
46
77
 
78
+ // Compiler-only validators for proven unbounded scalars. Separate entry points let
79
+ // text-only forms omit numeric syntax and boolean validation from their dependency graph.
80
+ export function checkText(r, value) {
81
+ if (Array.isArray(value)) return msg('form.text', 'Enter text')
82
+ if (empty(value)) return r.req ? msg('form.required', 'Required') : ''
83
+ if (r.t === 'email' && (typeof value !== 'string' || !RX_EMAIL.test(value))) return msg('form.email', 'Enter a valid email')
84
+ return typeof value === 'string' ? '' : msg('form.text', 'Enter text')
85
+ }
86
+ export function checkScalar(r, value) {
87
+ if (r.t !== 'bool' && r.t !== 'num') return checkText(r, value)
88
+ if (Array.isArray(value)) return msg(r.t === 'num' ? 'form.number' : 'form.boolean', r.t === 'num' ? 'Enter a number' : 'Choose true or false')
89
+ if (empty(value)) return r.req ? msg('form.required', 'Required') : ''
90
+ if (r.t === 'bool') return typeof value !== 'boolean' ? msg('form.boolean', 'Choose true or false') : r.req && !value ? msg('form.required', 'Required') : ''
91
+ return Number.isFinite(typeof value === 'number' ? value : typeof value === 'string' && NUMBER.test(value.trim()) ? Number(value) : NaN) ? '' : msg('form.number', 'Enter a number')
92
+ }
93
+
47
94
  // Message for one value, or '' when valid.
48
95
  export function check(r, v) {
49
96
  if (r.list) {
97
+ if (v != null && !Array.isArray(v)) return msg('form.list', 'Must be a list')
50
98
  if (empty(v)) return r.req || r.min > 0 ? (r.req && !r.min ? msg('form.required', 'Required') : msg('form.min_items', `Choose at least ${r.min}`, { n: r.min })) : ''
51
99
  if (!Array.isArray(v)) return msg('form.list', 'Must be a list')
52
100
  if (r.min != null && v.length < r.min) return msg('form.min_items', `Choose at least ${r.min}`, { n: r.min })
53
101
  if (r.max != null && v.length > r.max) return msg('form.max_items', `At most ${plural(r.max, 'item')}`, { n: r.max })
54
102
  const one = { ...r, list: false, req: true, min: null, max: null }
55
103
  for (let i = 0; i < v.length; i++) {
56
- const e = check(one, v[i])
104
+ const e = r.t === 'bool' ? typeof v[i] === 'boolean' ? '' : msg('form.boolean', 'Choose true or false') : check(one, v[i])
57
105
  if (e) return msg('form.item', `Item ${i + 1}: ${e}`, { n: i + 1, error: e })
58
106
  }
59
107
  return ''
60
108
  }
61
- if (r.t === 'bool') return r.req && v !== true ? msg('form.required', 'Required') : ''
109
+ if (Array.isArray(v)) return msg(r.t === 'num' ? 'form.number' : r.t === 'bool' ? 'form.boolean' : 'form.text', r.t === 'num' ? 'Enter a number' : r.t === 'bool' ? 'Choose true or false' : 'Enter text')
62
110
  if (empty(v)) return r.req ? msg('form.required', 'Required') : ''
63
- const s = String(v)
111
+ if (r.t === 'bool') return typeof v !== 'boolean' ? msg('form.boolean', 'Choose true or false') : r.req && !v ? msg('form.required', 'Required') : ''
112
+ const s = typeof v === 'string' ? v : ''
64
113
  switch (r.t) {
65
114
  case 'num': {
66
- const n = typeof v === 'number' ? v : s.trim() === '' ? NaN : Number(s)
115
+ const n = typeof v === 'number' ? v : typeof v === 'string' && NUMBER.test(s.trim()) ? Number(s) : NaN
67
116
  if (!Number.isFinite(n)) return msg('form.number', 'Enter a number')
68
117
  if (r.min != null && n < r.min) return msg('form.min', `Must be at least ${r.min}`, { n: r.min })
69
118
  if (r.max != null && n > r.max) return msg('form.max', `Must be at most ${r.max}`, { n: r.max })
70
119
  return ''
71
120
  }
72
121
  case 'email':
73
- if (!RX_EMAIL.test(s)) return msg('form.email', 'Enter a valid email')
122
+ if (typeof v !== 'string' || !RX_EMAIL.test(s)) return msg('form.email', 'Enter a valid email')
74
123
  break
75
124
  case 'url':
125
+ if (typeof v !== 'string') return msg('form.url', 'Enter a valid URL')
76
126
  try {
77
127
  if (!/^https?:$/.test(new URL(s).protocol)) return msg('form.url', 'Enter a valid URL')
78
128
  } catch {
@@ -80,9 +130,10 @@ export function check(r, v) {
80
130
  }
81
131
  break
82
132
  case 'date':
83
- if (Number.isNaN(Date.parse(s))) return msg('form.date', 'Enter a valid date')
84
- return ''
133
+ if (typeof v !== 'string' || !calendarDate(s)) return msg('form.date', 'Enter a valid date')
134
+ break
85
135
  case 'str':
136
+ if (typeof v !== 'string') return msg('form.text', 'Enter text')
86
137
  break
87
138
  default:
88
139
  throw Error(`form: unknown type '${r.t}'`)