@christianirack/brainllm-runtime 0.1.0 → 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/README.md +1 -1
- package/THIRD_PARTY_NOTICES.txt +211 -0
- package/brain-ai.js +129 -0
- package/brain-budget.js +142 -0
- package/brain-data.js +15 -3
- package/brain-dev.js +191 -28
- package/brain-err.js +168 -43
- package/brain-form.js +77 -27
- package/brain-hash.js +17 -0
- package/brain-inspect.js +6 -4
- package/brain-interop.js +50 -0
- package/brain-mcp.js +32 -17
- 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 +229 -13
- package/brain-rpc.js +3 -0
- package/brain-schema.js +105 -30
- 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 +6 -5
- package/package.json +46 -2
- package/server/server.mjs +615 -52
- 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
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,7 +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
|
|
97
|
+
const ahead = predict(o, u => same(u, true))
|
|
93
98
|
// Build signature (§35.7): the page's <meta name="brain-build">. A navigation response from another build, or the
|
|
94
99
|
// service worker announcing one, turns client navigation into full loads so old and new code never mix.
|
|
95
100
|
const build = () => {
|
|
@@ -97,7 +102,7 @@ export function router(o = {}) {
|
|
|
97
102
|
return m ? m.getAttribute('content') : null
|
|
98
103
|
}
|
|
99
104
|
const sw = G.navigator && navigator.serviceWorker
|
|
100
|
-
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())
|
|
101
106
|
if (sw && sw.addEventListener) sw.addEventListener('message', onMsg)
|
|
102
107
|
// a guard is a function, or the URL of its `_guard.jsbrain` chunk (BUILD #220): loaded the first time a navigation
|
|
103
108
|
// reaches that folder, then cached by the module map; its default export runs with the same ctx
|
|
@@ -110,24 +115,30 @@ export function router(o = {}) {
|
|
|
110
115
|
const url = new URL(u, location.href), path = strip(url.pathname) // guards see app paths (no base)
|
|
111
116
|
for (const [pre, g] of gs) {
|
|
112
117
|
if (!(path === pre || path.startsWith(pre.endsWith('/') ? pre : pre + '/'))) continue
|
|
113
|
-
|
|
118
|
+
// The browser has no session (the cookie is HttpOnly): a guard that reads ctx.user cannot decide here. If it would
|
|
119
|
+
// stop the navigation, the server decides on a full load instead (F12b: `ctx.user ? true : redirect('/login')`
|
|
120
|
+
// used to send a signed-in user to /login on every client navigation).
|
|
121
|
+
let v, asked = false
|
|
122
|
+
const ctx = { url, path, query: Object.fromEntries(url.searchParams) }
|
|
123
|
+
Object.defineProperty(ctx, 'user', { enumerable: true, get: () => ((asked = true), undefined) })
|
|
114
124
|
try {
|
|
115
|
-
v = await g(
|
|
125
|
+
v = await g(ctx)
|
|
116
126
|
} catch (e) {
|
|
127
|
+
if (asked) return null
|
|
117
128
|
return e && typeof e === 'object' && (R in e || 'redirect' in e) ? outcome(e) : null
|
|
118
129
|
}
|
|
119
130
|
v = outcome(v)
|
|
120
|
-
if (v !== true) return v
|
|
131
|
+
if (v !== true) return asked ? null : v
|
|
121
132
|
}
|
|
122
133
|
return true
|
|
123
134
|
}
|
|
124
135
|
|
|
125
136
|
// _loading.brain (#235): when the next page takes longer than 150 ms, the nearest loading view of the target route
|
|
126
|
-
// (`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
|
|
127
138
|
// region until the page arrives; fast navigations never show it, so nothing flickers.
|
|
128
139
|
const loads = o.loading || []
|
|
129
140
|
function waiting(url, my) {
|
|
130
|
-
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)))
|
|
131
142
|
const w = { done: false, t: 0 }
|
|
132
143
|
if (!l || !o.core) return w
|
|
133
144
|
w.t = setTimeout(() => {
|
|
@@ -154,7 +165,7 @@ export function router(o = {}) {
|
|
|
154
165
|
G.dispatchEvent && G.dispatchEvent(new Event('brain:navigate'))
|
|
155
166
|
} catch {}
|
|
156
167
|
try {
|
|
157
|
-
r = await fetch(url, { headers: { 'x-brain-nav': '1' } })
|
|
168
|
+
r = (await ahead.take(url)) || (await fetch(url, { headers: { 'x-brain-nav': '1' } }))
|
|
158
169
|
const b = r.headers && r.headers.get && r.headers.get('x-brain-build')
|
|
159
170
|
if (b && build() && b !== build()) return void (my === seq && hard(url)) // a new deploy: load it whole
|
|
160
171
|
const ct = (r.headers && r.headers.get && r.headers.get('content-type')) || 'text/html'
|
|
@@ -216,6 +227,10 @@ export function router(o = {}) {
|
|
|
216
227
|
if (pb && document.head) document.head.appendChild(document.adoptNode ? document.adoptNode(pb) : pb)
|
|
217
228
|
routed()
|
|
218
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)
|
|
233
|
+
ahead.scan()
|
|
219
234
|
}
|
|
220
235
|
if (document.startViewTransition) {
|
|
221
236
|
const vt = document.startViewTransition(swap)
|
|
@@ -225,11 +240,11 @@ export function router(o = {}) {
|
|
|
225
240
|
}
|
|
226
241
|
// a full load must not be intercepted again (Navigation API): `through` lets that one navigation pass
|
|
227
242
|
const hard = url => ((through = new URL(url, location.href).href), (o.hard || (u => location.assign(u)))(url))
|
|
228
|
-
const same = u => {
|
|
243
|
+
const same = (u, prefetch = false) => {
|
|
229
244
|
try {
|
|
230
245
|
const x = new URL(u, location.href), b = base()
|
|
231
246
|
// same origin and inside the app's base path (§23 #148); anything else is a full load
|
|
232
|
-
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(/\/+$/, '') || '/'))
|
|
233
248
|
} catch {
|
|
234
249
|
return false
|
|
235
250
|
}
|
|
@@ -243,6 +258,7 @@ export function router(o = {}) {
|
|
|
243
258
|
if (!e.canIntercept || e.hashChange || e.downloadRequest || e.formData || !same(e.destination.url)) return
|
|
244
259
|
if (e.cancelable && !leave()) return e.preventDefault()
|
|
245
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?.() })
|
|
246
262
|
if (!gs.length) return e.intercept({ handler: () => load(to) }) // scroll + focus reset handled by the browser
|
|
247
263
|
let g
|
|
248
264
|
e.intercept({
|
|
@@ -261,10 +277,11 @@ export function router(o = {}) {
|
|
|
261
277
|
})
|
|
262
278
|
}
|
|
263
279
|
nav.addEventListener('navigate', h)
|
|
280
|
+
ahead.scan()
|
|
264
281
|
return (active = {
|
|
265
282
|
go: (u, how) => nav.navigate(app(u), how === 'replace' ? { history: 'replace' } : undefined),
|
|
266
283
|
check,
|
|
267
|
-
stop: () => ((active = null), nav.removeEventListener('navigate', h), sw && sw.removeEventListener && sw.removeEventListener('message', onMsg)),
|
|
284
|
+
stop: () => ((active = null), ahead.stop(), nav.removeEventListener('navigate', h), sw && sw.removeEventListener && sw.removeEventListener('message', onMsg)),
|
|
268
285
|
})
|
|
269
286
|
}
|
|
270
287
|
|
|
@@ -273,6 +290,7 @@ export function router(o = {}) {
|
|
|
273
290
|
let here = location.href
|
|
274
291
|
const go = async (u, push = true, hops = 0) => {
|
|
275
292
|
if (!hops && !leave()) return push || history.pushState(history.state, '', here) // stay (back/forward: restore the URL)
|
|
293
|
+
if (!same(u)) return hard(u)
|
|
276
294
|
const g = gs.length ? await check(u) : true
|
|
277
295
|
if (g === null || hops > 5) return hard(u)
|
|
278
296
|
if (g === false) return push || history.pushState(history.state, '', here) // back/forward blocked: stay
|
|
@@ -295,7 +313,10 @@ export function router(o = {}) {
|
|
|
295
313
|
e.preventDefault()
|
|
296
314
|
go(u.href)
|
|
297
315
|
}
|
|
298
|
-
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
|
+
}
|
|
299
320
|
const focusMain = () => {
|
|
300
321
|
const m = document.querySelector(region), h = m && (m.querySelector('h1') || m)
|
|
301
322
|
if (h && h.focus) {
|
|
@@ -305,13 +326,208 @@ export function router(o = {}) {
|
|
|
305
326
|
}
|
|
306
327
|
document.addEventListener('click', click)
|
|
307
328
|
G.addEventListener && G.addEventListener('popstate', pop)
|
|
329
|
+
ahead.scan()
|
|
308
330
|
return (active = {
|
|
309
331
|
go: (u, push = true) => go(app(u), push),
|
|
310
332
|
check,
|
|
311
|
-
stop: () => ((active = null), document.removeEventListener('click', click), G.removeEventListener && G.removeEventListener('popstate', pop)),
|
|
333
|
+
stop: () => ((active = null), ahead.stop(), document.removeEventListener('click', click), G.removeEventListener && G.removeEventListener('popstate', pop)),
|
|
312
334
|
})
|
|
313
335
|
}
|
|
314
336
|
|
|
337
|
+
// Predictive prefetch (§19, 2026-10-29): no dead time during a visit. Every internal link that scrolls into view is
|
|
338
|
+
// fetched in the background while the browser is idle (at most 2 at a time, 12 per page), and hovering, focusing or
|
|
339
|
+
// touching a link moves it to the front of the queue. The fetched page is kept in memory and the next navigation to it
|
|
340
|
+
// swaps without waiting for the network; its module chunks get `<link rel=modulepreload>` so the view mounts at once.
|
|
341
|
+
// The current view never waits: requests run at low priority and only when idle. Skipped: Save-Data and 2G (only
|
|
342
|
+
// intent prefetches there, and never with Save-Data), `data-prefetch="false"` on the link or an ancestor, download or
|
|
343
|
+
// target links, files (`.pdf`, `.zip`…), `/api/` and `/auth/`, action-style paths (log out, unsubscribe, confirm,
|
|
344
|
+
// verify), URLs carrying a token in the query, and everything with `router({ prefetch: false })` or
|
|
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,
|
|
347
|
+
// and any write empties the memory first: a form submit, a `brain:write` (brain-data writes, every server function
|
|
348
|
+
// call, log-out included), so the next view never shows data from before the write or another session. Each fetch says
|
|
349
|
+
// `x-brain-prefetch: 1` (`ctx.prefetch` in the server's `load`), so a server can tell it apart from a click.
|
|
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
|
|
352
|
+
const FILE = /\.(?!html?$)[a-z0-9]{2,5}$/i
|
|
353
|
+
const SECRET = /(^|[?&])(token|code|key|sig|signature|auth|otp|state)=/i
|
|
354
|
+
export function predict(o = {}, same = () => false) {
|
|
355
|
+
const off = { scan() {}, stop() {}, take: async () => null }
|
|
356
|
+
const meta = G.document && document.querySelector && document.querySelector('meta[name=brain-prefetch]')
|
|
357
|
+
if (o.prefetch === false || (meta && /^(off|false|0)$/i.test(meta.getAttribute('content') || '')) || !G.document || !G.fetch) return off
|
|
358
|
+
const net = G.navigator && navigator.connection
|
|
359
|
+
const thin = () => !!net && (net.saveData || /(^|-)2g$/.test(net.effectiveType || ''))
|
|
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
|
|
362
|
+
const key = h => {
|
|
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)
|
|
369
|
+
}
|
|
370
|
+
const ok = a => {
|
|
371
|
+
if (!a || !a.getAttribute || !a.getAttribute('href') || a.getAttribute('target') || a.hasAttribute('download')) return null
|
|
372
|
+
for (let n = a; n && n.getAttribute; n = n.parentNode) if (n.getAttribute('data-prefetch') === 'false') return null
|
|
373
|
+
const k = key(a.href || a.getAttribute('href'))
|
|
374
|
+
return safe(k) ? k : null
|
|
375
|
+
}
|
|
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
|
|
382
|
+
const l = document.createElement('link')
|
|
383
|
+
l.setAttribute('rel', /\.(js|jsbrain)(\?|$)/.test(u) ? 'modulepreload' : 'prefetch')
|
|
384
|
+
l.setAttribute('href', u)
|
|
385
|
+
l.setAttribute('fetchpriority', 'low')
|
|
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) }
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
const remove = k => { const c = cache.get(k); if (c) bytes -= c.bytes || 0; cache.delete(k) }
|
|
392
|
+
const prune = () => {
|
|
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)
|
|
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
|
+
|
|
428
|
+
const get = k => {
|
|
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
|
|
450
|
+
}
|
|
451
|
+
const pump = () => {
|
|
452
|
+
if (dead || document.hidden || net && net.saveData) return
|
|
453
|
+
while (busy < PARALLEL && queue.length) {
|
|
454
|
+
const k = queue.shift()
|
|
455
|
+
if (cache.has(k)) continue
|
|
456
|
+
busy++
|
|
457
|
+
get(k).finally(() => { busy--; pump() })
|
|
458
|
+
}
|
|
459
|
+
}
|
|
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 } }
|
|
462
|
+
const want = (k, now) => {
|
|
463
|
+
if (!k || dead || cache.has(k) || count >= (now ? MAX_KEPT : MAX_PAGE)) return
|
|
464
|
+
const i = queue.indexOf(k)
|
|
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() }) }
|
|
468
|
+
}
|
|
469
|
+
const intent = e => {
|
|
470
|
+
if (net && net.saveData) return
|
|
471
|
+
let a = e.target
|
|
472
|
+
while (a && a.tagName !== 'A') a = a.parentNode
|
|
473
|
+
want(ok(a), true)
|
|
474
|
+
}
|
|
475
|
+
const evs = ['pointerover', 'focusin', 'touchstart']
|
|
476
|
+
for (const t of evs) document.addEventListener(t, intent, { passive: true, capture: true })
|
|
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
|
+
}
|
|
482
|
+
document.addEventListener('submit', forget, { capture: true })
|
|
483
|
+
if (G.addEventListener) G.addEventListener('brain:write', forget)
|
|
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' })
|
|
489
|
+
return {
|
|
490
|
+
scan() {
|
|
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
|
+
}
|
|
506
|
+
},
|
|
507
|
+
stop() {
|
|
508
|
+
if (dead) return
|
|
509
|
+
forget(); dead = true; crashed(); io && io.disconnect()
|
|
510
|
+
for (const t of evs) document.removeEventListener(t, intent, { capture: true })
|
|
511
|
+
document.removeEventListener('submit', forget, { capture: true })
|
|
512
|
+
document.removeEventListener('visibilitychange', visible)
|
|
513
|
+
G.removeEventListener && G.removeEventListener('brain:write', forget)
|
|
514
|
+
for (const l of pre.values()) l.remove()
|
|
515
|
+
pre.clear()
|
|
516
|
+
},
|
|
517
|
+
async take(u) {
|
|
518
|
+
const k = key(u), c = k && cache.get(k)
|
|
519
|
+
if (!c) return 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
|
|
527
|
+
},
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
315
531
|
const HOLD = ['click', 'submit']
|
|
316
532
|
function hold(el) {
|
|
317
533
|
if (el.$hold) el.$hold() // a newer navigation drops what the previous page held
|
package/brain-rpc.js
CHANGED
|
@@ -10,6 +10,9 @@ export async function rpc(id, a, oops, base) {
|
|
|
10
10
|
j = t ? JSON.parse(t) : undefined
|
|
11
11
|
} catch (e) {
|
|
12
12
|
throw (oops(e, null, 'rpc'), e)
|
|
13
|
+
} finally {
|
|
14
|
+
// a server function may write or change the session: pages the router prefetched may be stale (§19)
|
|
15
|
+
if (globalThis.dispatchEvent && globalThis.Event) globalThis.dispatchEvent(new Event('brain:write'))
|
|
13
16
|
}
|
|
14
17
|
if (r.ok) return j
|
|
15
18
|
const e = Object.assign(Error((j && j.error) || 'rpc ' + id + ': ' + r.status), { code: 'rpc', status: r.status, id, errors: j && j.errors })
|
package/brain-schema.js
CHANGED
|
@@ -3,67 +3,142 @@
|
|
|
3
3
|
// Per field: `str num bool email url date` + `!` required + `<=N >=N` (length for str and lists, value for num) + `[T]`
|
|
4
4
|
// list of T, or a nested schema object.
|
|
5
5
|
const RX_EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
|
6
|
+
const G = globalThis
|
|
7
|
+
|
|
8
|
+
// Validation messages (§14, §37): the app's dictionary wins when it has the key (`"form.required": "Obligatorio"` in
|
|
9
|
+
// i18n/<locale>.json; plural objects allowed for keys with `{n}`), else the English text. The table is read directly
|
|
10
|
+
// (globalThis.__brainDict, filled by brain-i18n.js `add`) so a page that only validates does not load brain-i18n.js.
|
|
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.text form.boolean form.min_chars form.max_chars.
|
|
13
|
+
export function msg(key, en, vars) {
|
|
14
|
+
const D = G.__brainDict
|
|
15
|
+
if (D) {
|
|
16
|
+
const h = G.document && G.document.documentElement
|
|
17
|
+
const loc = G.__brainLocale || (h && h.getAttribute('lang')) || 'en'
|
|
18
|
+
const def = (h && h.getAttribute('data-default-lang')) || 'en'
|
|
19
|
+
let m = (D[loc] && D[loc][key]) ?? (D[def] && D[def][key])
|
|
20
|
+
if (m != null) {
|
|
21
|
+
if (typeof m === 'object') {
|
|
22
|
+
const n = vars && vars.n
|
|
23
|
+
m = m['=' + n] ?? m[new Intl.PluralRules(loc).select(+n || 0)] ?? m.other ?? en
|
|
24
|
+
}
|
|
25
|
+
return vars ? String(m).replace(/\{(\w+)\}/g, (s, k) => (k in vars ? String(vars[k]) : s)) : String(m)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return en
|
|
29
|
+
}
|
|
6
30
|
|
|
7
31
|
// 'str!<=80' → { t: 'str', req: true, min, max, list: false }
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
r.
|
|
15
|
-
|
|
16
|
-
|
|
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}'`)
|
|
17
45
|
return r
|
|
18
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
|
+
}
|
|
19
75
|
|
|
20
|
-
const empty = v => v == null || v === '' || (Array.isArray(v) && !v.length)
|
|
21
76
|
const plural = (n, w) => `${n} ${w}${n === 1 ? '' : 's'}`
|
|
22
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
|
+
|
|
23
94
|
// Message for one value, or '' when valid.
|
|
24
95
|
export function check(r, v) {
|
|
25
96
|
if (r.list) {
|
|
26
|
-
if (
|
|
27
|
-
if (
|
|
28
|
-
if (
|
|
29
|
-
if (r.
|
|
97
|
+
if (v != null && !Array.isArray(v)) return msg('form.list', 'Must be a list')
|
|
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 })) : ''
|
|
99
|
+
if (!Array.isArray(v)) return msg('form.list', 'Must be a list')
|
|
100
|
+
if (r.min != null && v.length < r.min) return msg('form.min_items', `Choose at least ${r.min}`, { n: r.min })
|
|
101
|
+
if (r.max != null && v.length > r.max) return msg('form.max_items', `At most ${plural(r.max, 'item')}`, { n: r.max })
|
|
30
102
|
const one = { ...r, list: false, req: true, min: null, max: null }
|
|
31
103
|
for (let i = 0; i < v.length; i++) {
|
|
32
|
-
const e = check(one, v[i])
|
|
33
|
-
if (e) return `Item ${i + 1}: ${e}
|
|
104
|
+
const e = r.t === 'bool' ? typeof v[i] === 'boolean' ? '' : msg('form.boolean', 'Choose true or false') : check(one, v[i])
|
|
105
|
+
if (e) return msg('form.item', `Item ${i + 1}: ${e}`, { n: i + 1, error: e })
|
|
34
106
|
}
|
|
35
107
|
return ''
|
|
36
108
|
}
|
|
37
|
-
if (r.t === 'bool'
|
|
38
|
-
if (empty(v)) return r.req ? 'Required' : ''
|
|
39
|
-
|
|
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')
|
|
110
|
+
if (empty(v)) return r.req ? msg('form.required', 'Required') : ''
|
|
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 : ''
|
|
40
113
|
switch (r.t) {
|
|
41
114
|
case 'num': {
|
|
42
|
-
const n = typeof v === 'number' ? v :
|
|
43
|
-
if (!Number.isFinite(n)) return 'Enter a number'
|
|
44
|
-
if (r.min != null && n < r.min) return `Must be at least ${r.min}
|
|
45
|
-
if (r.max != null && n > r.max) return `Must be at most ${r.max}
|
|
115
|
+
const n = typeof v === 'number' ? v : typeof v === 'string' && NUMBER.test(s.trim()) ? Number(s) : NaN
|
|
116
|
+
if (!Number.isFinite(n)) return msg('form.number', 'Enter a number')
|
|
117
|
+
if (r.min != null && n < r.min) return msg('form.min', `Must be at least ${r.min}`, { n: r.min })
|
|
118
|
+
if (r.max != null && n > r.max) return msg('form.max', `Must be at most ${r.max}`, { n: r.max })
|
|
46
119
|
return ''
|
|
47
120
|
}
|
|
48
121
|
case 'email':
|
|
49
|
-
if (!RX_EMAIL.test(s)) return 'Enter a valid email'
|
|
122
|
+
if (typeof v !== 'string' || !RX_EMAIL.test(s)) return msg('form.email', 'Enter a valid email')
|
|
50
123
|
break
|
|
51
124
|
case 'url':
|
|
125
|
+
if (typeof v !== 'string') return msg('form.url', 'Enter a valid URL')
|
|
52
126
|
try {
|
|
53
|
-
if (!/^https?:$/.test(new URL(s).protocol)) return 'Enter a valid URL'
|
|
127
|
+
if (!/^https?:$/.test(new URL(s).protocol)) return msg('form.url', 'Enter a valid URL')
|
|
54
128
|
} catch {
|
|
55
|
-
return 'Enter a valid URL'
|
|
129
|
+
return msg('form.url', 'Enter a valid URL')
|
|
56
130
|
}
|
|
57
131
|
break
|
|
58
132
|
case 'date':
|
|
59
|
-
if (
|
|
60
|
-
|
|
133
|
+
if (typeof v !== 'string' || !calendarDate(s)) return msg('form.date', 'Enter a valid date')
|
|
134
|
+
break
|
|
61
135
|
case 'str':
|
|
136
|
+
if (typeof v !== 'string') return msg('form.text', 'Enter text')
|
|
62
137
|
break
|
|
63
138
|
default:
|
|
64
139
|
throw Error(`form: unknown type '${r.t}'`)
|
|
65
140
|
}
|
|
66
|
-
if (r.min != null && s.length < r.min) return `At least ${plural(r.min, 'character')}
|
|
67
|
-
if (r.max != null && s.length > r.max) return `At most ${plural(r.max, 'character')}
|
|
141
|
+
if (r.min != null && s.length < r.min) return msg('form.min_chars', `At least ${plural(r.min, 'character')}`, { n: r.min })
|
|
142
|
+
if (r.max != null && s.length > r.max) return msg('form.max_chars', `At most ${plural(r.max, 'character')}`, { n: r.max })
|
|
68
143
|
return ''
|
|
69
144
|
}
|