@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.
- package/THIRD_PARTY_NOTICES.txt +211 -0
- package/brain-budget.js +142 -0
- package/brain-data.js +13 -3
- package/brain-dev.js +191 -28
- package/brain-err.js +168 -43
- package/brain-form.js +58 -23
- package/brain-hash.js +17 -0
- package/brain-inspect.js +6 -4
- package/brain-interop.js +50 -0
- package/brain-overlay.js +12 -0
- package/brain-preferences.js +64 -0
- package/brain-prefetch-shared.js +36 -0
- package/brain-prefetch.js +19 -0
- package/brain-router.js +144 -78
- package/brain-schema.js +69 -18
- package/brain-sqlite.js +122 -0
- package/brain-storage.js +143 -0
- package/brain-threads.js +49 -0
- package/brain-time.js +77 -0
- package/brain-url.js +125 -0
- package/brain-worker.js +105 -13
- package/brain.js +5 -4
- package/package.json +45 -1
- package/server/server.mjs +440 -41
- package/server/templates/gcs-adapter.mjs +99 -0
- package/templates/google-signin.js +102 -0
- package/ui/button.brain +1 -1
- package/ui/checkbox.brain +2 -1
- package/ui/dialog.brain +5 -3
- package/ui/error-state.brain +1 -1
- package/ui/input.brain +4 -3
- package/ui/menu.brain +4 -1
- package/ui/select.brain +2 -2
- package/ui/toast.brain +4 -1
|
@@ -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
|
|
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(
|
|
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 = () =>
|
|
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">`.
|
|
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,
|
|
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 = [],
|
|
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
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
-
|
|
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
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
const
|
|
371
|
-
|
|
372
|
-
|
|
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
|
-
|
|
391
|
+
const remove = k => { const c = cache.get(k); if (c) bytes -= c.bytes || 0; cache.delete(k) }
|
|
383
392
|
const prune = () => {
|
|
384
|
-
const
|
|
385
|
-
|
|
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
|
-
|
|
390
|
-
|
|
391
|
-
const
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
.
|
|
402
|
-
|
|
403
|
-
|
|
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(() =>
|
|
457
|
+
get(k).finally(() => { busy--; pump() })
|
|
411
458
|
}
|
|
412
459
|
}
|
|
413
|
-
const later = f =>
|
|
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)
|
|
419
|
-
else
|
|
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
|
-
|
|
430
|
-
|
|
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 (
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
for (const a of
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
r.
|
|
39
|
-
|
|
40
|
-
|
|
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 === '
|
|
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
|
-
|
|
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 :
|
|
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 (
|
|
84
|
-
|
|
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}'`)
|