@christianirack/brainllm-runtime 0.0.0-stage → 0.3.1

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.
Files changed (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +4 -2
  3. package/brain-ai.js +129 -0
  4. package/brain-anim.js +21 -0
  5. package/brain-ch.js +254 -0
  6. package/brain-consent.js +38 -0
  7. package/brain-data.js +549 -0
  8. package/brain-dev.js +100 -0
  9. package/brain-env.js +160 -0
  10. package/brain-err.js +242 -0
  11. package/brain-form.js +275 -0
  12. package/brain-guard.js +242 -0
  13. package/brain-hydrate.js +72 -0
  14. package/brain-i18n.js +111 -0
  15. package/brain-inspect.js +745 -0
  16. package/brain-island.js +106 -0
  17. package/brain-lazy.js +88 -0
  18. package/brain-mcp.js +73 -0
  19. package/brain-mock.js +80 -0
  20. package/brain-overlay.js +123 -0
  21. package/brain-persist.js +65 -0
  22. package/brain-router.js +503 -0
  23. package/brain-rpc.js +20 -0
  24. package/brain-schema.js +93 -0
  25. package/brain-wc.js +69 -0
  26. package/brain-why.js +198 -0
  27. package/brain-worker.js +66 -0
  28. package/brain.js +421 -0
  29. package/package.json +20 -4
  30. package/server/server.mjs +1046 -0
  31. package/shim/dom.js +448 -0
  32. package/shim/probe.js +111 -0
  33. package/sw/brain-sw.js +103 -0
  34. package/themes/contrast.cssbrain +39 -0
  35. package/themes/dark.cssbrain +39 -0
  36. package/themes/glass.cssbrain +39 -0
  37. package/themes/paper.cssbrain +39 -0
  38. package/ui/button.brain +31 -0
  39. package/ui/center.brain +11 -0
  40. package/ui/checkbox.brain +13 -0
  41. package/ui/container.brain +11 -0
  42. package/ui/dialog.brain +29 -0
  43. package/ui/error-state.brain +53 -0
  44. package/ui/field.brain +34 -0
  45. package/ui/grid.brain +11 -0
  46. package/ui/input.brain +23 -0
  47. package/ui/lang-switch.brain +38 -0
  48. package/ui/menu.brain +64 -0
  49. package/ui/row.brain +12 -0
  50. package/ui/select.brain +29 -0
  51. package/ui/sidebar.brain +17 -0
  52. package/ui/spacer.brain +12 -0
  53. package/ui/stack.brain +10 -0
  54. package/ui/table.brain +73 -0
  55. package/ui/tabs.brain +33 -0
  56. package/ui/toast.brain +45 -0
@@ -0,0 +1,503 @@
1
+ // BRAINLLM client router (INTERNALS §19). Views are prerendered pages, so navigating = fetch the next page, swap its
2
+ // region (`main` by default), title and SEO meta, inside a View Transition when available. Islands keep working: their
3
+ // resume listeners are delegated on document; islands swapped out are disposed (`$d`, set by the core's resume).
4
+ // Navigation API (Baseline 2026) intercepts link navigations and back/forward and restores scroll and
5
+ // focus; without it, a click listener + History API do the same. Any failure falls back to a full page load.
6
+ // Page contract (dev server and build): the region holds the view and the script that mounts it, which stores the
7
+ // dispose on the region (`e.$d = mount(V, e, p)`); the router disposes it before swapping and runs the new one.
8
+ // Usage (app entry, emitted by the toolchain): `router()` · options: { region, skip(url) → bool, parse(html) → doc,
9
+ // guards }.
10
+ // Guards (INTERNALS §22): `guards: { '/admin': ctx => … }` or `guards: { '/admin': '/_b/<h>.js' }`, one per folder with
11
+ // `_guard.jsbrain` (the build emits the chunk URL; the router imports it the first time it is needed). Before navigating, every guard whose folder contains
12
+ // the target runs, outer to inner, with ctx = { url, path, query }. It allows by returning nothing/true, cancels with
13
+ // `false` and redirects with `redirect('/login')` (thrown or returned) or a string. The server runs the same guard,
14
+ // so a redirect it answers (fetch ends on another URL) is reflected in the address bar. A guard that throws anything
15
+ // else → full page load: the server decides.
16
+ const G = globalThis
17
+ // Base path (<meta name="brain-base" content="/app/">, §23 #148): links in the HTML already carry it; guards and
18
+ // routes work with app paths (strip), and app paths given to go()/navigate()/redirect() get it (app). '/' when absent.
19
+ const base = () => {
20
+ const m = G.document && document.querySelector('meta[name=brain-base]')
21
+ return (m && m.getAttribute('content')) || '/'
22
+ }
23
+ const strip = p => {
24
+ const b = base()
25
+ return b !== '/' && (p + '/').startsWith(b) ? '/' + p.slice(b.length) : p
26
+ }
27
+ const app = u => {
28
+ const b = base()
29
+ return typeof u === 'string' && b !== '/' && u.startsWith('/') && !u.startsWith('//') && !(u + '/').startsWith(b) ? b + u.slice(1) : u
30
+ }
31
+
32
+ // navigate('/account', { replace }) (§41.5): an app path (the base is added), through the running router (no reload,
33
+ // guards run); without a router it is a normal page load.
34
+ let active = null
35
+ export function navigate(to, o = {}) {
36
+ const u = app(String(to))
37
+ if (active) return active.go(u, o.replace ? 'replace' : true)
38
+ o.replace ? location.replace(u) : location.assign(u)
39
+ }
40
+ G.__brainNavigate = navigate // brain-form follows a server function's { redirect } without importing the router
41
+
42
+ // route() lives in brain-env.js (it needs the core's signals; a page without islands downloads only this router):
43
+ // after every client navigation the router calls the hook it installs.
44
+ const routed = () => G.__brainRouted && G.__brainRouted()
45
+
46
+ // Active links (§41.5): every internal <a href> to the current page gets aria-current="page" (the CSS hook is
47
+ // [aria-current=page]); the others lose it. On start and after every client navigation.
48
+ const current = () => {
49
+ if (!G.document || !G.location) return
50
+ for (const a of document.querySelectorAll('a[href]')) {
51
+ let u
52
+ try {
53
+ u = new URL(a.getAttribute('href'), location.href)
54
+ } catch {
55
+ continue
56
+ }
57
+ if (u.origin === location.origin && u.pathname.replace(/\/$/, '') === location.pathname.replace(/\/$/, '')) a.setAttribute('aria-current', 'page')
58
+ else if (a.getAttribute('aria-current') === 'page') a.removeAttribute('aria-current')
59
+ }
60
+ }
61
+
62
+ // A form() with unsaved changes (brain-form keeps them in globalThis.__brainDirty) asks before leaving (§41.5); one
63
+ // being submitted does not (#299): its submit() may navigate on success before it returns
64
+ const leave = () => {
65
+ for (const f of G.__brainDirty || []) if (f.dirty && !f.busy) return !G.confirm || G.confirm('Leave this page? Changes you made will not be saved.')
66
+ return true
67
+ }
68
+
69
+ // Same shapes as server.mjs, so one _guard.jsbrain works on both sides: { redirect, status } and { deny }
70
+ const R = Symbol('redirect') // older compiled guards
71
+ export function redirect(url, status = 303) {
72
+ throw { redirect: String(url), status }
73
+ }
74
+ export const deny = (reason = 'Forbidden') => ({ deny: String(reason) })
75
+ // guard result → true (go on) · false (cancel) · string (redirect) · null (full load: the server decides, e.g. 403)
76
+ const outcome = v =>
77
+ v && typeof v === 'object'
78
+ ? R in v
79
+ ? v[R]
80
+ : typeof v.redirect === 'string'
81
+ ? v.redirect
82
+ : v.deny !== undefined
83
+ ? null
84
+ : true
85
+ : v === false || typeof v === 'string'
86
+ ? v
87
+ : true
88
+
89
+ export function router(o = {}) {
90
+ const region = o.region || 'main'
91
+ const parse = o.parse || (h => new DOMParser().parseFromString(h, 'text/html'))
92
+ let seq = 0, through = null, stale = false
93
+ const ahead = predict(o, u => same(u))
94
+ // Build signature (§35.7): the page's <meta name="brain-build">. A navigation response from another build, or the
95
+ // service worker announcing one, turns client navigation into full loads so old and new code never mix.
96
+ const build = () => {
97
+ const m = document.querySelector('meta[name=brain-build]')
98
+ return m ? m.getAttribute('content') : null
99
+ }
100
+ 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)
102
+ if (sw && sw.addEventListener) sw.addEventListener('message', onMsg)
103
+ // a guard is a function, or the URL of its `_guard.jsbrain` chunk (BUILD #220): loaded the first time a navigation
104
+ // reaches that folder, then cached by the module map; its default export runs with the same ctx
105
+ const gs = Object.entries(o.guards || {})
106
+ .map(([pre, g]) => [pre, typeof g === 'string' ? c => import(g).then(m => m.default(c)) : g])
107
+ .sort((a, b) => a[0].length - b[0].length)
108
+ current()
109
+ // → true (go on) · false (cancel) · string (redirect) · null (guard failed: full load)
110
+ async function check(u) {
111
+ const url = new URL(u, location.href), path = strip(url.pathname) // guards see app paths (no base)
112
+ for (const [pre, g] of gs) {
113
+ if (!(path === pre || path.startsWith(pre.endsWith('/') ? pre : pre + '/'))) continue
114
+ // The browser has no session (the cookie is HttpOnly): a guard that reads ctx.user cannot decide here. If it would
115
+ // stop the navigation, the server decides on a full load instead (F12b: `ctx.user ? true : redirect('/login')`
116
+ // used to send a signed-in user to /login on every client navigation).
117
+ let v, asked = false
118
+ const ctx = { url, path, query: Object.fromEntries(url.searchParams) }
119
+ Object.defineProperty(ctx, 'user', { enumerable: true, get: () => ((asked = true), undefined) })
120
+ try {
121
+ v = await g(ctx)
122
+ } catch (e) {
123
+ if (asked) return null
124
+ return e && typeof e === 'object' && (R in e || 'redirect' in e) ? outcome(e) : null
125
+ }
126
+ v = outcome(v)
127
+ if (v !== true) return asked ? null : v
128
+ }
129
+ return true
130
+ }
131
+
132
+ // _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
134
+ // region until the page arrives; fast navigations never show it, so nothing flickers.
135
+ const loads = o.loading || []
136
+ function waiting(url, my) {
137
+ const path = strip(new URL(url, location.href).pathname), l = loads.find(x => new RegExp(x.rx).test(path))
138
+ const w = { done: false, t: 0 }
139
+ if (!l || !o.core) return w
140
+ w.t = setTimeout(() => {
141
+ Promise.all([import(o.core), import(l.view)])
142
+ .then(([c, v]) => {
143
+ const el = document.querySelector(region), mount = c.m || c.mount
144
+ if (w.done || my !== seq || !el || !mount) return // the page arrived first
145
+ for (const e of [el, ...walkAll(el)]) if (e.$d) e.$d(), (e.$d = null)
146
+ for (const n of [...el.childNodes]) if (!/^(STYLE|LINK)$/.test(n.nodeName)) n.remove() // the view's CSS stays (BUILD #35)
147
+ el.$d = mount(v.default, el, {})
148
+ })
149
+ .catch(() => {})
150
+ }, 150)
151
+ return w
152
+ }
153
+
154
+ async function load(url) {
155
+ const my = ++seq
156
+ let doc, r
157
+ if (stale) return hard(url)
158
+ const slow = waiting(url, my)
159
+ // the previous route's background requests (prefetch) are dropped by brain-data's queue (§41.4)
160
+ try {
161
+ G.dispatchEvent && G.dispatchEvent(new Event('brain:navigate'))
162
+ } catch {}
163
+ try {
164
+ r = (await ahead.take(url)) || (await fetch(url, { headers: { 'x-brain-nav': '1' } }))
165
+ const b = r.headers && r.headers.get && r.headers.get('x-brain-build')
166
+ if (b && build() && b !== build()) return void (my === seq && hard(url)) // a new deploy: load it whole
167
+ const ct = (r.headers && r.headers.get && r.headers.get('content-type')) || 'text/html'
168
+ if (!r.ok || !/html/.test(ct)) throw Error(r.status)
169
+ doc = parse(await r.text())
170
+ } catch {
171
+ return void ((slow.done = true), clearTimeout(slow.t), my === seq && hard(url))
172
+ }
173
+ slow.done = true
174
+ clearTimeout(slow.t)
175
+ if (my !== seq) return // a newer navigation won
176
+ // another language (§37): a full load brings its own dictionaries, `lang` and `dir`; locales never mix in memory
177
+ const lang = d => d && d.documentElement && d.documentElement.getAttribute('lang')
178
+ if (lang(doc) && lang(document) && lang(doc) !== lang(document)) return hard(url)
179
+ if (r.redirected && r.url && G.history && r.url !== new URL(url, location.href).href) history.replaceState(history.state, '', r.url)
180
+ const cur = document.querySelector(region), next = doc.querySelector(region)
181
+ if (!cur || !next) return hard(url)
182
+ const swap = () => {
183
+ // read before the swap: afterwards the fetched page's scripts are in the document too
184
+ const own = document.querySelector('script[nonce]'), nonce = own && (own.nonce || own.getAttribute('nonce'))
185
+ for (const el of [cur, ...walkAll(cur)]) if (el.$d) el.$d(), (el.$d = null)
186
+ cur.replaceChildren(...[...next.childNodes].map(n => (document.adoptNode ? document.adoptNode(n) : n)))
187
+ // Scripts parsed by DOMParser never run: the region's own scripts (the view's mount, `e.$d = mount(...)`) are
188
+ // recreated so they execute. Already-imported modules come from the module map: their state survives.
189
+ // The page's CSP only trusts its own nonce: a recreated script carries the current page's nonce, never the one
190
+ // that came with the fetched page (each server response has a fresh one).
191
+ for (const sc of cur.querySelectorAll('script')) {
192
+ if (sc.type && !/^(module|text\/javascript)$/.test(sc.type)) continue
193
+ const n = document.createElement('script')
194
+ for (const a of sc.attributes) if (a.name !== 'nonce') n.setAttribute(a.name, a.value)
195
+ if (nonce) (n.nonce = nonce), n.setAttribute('nonce', nonce)
196
+ n.textContent = sc.textContent
197
+ sc.replaceWith(n)
198
+ }
199
+ // QA #291: the view's mount script stores its dispose (`e.$d = mount(...)`) after the swap, since module scripts
200
+ // run later; until then the region shows the server's HTML with no handlers. Clicks and submits there are held and
201
+ // replayed on the mounted view, by position, so the first click after a navigation is never lost.
202
+ if (!cur.$d && [...cur.querySelectorAll('script[type=module]')].some(sc => /\$d\s*=/.test(sc.textContent))) hold(cur)
203
+ for (const a of next.attributes || []) cur.setAttribute(a.name, a.value)
204
+ if (doc.title) document.title = doc.title
205
+ for (const s of ['meta[name=description]', 'link[rel=canonical]']) {
206
+ const a = document.head && document.head.querySelector(s), b = doc.querySelector(s)
207
+ if (a && b) a.replaceWith(document.adoptNode ? document.adoptNode(b) : b)
208
+ }
209
+ // SEO (§32): og:*, twitter:*, robots and JSON-LD follow the page; the region's own tags stay where they are
210
+ const tags = (r, skip) =>
211
+ [...((r && r.querySelectorAll && r.querySelectorAll('meta,script')) || [])].filter(e => {
212
+ for (let p = e.parentNode; p; p = p.parentNode) if (p === skip) return false
213
+ const k = e.getAttribute('property') || e.getAttribute('name') || ''
214
+ return e.tagName === 'SCRIPT' ? e.getAttribute('type') === 'application/ld+json' : /^(og:|twitter:|robots$)/.test(k)
215
+ })
216
+ if (document.head) {
217
+ for (const e of tags(document.head)) e.remove()
218
+ for (const e of tags(doc.head || doc, next)) document.head.appendChild(document.adoptNode ? document.adoptNode(e) : e)
219
+ }
220
+ // the next page's route params (or none): route() reads them
221
+ const pa = document.head && document.head.querySelector('meta[name=brain-params]'), pb = doc.querySelector('meta[name=brain-params]')
222
+ if (pa) pa.remove()
223
+ if (pb && document.head) document.head.appendChild(document.adoptNode ? document.adoptNode(pb) : pb)
224
+ routed()
225
+ current()
226
+ ahead.scan()
227
+ }
228
+ if (document.startViewTransition) {
229
+ const vt = document.startViewTransition(swap)
230
+ vt.ready.catch(() => {}) // rejects when a faster navigation skips this transition: expected, not an error
231
+ await vt.updateCallbackDone
232
+ } else swap()
233
+ }
234
+ // a full load must not be intercepted again (Navigation API): `through` lets that one navigation pass
235
+ const hard = url => ((through = new URL(url, location.href).href), (o.hard || (u => location.assign(u)))(url))
236
+ const same = u => {
237
+ try {
238
+ const x = new URL(u, location.href), b = base()
239
+ // 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))
241
+ } catch {
242
+ return false
243
+ }
244
+ }
245
+
246
+ const nav = G.navigation
247
+ if (nav && nav.addEventListener) {
248
+ const h = e => {
249
+ if (through && e.destination.url === through) return void (through = null)
250
+ if (e.navigationType === 'reload') return // a reload is always a real full load (e.g. after a deploy deleted a chunk)
251
+ if (!e.canIntercept || e.hashChange || e.downloadRequest || e.formData || !same(e.destination.url)) return
252
+ if (e.cancelable && !leave()) return e.preventDefault()
253
+ const to = e.destination.url, from = nav.currentEntry && nav.currentEntry.url
254
+ if (!gs.length) return e.intercept({ handler: () => load(to) }) // scroll + focus reset handled by the browser
255
+ let g
256
+ e.intercept({
257
+ // Chrome 141+: decided before the URL commits (cancel = the address bar never changes; native redirect).
258
+ async precommitHandler(c) {
259
+ g = await check(to)
260
+ if (typeof g === 'string' && c.redirect) c.redirect(new URL(app(g), location.href).href), (g = true)
261
+ else if (g === false) throw new DOMException('guard', 'AbortError')
262
+ },
263
+ async handler() {
264
+ if (g === undefined) g = await check(to) // no precommit support: the URL already committed
265
+ if (g === true) return load(nav.currentEntry ? nav.currentEntry.url : to)
266
+ if (g === null) return hard(to)
267
+ nav.navigate(g === false ? from : new URL(app(g), location.href).href, { history: 'replace' })
268
+ },
269
+ })
270
+ }
271
+ nav.addEventListener('navigate', h)
272
+ ahead.scan()
273
+ return (active = {
274
+ go: (u, how) => nav.navigate(app(u), how === 'replace' ? { history: 'replace' } : undefined),
275
+ check,
276
+ stop: () => ((active = null), ahead.stop(), nav.removeEventListener('navigate', h), sw && sw.removeEventListener && sw.removeEventListener('message', onMsg)),
277
+ })
278
+ }
279
+
280
+ // Fallback: History API. Scroll position is kept in history.state so back/forward restores it.
281
+ const save = () => history.replaceState({ ...(history.state || {}), y: G.scrollY || 0 }, '')
282
+ let here = location.href
283
+ const go = async (u, push = true, hops = 0) => {
284
+ if (!hops && !leave()) return push || history.pushState(history.state, '', here) // stay (back/forward: restore the URL)
285
+ const g = gs.length ? await check(u) : true
286
+ if (g === null || hops > 5) return hard(u)
287
+ if (g === false) return push || history.pushState(history.state, '', here) // back/forward blocked: stay
288
+ if (g !== true) return go(new URL(app(g), location.href).href, push, hops + 1)
289
+ if (push) save(), history[push === 'replace' ? 'replaceState' : 'pushState']({ y: 0 }, '', u) // push: true | 'replace' | false (back/forward)
290
+ await load(u)
291
+ here = location.href
292
+ const hash = new URL(u, location.href).hash, t = hash && document.getElementById(hash.slice(1))
293
+ if (t) t.scrollIntoView()
294
+ else G.scrollTo && G.scrollTo(0, push ? 0 : (history.state && history.state.y) || 0)
295
+ focusMain()
296
+ }
297
+ const click = e => {
298
+ if (e.defaultPrevented || e.button || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
299
+ let a = e.target
300
+ while (a && a.tagName !== 'A') a = a.parentNode
301
+ if (!a || !a.getAttribute('href') || a.getAttribute('target') || a.hasAttribute('download') || !same(a.href || a.getAttribute('href'))) return
302
+ const u = new URL(a.href || a.getAttribute('href'), location.href)
303
+ if (u.pathname === location.pathname && u.hash) return // in-page anchor: native
304
+ e.preventDefault()
305
+ go(u.href)
306
+ }
307
+ const pop = () => go(location.href, false)
308
+ const focusMain = () => {
309
+ const m = document.querySelector(region), h = m && (m.querySelector('h1') || m)
310
+ if (h && h.focus) {
311
+ if (!h.hasAttribute('tabindex')) h.setAttribute('tabindex', '-1')
312
+ h.focus({ preventScroll: true }) // screen readers announce the new view
313
+ }
314
+ }
315
+ document.addEventListener('click', click)
316
+ G.addEventListener && G.addEventListener('popstate', pop)
317
+ ahead.scan()
318
+ return (active = {
319
+ go: (u, push = true) => go(app(u), push),
320
+ check,
321
+ stop: () => ((active = null), ahead.stop(), document.removeEventListener('click', click), G.removeEventListener && G.removeEventListener('popstate', pop)),
322
+ })
323
+ }
324
+
325
+ // Predictive prefetch (§19, 2026-10-29): no dead time during a visit. Every internal link that scrolls into view is
326
+ // fetched in the background while the browser is idle (at most 2 at a time, 12 per page), and hovering, focusing or
327
+ // touching a link moves it to the front of the queue. The fetched page is kept in memory and the next navigation to it
328
+ // swaps without waiting for the network; its module chunks get `<link rel=modulepreload>` so the view mounts at once.
329
+ // The current view never waits: requests run at low priority and only when idle. Skipped: Save-Data and 2G (only
330
+ // intent prefetches there, and never with Save-Data), `data-prefetch="false"` on the link or an ancestor, download or
331
+ // target links, files (`.pdf`, `.zip`…), `/api/` and `/auth/`, action-style paths (log out, unsubscribe, confirm,
332
+ // 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,
334
+ // and any write empties the memory first: a form submit, a `brain:write` (brain-data writes, every server function
335
+ // call, log-out included), so the next view never shows data from before the write or another session. Each fetch says
336
+ // `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
339
+ const FILE = /\.(?!html?$)[a-z0-9]{2,5}$/i
340
+ const SECRET = /(^|[?&])(token|code|key|sig|signature|auth|otp|state)=/i
341
+ export function predict(o = {}, same = () => false) {
342
+ const off = { scan() {}, stop() {}, take: async () => null }
343
+ const meta = G.document && document.querySelector && document.querySelector('meta[name=brain-prefetch]')
344
+ if (o.prefetch === false || (meta && /^(off|false|0)$/i.test(meta.getAttribute('content') || '')) || !G.document || !G.fetch) return off
345
+ const net = G.navigator && navigator.connection
346
+ 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
349
+ 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
+ }
357
+ }
358
+ const ok = a => {
359
+ if (!a || !a.getAttribute || !a.getAttribute('href') || a.getAttribute('target') || a.hasAttribute('download')) return null
360
+ for (let n = a; n && n.getAttribute; n = n.parentNode) if (n.getAttribute('data-prefetch') === 'false') return null
361
+ 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
366
+ }
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 l = document.createElement('link')
377
+ l.setAttribute('rel', 'modulepreload')
378
+ l.setAttribute('href', u)
379
+ document.head.appendChild(l)
380
+ }
381
+ }
382
+ // drop what expired, then the oldest past MAX_KEPT (a Map keeps insertion order)
383
+ 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)
387
+ }
388
+ 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
404
+ }
405
+ const pump = () => {
406
+ while (busy < PARALLEL && queue.length) {
407
+ const k = queue.shift()
408
+ if (cache.has(k)) continue
409
+ busy++
410
+ get(k).finally(() => (busy--, pump()))
411
+ }
412
+ }
413
+ const later = f => (G.requestIdleCallback ? G.requestIdleCallback(f, { timeout: 2000 }) : setTimeout(f, 200))
414
+ const want = (k, now) => {
415
+ if (!k || cache.has(k)) return
416
+ 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()))
420
+ }
421
+ const intent = e => {
422
+ if (net && net.saveData) return
423
+ let a = e.target
424
+ while (a && a.tagName !== 'A') a = a.parentNode
425
+ want(ok(a), true)
426
+ }
427
+ const evs = ['pointerover', 'focusin', 'touchstart']
428
+ 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())
431
+ document.addEventListener('submit', forget, { capture: true })
432
+ 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
+ )
440
+ return {
441
+ 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)
446
+ },
447
+ stop() {
448
+ io && io.disconnect()
449
+ for (const t of evs) document.removeEventListener(t, intent, { capture: true })
450
+ document.removeEventListener('submit', forget, { capture: true })
451
+ G.removeEventListener && G.removeEventListener('brain:write', forget)
452
+ cache.clear()
453
+ },
454
+ // the prefetched page for this URL when it is still fresh (used once: a later visit fetches again)
455
+ async take(u) {
456
+ const k = key(u), c = k && cache.get(k)
457
+ 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
461
+ },
462
+ }
463
+ }
464
+
465
+ const HOLD = ['click', 'submit']
466
+ function hold(el) {
467
+ if (el.$hold) el.$hold() // a newer navigation drops what the previous page held
468
+ const q = []
469
+ const keep = e => {
470
+ const path = []
471
+ for (let n = e.target; n && n !== el; n = n.parentNode) path.unshift([].indexOf.call(n.parentNode.childNodes, n))
472
+ q.push([e, path])
473
+ e.preventDefault()
474
+ e.stopImmediatePropagation()
475
+ }
476
+ const done = () => {
477
+ clearTimeout(t)
478
+ for (const k of HOLD) el.removeEventListener(k, keep, true)
479
+ delete el.$d
480
+ el.$hold = null
481
+ }
482
+ // a page whose script never mounts (it failed to load) stops holding after 5 s: later clicks act on the HTML as is
483
+ const t = setTimeout(done, 5000)
484
+ for (const k of HOLD) el.addEventListener(k, keep, true)
485
+ el.$hold = done
486
+ Object.defineProperty(el, '$d', {
487
+ configurable: true,
488
+ get: () => undefined,
489
+ set(v) {
490
+ done()
491
+ el.$d = v
492
+ for (const [e, path] of q) {
493
+ let n = el
494
+ for (const i of path) n = n && n.childNodes[i]
495
+ if (n) n.dispatchEvent(new e.constructor(e.type, e))
496
+ }
497
+ },
498
+ })
499
+ }
500
+ function walkAll(n, out = []) {
501
+ for (const c of n.childNodes) if (c.nodeType === 1) out.push(c), walkAll(c, out)
502
+ return out
503
+ }
package/brain-rpc.js ADDED
@@ -0,0 +1,20 @@
1
+ // BRAINLLM RPC client (INTERNALS §11.1), loaded by the core's `rpc()` on the first call (the core stays small: #226).
2
+ // Same-origin POST with an anti-CSRF header; returns the response JSON. A failure (network or status ≥400) is reported
3
+ // to the catcher and rethrown to the caller; 422 (validation, `{ errors: { field: msg } }` that form() marks) is
4
+ // expected, not reported. It never imports the core (that would be a hash cycle): the core hands it `oops` and the base.
5
+ export async function rpc(id, a, oops, base) {
6
+ let r, j
7
+ try {
8
+ r = await fetch(base + '_r/' + id, { method: 'POST', headers: { 'content-type': 'application/json', 'x-brain-rpc': '1' }, body: JSON.stringify(a) })
9
+ const t = await r.text()
10
+ j = t ? JSON.parse(t) : undefined
11
+ } catch (e) {
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'))
16
+ }
17
+ if (r.ok) return j
18
+ const e = Object.assign(Error((j && j.error) || 'rpc ' + id + ': ' + r.status), { code: 'rpc', status: r.status, id, errors: j && j.errors })
19
+ throw (r.status == 422 || oops(e, null, 'rpc'), e)
20
+ }
@@ -0,0 +1,93 @@
1
+ // BRAINLLM schema mini-language (INTERNALS §14, §15): shared by form() (brain-form.js), api() validation
2
+ // (brain-data.js) and server.mjs, so a page that only fetches data does not load the form module.
3
+ // Per field: `str num bool email url date` + `!` required + `<=N >=N` (length for str and lists, value for num) + `[T]`
4
+ // list of T, or a nested schema object.
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.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
+ }
30
+
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
41
+ return r
42
+ }
43
+
44
+ const empty = v => v == null || v === '' || (Array.isArray(v) && !v.length)
45
+ const plural = (n, w) => `${n} ${w}${n === 1 ? '' : 's'}`
46
+
47
+ // Message for one value, or '' when valid.
48
+ export function check(r, v) {
49
+ if (r.list) {
50
+ 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
+ if (!Array.isArray(v)) return msg('form.list', 'Must be a list')
52
+ if (r.min != null && v.length < r.min) return msg('form.min_items', `Choose at least ${r.min}`, { n: r.min })
53
+ if (r.max != null && v.length > r.max) return msg('form.max_items', `At most ${plural(r.max, 'item')}`, { n: r.max })
54
+ const one = { ...r, list: false, req: true, min: null, max: null }
55
+ for (let i = 0; i < v.length; i++) {
56
+ const e = check(one, v[i])
57
+ if (e) return msg('form.item', `Item ${i + 1}: ${e}`, { n: i + 1, error: e })
58
+ }
59
+ return ''
60
+ }
61
+ if (r.t === 'bool') return r.req && v !== true ? msg('form.required', 'Required') : ''
62
+ if (empty(v)) return r.req ? msg('form.required', 'Required') : ''
63
+ const s = String(v)
64
+ switch (r.t) {
65
+ case 'num': {
66
+ const n = typeof v === 'number' ? v : s.trim() === '' ? NaN : Number(s)
67
+ if (!Number.isFinite(n)) return msg('form.number', 'Enter a number')
68
+ if (r.min != null && n < r.min) return msg('form.min', `Must be at least ${r.min}`, { n: r.min })
69
+ if (r.max != null && n > r.max) return msg('form.max', `Must be at most ${r.max}`, { n: r.max })
70
+ return ''
71
+ }
72
+ case 'email':
73
+ if (!RX_EMAIL.test(s)) return msg('form.email', 'Enter a valid email')
74
+ break
75
+ case 'url':
76
+ try {
77
+ if (!/^https?:$/.test(new URL(s).protocol)) return msg('form.url', 'Enter a valid URL')
78
+ } catch {
79
+ return msg('form.url', 'Enter a valid URL')
80
+ }
81
+ break
82
+ case 'date':
83
+ if (Number.isNaN(Date.parse(s))) return msg('form.date', 'Enter a valid date')
84
+ return ''
85
+ case 'str':
86
+ break
87
+ default:
88
+ throw Error(`form: unknown type '${r.t}'`)
89
+ }
90
+ if (r.min != null && s.length < r.min) return msg('form.min_chars', `At least ${plural(r.min, 'character')}`, { n: r.min })
91
+ if (r.max != null && s.length > r.max) return msg('form.max_chars', `At most ${plural(r.max, 'character')}`, { n: r.max })
92
+ return ''
93
+ }