@christianirack/brainllm-runtime 0.7.0 → 0.8.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/brain-data.js CHANGED
@@ -380,10 +380,11 @@ async function once(u, M, body, sg, pri, up) {
380
380
  if (st === 401 && P.login && G.location && !location.pathname.endsWith(P.login)) location.assign(appBase() + P.login + '?next=' + encodeURIComponent(location.pathname + location.search))
381
381
  const native = j && j.schemaVersion === 1 && j.error && typeof j.error === 'object' && typeof j.error.code === 'string' && /^[A-Z][A-Z0-9_]{0,63}$/.test(j.error.code)
382
382
  const fields = native && st === 422 ? j.error.fields : j && j.errors
383
- // "highlighted fields" only when there are fields to highlight: else the backend's own fix, or a plain sentence
384
- const generic = st === 401 ? 'Authentication failed. Sign in again.' : st === 403 ? 'You do not have permission to continue.' : st === 422 ? (fields && Object.keys(fields).length ? 'Check the highlighted fields.' : (native && typeof j.error.fix === 'string' && j.error.fix.slice(0, 512)) || 'The server rejected this request. Check it and try again.') : st === 429 ? 'Too many requests. Wait before trying again.' : `Request failed (${st}).`
383
+ // "highlighted fields" only when there are fields to highlight. The backend's `fix` is for the developer (error.fix),
384
+ // never visitor text: it is English and names what the app should change.
385
+ const generic = st === 401 ? 'Authentication failed. Sign in again.' : st === 403 ? 'You do not have permission to continue.' : st === 422 ? (fields && Object.keys(fields).length ? 'Check the highlighted fields.' : 'The server rejected this request. Check it and try again.') : st === 429 ? 'Too many requests. Wait before trying again.' : `Request failed (${st}).`
385
386
  const message = !native && st < 500 && typeof j?.error === 'string' && j.error.length <= 512 && !/[\r\n]|(?:TypeError|ReferenceError|SyntaxError|Error):/.test(j.error) ? j.error : generic
386
- throw new ApiError('http', message, { status: st, errors: fields, retryAfter, id: h('x-request-id') || undefined, ...(native ? { backendCode: j.error.code, fix: typeof j.error.fix === 'string' ? j.error.fix.slice(0,1024) : undefined } : {}) })
387
+ throw new ApiError('http', message, { status: st, errors: fields, retryAfter, id: h('x-request-id') || undefined, plain: message === generic, ...(native ? { backendCode: j.error.code, fix: typeof j.error.fix === 'string' ? j.error.fix.slice(0,1024) : undefined } : {}) })
387
388
  }
388
389
 
389
390
  // multipart upload with progress (fetch has no upload progress)
package/brain-form.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // BRAINLLM forms (INTERNALS §14). `let f = form(schema, { submit: async (v) => … })` →
2
2
  // bind:value={f.v.email} · {f.err.email} · f.busy · f.error (form-level) · f.ok · on:submit={f.submit}
3
- // Schema mini-language per field: `str num bool email url date` + `!` required + `<=N >=N` (length for str and lists,
4
- // value for num) + `[T]` list of T, or a nested schema object (a type from types.jsbrain).
3
+ // Schema mini-language per field: `str num bool email url date tel file` + `!` required + `<=N >=N` (length for str and
4
+ // lists, value for num, bytes for file) + `[T]` list of T, or a nested schema object (a type from types.jsbrain).
5
5
  // Validation: a field is validated when it loses focus and every field on submit; errors show only after that.
6
6
  // The element of each field is found without ids: the control that has focus while its value is written (the
7
7
  // `bind:` input handler), or on submit `form.elements[name]`. On it the runtime keeps `aria-invalid` and, when the
@@ -42,7 +42,7 @@ if (globalThis.addEventListener && !globalThis.__brainDirtyHook) {
42
42
  })
43
43
  }
44
44
  const empty = v => v == null || typeof v === 'string' && v.trim() === '' || Array.isArray(v) && !v.length
45
- const init0 = r => (r.list ? [] : r.t === 'bool' ? false : '')
45
+ const init0 = r => (r.list ? [] : r.t === 'bool' ? false : r.t === 'file' ? null : '')
46
46
  const out = (r, v) => {
47
47
  if (r.list) {
48
48
  if (!Array.isArray(v)) return []
@@ -277,9 +277,10 @@ function createForm(schema, o, readRule, validateRule, remote) {
277
277
  shown.v++
278
278
  })
279
279
  if (o.validate && !first) {
280
- const cross = o.validate(values()) // cross-field rules: { field: msg }
281
- if (cross && Object.keys(cross).length) {
282
- mark(cross)
280
+ const cross = o.validate(values()) // cross-field rules: { field: msg }; an empty message means valid
281
+ const failed = cross && Object.fromEntries(Object.entries(cross).filter(([, message]) => message))
282
+ if (failed && Object.keys(failed).length) {
283
+ mark(failed)
283
284
  return void aim()
284
285
  }
285
286
  }
@@ -303,8 +304,9 @@ function createForm(schema, o, readRule, validateRule, remote) {
303
304
  } catch (x) {
304
305
  if (x?.errors) mark(x.errors)
305
306
  // a server failure reads the same as without JS: 4xx and the author's own errors keep their message; 5xx and a
306
- // TypeError (the network dropped, or a bug) get the generic one
307
- else if (x?.kind !== 'aborted') error.v = (!(x?.status >= 500) && x?.name !== 'TypeError' && x?.message) || msg('form.error', GENERIC)
307
+ // TypeError (the network dropped, or a bug) get the generic one; the runtime's own English sentence (`plain`) gives
308
+ // way to the app's translated form.rejected
309
+ else if (x?.kind !== 'aborted') error.v = (!(x?.status >= 500) && x?.name !== 'TypeError' && (x?.plain ? msg('form.rejected', x.message) : x?.message)) || msg('form.error', GENERIC)
308
310
  } finally {
309
311
  busy.v = false
310
312
  if (fm) fm.removeAttribute('aria-busy')
@@ -0,0 +1,120 @@
1
+ // Dev observability panel. `brain run` loads this file; builds never contain it. Alt+Shift+O or `?brain-observe` opens it.
2
+ // It shows what the running page does: Web Vitals, requests, long frames, errors and live components, and "Pick" names
3
+ // the component and source file of an element. `__brainObserve.snapshot()` returns the same data as JSON for an agent.
4
+ import { live } from './brain-dev.js'
5
+ import { sub } from './brain-err.js'
6
+ import { vitals } from './brain-vitals.js'
7
+
8
+ const LIMIT = 40
9
+ const data = { vitals: {}, requests: [], frames: [], errors: 0, updates: 0 }
10
+ const keep = (list, item) => { list.unshift(item); if (list.length > LIMIT) list.pop() }
11
+ let host, box, on = false, timer, picking = null
12
+
13
+ const CSS = `:host{all:initial;position:fixed;left:16px;bottom:16px;z-index:2147483646;font:12px/1.45 system-ui,-apple-system,sans-serif;color:#1a1a1a}
14
+ .p{width:min(420px,calc(100vw - 32px));max-height:min(70vh,560px);overflow:auto;box-sizing:border-box;border-radius:16px;padding:12px 14px;background:rgba(255,255,255,.76);
15
+ -webkit-backdrop-filter:blur(20px) saturate(1.6);backdrop-filter:blur(20px) saturate(1.6);border:1px solid rgba(0,0,0,.08);
16
+ box-shadow:0 1px 3px rgba(16,24,40,.08),0 12px 32px rgba(16,24,40,.14)}@supports not (backdrop-filter:blur(1px)){.p{background:#fff}}
17
+ @media (prefers-color-scheme:dark){:host{color:#f2f2f2}.p{background:rgba(28,28,30,.84);border-color:rgba(255,255,255,.1)}}
18
+ .h{display:flex;justify-content:space-between;align-items:center;gap:8px;font-weight:600}h3{margin:10px 0 4px;font-size:11px;font-weight:600;opacity:.65}
19
+ .v{display:flex;flex-wrap:wrap;gap:6px;font-variant-numeric:tabular-nums}.v span{padding:2px 7px;border-radius:8px;background:rgba(127,127,127,.14)}
20
+ .good{color:#0a7d32}.needs-improvement{color:#a85b00}.poor,.bad{color:#d70015}
21
+ .r{display:flex;gap:8px;font-variant-numeric:tabular-nums}.r b{font-weight:500;min-width:30px}.r i{flex:1;font-style:normal;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}.e{opacity:.6}
22
+ button{font:inherit;padding:3px 10px;border-radius:8px;border:1px solid rgba(127,127,127,.35);background:transparent;color:inherit;cursor:pointer}`
23
+
24
+ const el = (tag, cls, text) => Object.assign(document.createElement(tag), cls ? { className: cls } : {}, text === undefined ? {} : { textContent: text })
25
+ const fileOf = i => i.C?.f ?? null
26
+ const nameOf = i => i.C?.C?.name || i.C?.name || i.h
27
+
28
+ export function components() {
29
+ const rows = []
30
+ for (const [hash, set] of live) if (set.size) { const i = set.values().next().value; rows.push({ name: nameOf(i), file: fileOf(i), hash, instances: set.size }) }
31
+ return rows.sort((a, b) => b.instances - a.instances)
32
+ }
33
+ // The innermost live component: brain-dev marks each root element of an instance with `$i`.
34
+ export function componentOf(node) {
35
+ for (let n = node; n; n = n.parentNode ?? n.host) {
36
+ const i = n.$i
37
+ if (i) return { name: nameOf(i), file: fileOf(i), hash: i.h }
38
+ }
39
+ return null
40
+ }
41
+ export const snapshot = () => ({ path: location.pathname, vitals: { ...data.vitals }, requests: data.requests.slice(), longFrames: data.frames.slice(), errors: data.errors, hotUpdates: data.updates, components: components() })
42
+
43
+ function section(title, rows, empty) {
44
+ const out = [el('h3', '', title)]
45
+ if (!rows.length) out.push(el('div', 'e', empty))
46
+ return out.concat(rows)
47
+ }
48
+ function draw() {
49
+ if (!box) return
50
+ const pick = el('button', '', picking ? 'Picking… Esc cancels' : 'Pick')
51
+ pick.onclick = () => (picking ? stopPick() : startPick())
52
+ const head = el('div', 'h', 'brain observe')
53
+ head.append(pick)
54
+ const metrics = el('div', 'v')
55
+ for (const [name, m] of Object.entries(data.vitals)) metrics.append(el('span', m.rating, `${name} ${m.value}${name === 'CLS' ? '' : ' ms'}`))
56
+ const row = (a, b, c, cls) => { const r = el('div', 'r' + (cls ? ' ' + cls : '')); r.append(el('b', '', a), el('i', '', b), el('span', '', c)); return r }
57
+ box.replaceChildren(
58
+ head,
59
+ ...section('Web Vitals', metrics.childElementCount ? [metrics] : [], 'Waiting for the browser to report.'),
60
+ ...section(`Requests (${data.requests.length})`, data.requests.slice(0, 8).map(r => row(String(r.status ?? '…'), `${r.method ?? ''} ${r.url}`, `${r.ms ?? '?'} ms`, r.status >= 400 ? 'bad' : '')), 'No api() request yet.'),
61
+ ...section(`Long frames (${data.frames.length}) · errors ${data.errors} · hot updates ${data.updates}`, data.frames.slice(0, 4).map(f => row(`${f.ms}`, f.source || 'unattributed', 'ms')), 'No frame over 50 ms.'),
62
+ ...section(`Components (${live.size})`, components().slice(0, 8).map(c => row(`×${c.instances}`, c.file ?? c.name, '')), 'None mounted.'),
63
+ )
64
+ }
65
+ function toggle(force) {
66
+ on = force ?? !on
67
+ try { on ? sessionStorage.setItem('brain:observe', '1') : sessionStorage.removeItem('brain:observe') } catch {}
68
+ if (!on) { stopPick(); clearInterval(timer); host?.remove(); return on }
69
+ if (!host) {
70
+ host = document.createElement('brain-observe')
71
+ const root = host.attachShadow({ mode: 'open' })
72
+ root.append(el('style', '', CSS))
73
+ box = root.appendChild(el('div', 'p'))
74
+ }
75
+ document.body.append(host)
76
+ draw()
77
+ timer = setInterval(draw, 1000)
78
+ return on
79
+ }
80
+ function startPick() {
81
+ const mark = el('div')
82
+ mark.style.cssText = 'position:fixed;z-index:2147483645;pointer-events:none;outline:2px solid #0071e3;background:rgba(0,113,227,.08);border-radius:3px'
83
+ const tip = el('div')
84
+ tip.style.cssText = 'position:fixed;z-index:2147483645;pointer-events:none;padding:2px 8px;border-radius:6px;background:#111;color:#fff;font:12px ui-monospace,monospace'
85
+ document.body.append(mark, tip)
86
+ const move = ev => {
87
+ if (ev.target === host) return
88
+ const r = ev.target.getBoundingClientRect(), c = componentOf(ev.target)
89
+ Object.assign(mark.style, { left: r.left + 'px', top: r.top + 'px', width: r.width + 'px', height: r.height + 'px' })
90
+ Object.assign(tip.style, { left: r.left + 'px', top: Math.max(0, r.top - 24) + 'px' })
91
+ tip.textContent = c ? c.file ?? c.name : 'outside any component'
92
+ }
93
+ const click = ev => {
94
+ if (ev.target === host) return
95
+ ev.preventDefault(); ev.stopPropagation()
96
+ const c = componentOf(ev.target)
97
+ if (c) console.info('brain:observe', c)
98
+ dispatchEvent(new CustomEvent('brain:observe-pick', { detail: c }))
99
+ stopPick()
100
+ }
101
+ const key = ev => ev.key === 'Escape' && stopPick()
102
+ addEventListener('mousemove', move, true); addEventListener('click', click, true); addEventListener('keydown', key, true)
103
+ picking = () => { removeEventListener('mousemove', move, true); removeEventListener('click', click, true); removeEventListener('keydown', key, true); mark.remove(); tip.remove() }
104
+ draw()
105
+ }
106
+ function stopPick() { picking?.(); picking = null; draw() }
107
+
108
+ export function start() {
109
+ vitals(m => { data.vitals[m.name] = m })
110
+ addEventListener('brain:net', ev => keep(data.requests, ev.detail))
111
+ addEventListener('brain:hmr', () => data.updates++)
112
+ sub.add(() => data.errors++)
113
+ if (PerformanceObserver.supportedEntryTypes?.includes('long-animation-frame'))
114
+ new PerformanceObserver(list => { for (const e of list.getEntries()) keep(data.frames, { ms: Math.round(e.duration), source: e.scripts?.[0]?.sourceURL ? new URL(e.scripts[0].sourceURL, location.href).pathname : '' }) }).observe({ type: 'long-animation-frame', buffered: true })
115
+ addEventListener('keydown', ev => { if (ev.altKey && ev.shiftKey && ev.code === 'KeyO') { ev.preventDefault(); toggle() } })
116
+ globalThis.__brainObserve = { snapshot, toggle, componentOf }
117
+ let saved = false
118
+ try { saved = sessionStorage.getItem('brain:observe') === '1' } catch {}
119
+ if (saved || new URLSearchParams(location.search).has('brain-observe')) toggle(true)
120
+ }
package/brain-router.js CHANGED
@@ -36,6 +36,7 @@ export function navigate(to, o = {}) {
36
36
  if (/[\\\x00-\x20\x7f]/.test(raw)) throw Error('Invalid navigation target')
37
37
  const u = app(raw)
38
38
  if (active) return active.go(u, o.replace ? 'replace' : true)
39
+ if (G.__brainDevGo) return G.__brainDevGo(u, o.replace)
39
40
  o.replace ? location.replace(u) : location.assign(u)
40
41
  }
41
42
  G.__brainNavigate = navigate // brain-form follows a server function's { redirect } without importing the router
package/brain-schema.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // BRAINLLM schema mini-language (INTERNALS §14, §15): shared by form() (brain-form.js), api() validation
2
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.
3
+ // Per field: `str num bool email url date tel file` + `!` required + `<=N >=N` (length for str and lists, value for num,
4
+ // bytes for file) + `[T]` list of T, or a nested schema object. `file(image/*,.pdf)` names the accepted types.
5
5
  const RX_EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
6
6
  const G = globalThis
7
7
 
@@ -9,7 +9,8 @@ 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.text form.boolean form.min_chars form.max_chars.
12
+ // form.url form.date form.text form.boolean form.min_chars form.max_chars form.tel form.file form.file_type
13
+ // form.file_min form.file_max.
13
14
  export function msg(key, en, vars) {
14
15
  const D = G.__brainDict
15
16
  if (D) {
@@ -29,14 +30,21 @@ export function msg(key, en, vars) {
29
30
  }
30
31
 
31
32
  // 'str!<=80' → { t: 'str', req: true, min, max, list: false }
32
- const TYPES = new Set(['str', 'num', 'bool', 'email', 'url', 'date'])
33
+ const TYPES = new Set(['str', 'num', 'bool', 'email', 'url', 'date', 'tel', 'file'])
34
+ const ACCEPT = /^(?:\.[a-z0-9]+|[a-z]+\/(?:\*|[a-z0-9.+-]+))$/
33
35
  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 }
36
+ const syntax = '(use str num bool email url date tel file, file(image/*,.pdf), ! required, <=N >=N, [T])'
37
+ if (typeof s !== 'string' || s.length > 256) throw Error(`form: invalid rule ${syntax}`)
38
+ const m = /^(?:([a-z]+)(?:\(([^()]*)\))?(!)?|\[([a-z]+)(?:\(([^()]*)\))?(!)?\](!)?)((?:[<>]=-?\d+(?:\.\d+)?)*)$/.exec(s)
39
+ if (!m || m[6] && m[7]) throw Error(`form: invalid rule '${s}' ${syntax}`)
40
+ const r = { t: m[1] || m[4], req: !!(m[3] || m[6] || m[7]), list: !!m[4], min: null, max: null }
38
41
  if (!TYPES.has(r.t)) throw Error(`form: unknown type '${r.t}'`)
39
- for (const [, op, n] of m[6].matchAll(/([<>])=(-?\d+(?:\.\d+)?)/g)) {
42
+ const accept = m[2] ?? m[5]
43
+ if (accept != null) {
44
+ r.accept = accept.split(',').map(a => a.trim().toLowerCase())
45
+ if (r.t !== 'file' || !r.accept.every(a => ACCEPT.test(a))) throw Error(`form: invalid rule '${s}' ${syntax}`)
46
+ }
47
+ for (const [, op, n] of m[8].matchAll(/([<>])=(-?\d+(?:\.\d+)?)/g)) {
40
48
  const key = op === '<' ? 'max' : 'min', value = Number(n)
41
49
  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
50
  r[key] = value
@@ -73,6 +81,24 @@ function calendarDate(s) {
73
81
  return year > 0 && month > 0 && month <= 12 && day > 0 && day <= days[month - 1]
74
82
  }
75
83
 
84
+ // A phone as people write it: optional leading +, digits with single separators, 7 to 15 digits (E.164's maximum).
85
+ function validTel(s) {
86
+ const t = s.trim()
87
+ if (!/^\+?\(?\d(?:[ .()-]{0,2}\d)*$/.test(t)) return false
88
+ const digits = t.replace(/\D/g, '').length
89
+ return digits >= 7 && digits <= 15
90
+ }
91
+ // The browser's File/Blob shape. The type and name come from the client: the server has to verify the content again.
92
+ const isFile = v => !!v && typeof v === 'object' && typeof v.size === 'number' && typeof v.type === 'string'
93
+ const accepted = (r, f) => r.accept.some(a => a[0] === '.' ? String(f.name || '').toLowerCase().endsWith(a) : a.endsWith('/*') ? f.type.toLowerCase().startsWith(a.slice(0, -1)) : f.type.toLowerCase() === a)
94
+ function checkFile(r, f) {
95
+ if (!isFile(f)) return msg('form.file', 'Choose a file')
96
+ if (r.accept && !accepted(r, f)) return msg('form.file_type', 'This file type is not allowed')
97
+ if (r.min != null && f.size < r.min) return msg('form.file_min', `The file must be at least ${r.min} bytes`, { n: r.min })
98
+ if (r.max != null && f.size > r.max) return msg('form.file_max', `The file must be at most ${r.max} bytes`, { n: r.max })
99
+ return ''
100
+ }
101
+
76
102
  const plural = (n, w) => `${n} ${w}${n === 1 ? '' : 's'}`
77
103
 
78
104
  // Compiler-only validators for proven unbounded scalars. Separate entry points let
@@ -106,6 +132,7 @@ export function check(r, v) {
106
132
  }
107
133
  return ''
108
134
  }
135
+ if (r.t === 'file' && !empty(v)) return checkFile(r, v)
109
136
  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
137
  if (empty(v)) return r.req ? msg('form.required', 'Required') : ''
111
138
  if (r.t === 'bool') return typeof v !== 'boolean' ? msg('form.boolean', 'Choose true or false') : r.req && !v ? msg('form.required', 'Required') : ''
@@ -132,9 +159,14 @@ export function check(r, v) {
132
159
  case 'date':
133
160
  if (typeof v !== 'string' || !calendarDate(s)) return msg('form.date', 'Enter a valid date')
134
161
  break
162
+ case 'tel':
163
+ if (typeof v !== 'string' || !validTel(s)) return msg('form.tel', 'Enter a valid phone number')
164
+ break
135
165
  case 'str':
136
166
  if (typeof v !== 'string') return msg('form.text', 'Enter text')
137
167
  break
168
+ case 'file':
169
+ return msg('form.file', 'Choose a file')
138
170
  default:
139
171
  throw Error(`form: unknown type '${r.t}'`)
140
172
  }
@@ -0,0 +1,60 @@
1
+ // Core Web Vitals, opt-in: `vitals(report)` measures in this page and `reportVitals(url)` sends the final values.
2
+ // Nothing is emitted unless an app imports this module. Metrics a browser lacks are never reported, so read them as
3
+ // optional: LCP and INP do not exist in every engine. Thresholds are the published web.dev good/poor limits.
4
+ const LIMITS = { LCP: [2500, 4000], CLS: [0.1, 0.25], INP: [200, 500], FCP: [1800, 3000], TTFB: [800, 1800] }
5
+ const rate = (name, value) => (value <= LIMITS[name][0] ? 'good' : value <= LIMITS[name][1] ? 'needs-improvement' : 'poor')
6
+
7
+ export function vitals(report, env = globalThis) {
8
+ const PO = env.PerformanceObserver
9
+ const types = PO?.supportedEntryTypes ?? []
10
+ const stops = []
11
+ const send = (name, value) => report({ name, value: name === 'CLS' ? +value.toFixed(4) : Math.round(value), rating: rate(name, value) })
12
+ const watch = (type, options, read) => {
13
+ if (!types.includes(type)) return
14
+ const po = new PO(list => read(list.getEntries()))
15
+ po.observe({ type, buffered: true, ...options })
16
+ stops.push(() => po.disconnect())
17
+ }
18
+ const nav = env.performance?.getEntriesByType?.('navigation')?.[0]
19
+ if (nav?.responseStart > 0) send('TTFB', nav.responseStart)
20
+ watch('paint', {}, list => { for (const e of list) if (e.name === 'first-contentful-paint') send('FCP', e.startTime) })
21
+ watch('largest-contentful-paint', {}, list => { const last = list[list.length - 1]; if (last) send('LCP', last.startTime) })
22
+ // CLS: the worst session window (gap under 1 s, at most 5 s long), without shifts caused by recent input.
23
+ let worst = 0, sum = 0, first = 0, previous = 0
24
+ watch('layout-shift', {}, list => {
25
+ for (const e of list) {
26
+ if (e.hadRecentInput) continue
27
+ if (sum && e.startTime - previous < 1000 && e.startTime - first < 5000) sum += e.value
28
+ else { sum = e.value; first = e.startTime }
29
+ previous = e.startTime
30
+ if (sum > worst) { worst = sum; send('CLS', worst) }
31
+ }
32
+ })
33
+ // INP: the slowest interaction, one in 50 discarded as an outlier, as the metric's definition says.
34
+ const slow = new Map()
35
+ let inp = -1
36
+ watch('event', { durationThreshold: 40 }, list => {
37
+ for (const e of list) if (e.interactionId) slow.set(e.interactionId, Math.max(slow.get(e.interactionId) ?? 0, e.duration))
38
+ const all = [...slow.values()].sort((a, b) => b - a)
39
+ const value = all[Math.min(all.length - 1, Math.floor(all.length / 50))]
40
+ if (value !== undefined && value !== inp) send('INP', (inp = value))
41
+ if (slow.size > 500) for (const key of [...slow.keys()].slice(0, 250)) slow.delete(key)
42
+ })
43
+ return () => { for (const stop of stops) stop() }
44
+ }
45
+
46
+ // One beacon each time the page is hidden, with what changed since the last one; the query string is never sent.
47
+ export function reportVitals(url, env = globalThis) {
48
+ if (typeof url !== 'string' || !url.startsWith('/')) throw new TypeError('reportVitals(url): pass a same-origin path, such as "/api/vitals"')
49
+ const doc = env.document
50
+ const fresh = new Map()
51
+ const stop = vitals(metric => fresh.set(metric.name, metric), env)
52
+ const flush = () => {
53
+ if (doc.visibilityState !== 'hidden' || !fresh.size) return
54
+ const body = JSON.stringify({ path: env.location.pathname, metrics: [...fresh.values()] })
55
+ if (env.navigator.sendBeacon?.(url, new Blob([body], { type: 'application/json' }))) fresh.clear()
56
+ }
57
+ doc.addEventListener('visibilitychange', flush)
58
+ env.addEventListener('pagehide', flush)
59
+ return () => { stop(); doc.removeEventListener('visibilitychange', flush); env.removeEventListener('pagehide', flush) }
60
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@christianirack/brainllm-runtime",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "The BRAINLLM runtime for the Rust-built web compiler for LLMs: dependency-free ES modules (signals, DOM, router, data, forms, channels). The brain binary embeds this same version.",
5
5
  "type": "module",
6
6
  "exports": {
package/server/llm.mjs ADDED
@@ -0,0 +1,199 @@
1
+ // Server LLM module (INTERNALS §51): `ctx.llm`, one provider-neutral call for Anthropic and OpenAI. Zero dependencies.
2
+ // Provider contracts reviewed 2026-10-31 against the official documentation:
3
+ // - Anthropic: platform.claude.com/docs/en/api/messages, /api/versioning, /api/errors, /build-with-claude/streaming.
4
+ // API version 2026-10-21 rejects temperature, top_p and top_k with a 400, so the neutral API has no sampling options.
5
+ // - OpenAI: developers.openai.com/api/reference/resources/responses/methods/create, /api/docs/guides/streaming-responses
6
+ // and the event schemas of github.com/openai/openai-openapi (Responses API, stored off with `store: false`).
7
+ // Keys come only from the server environment. An error never carries a key; provider text stays in `detail`, redacted.
8
+ const LLM_PROVIDERS = {
9
+ anthropic: { key: 'ANTHROPIC_API_KEY', model: 'ANTHROPIC_MODEL', base: 'ANTHROPIC_BASE_URL', origin: 'https://api.anthropic.com' },
10
+ openai: { key: 'OPENAI_API_KEY', model: 'OPENAI_MODEL', base: 'OPENAI_BASE_URL', origin: 'https://api.openai.com' },
11
+ }
12
+ const LLM_ANTHROPIC_VERSION = '2026-10-21'
13
+ const LLM_DEFAULT_TIMEOUT = 60000, LLM_MAX_TIMEOUT = 600000, LLM_DEFAULT_MAX_TOKENS = 1024
14
+ const LLM_MAX_BODY = 32 * 1024 * 1024, LLM_MAX_LINE = 4 * 1024 * 1024, LLM_MAX_DETAIL = 500
15
+
16
+ export class LlmError extends Error {
17
+ constructor(code, status, message, fix, extra = {}) {
18
+ super(message)
19
+ this.name = 'LlmError'
20
+ this.code = code
21
+ this.status = status
22
+ this.fix = fix
23
+ Object.assign(this, extra)
24
+ }
25
+ }
26
+ const llmConfig = (message, fix) => new LlmError('B0921', 503, message, fix)
27
+ const llmFailure = (status, message, extra) => new LlmError('B0922', status, message, 'Inspect err.provider, err.providerStatus and err.detail on the server', extra)
28
+
29
+ function llmResolve(env, o) {
30
+ if (!o || typeof o !== 'object') throw llmConfig('ctx.llm needs an options object', 'Pass { messages: [{ role: "user", content: "…" }] } or { prompt: "…" }')
31
+ const requested = o.provider ?? env.BRAIN_LLM_PROVIDER
32
+ if (requested != null && requested !== '' && !Object.hasOwn(LLM_PROVIDERS, requested)) throw llmConfig(`Unknown LLM provider "${String(requested).slice(0, 40)}"`, 'Use "anthropic" or "openai"')
33
+ const present = Object.keys(LLM_PROVIDERS).filter(name => env[LLM_PROVIDERS[name].key])
34
+ const provider = requested || (present.length === 1 ? present[0] : null)
35
+ if (!provider) throw llmConfig(present.length ? 'Both LLM provider keys are set and none is selected' : 'No LLM provider key is set', present.length ? 'Pass { provider } or set BRAIN_LLM_PROVIDER in .brain_env' : 'Set ANTHROPIC_API_KEY or OPENAI_API_KEY in .brain_env (never with PUBLIC_)')
36
+ const p = LLM_PROVIDERS[provider], key = env[p.key]
37
+ if (!key) throw llmConfig(`${p.key} is not set`, `Set ${p.key} in .brain_env (never with PUBLIC_)`)
38
+ const model = o.model ?? env[p.model]
39
+ if (typeof model !== 'string' || !model) throw llmConfig(`No model selected for ${provider}`, `Pass { model } or set ${p.model} in .brain_env; BRAINLLM pins no model`)
40
+ let base
41
+ try { base = new URL(env[p.base] || p.origin) } catch { throw llmConfig(`${p.base} is not a URL`, `Set ${p.base} to an https address`) }
42
+ const loopback = base.hostname === '127.0.0.1' || base.hostname === 'localhost' || base.hostname === '[::1]'
43
+ if (base.protocol !== 'https:' && !(base.protocol === 'http:' && loopback)) throw llmConfig(`${p.base} must use https`, 'Only a loopback address may use plain http')
44
+ if (base.username || base.password) throw llmConfig(`${p.base} must not carry credentials`, `Keep the key in ${p.key}`)
45
+ const messages = o.messages ?? (typeof o.prompt === 'string' ? [{ role: 'user', content: o.prompt }] : null)
46
+ if (!Array.isArray(messages) || !messages.length) throw new LlmError('B0921', 400, 'ctx.llm needs messages or prompt', 'Pass { messages: [{ role: "user", content: "…" }] }')
47
+ for (const m of messages) if (!m || (m.role !== 'user' && m.role !== 'assistant') || typeof m.content !== 'string') throw new LlmError('B0921', 400, 'Each message needs role "user" or "assistant" and text content', 'The system prompt goes in { system }')
48
+ if (o.system != null && typeof o.system !== 'string') throw new LlmError('B0921', 400, 'system must be text', 'Pass { system: "…" }')
49
+ const maxTokens = o.maxTokens ?? LLM_DEFAULT_MAX_TOKENS
50
+ if (!Number.isSafeInteger(maxTokens) || maxTokens < 1) throw new LlmError('B0921', 400, 'maxTokens must be a positive integer', `Omit it for ${LLM_DEFAULT_MAX_TOKENS}`)
51
+ const timeoutMs = o.timeoutMs ?? LLM_DEFAULT_TIMEOUT
52
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > LLM_MAX_TIMEOUT) throw new LlmError('B0921', 400, `timeoutMs must be between 1 and ${LLM_MAX_TIMEOUT}`, `Omit it for ${LLM_DEFAULT_TIMEOUT}`)
53
+ return { provider, key, model, root: base.href.replace(/\/+$/, ''), messages, system: o.system ?? null, maxTokens, timeoutMs, signal: o.signal }
54
+ }
55
+
56
+ function llmRequest(r, stream) {
57
+ if (r.provider === 'anthropic') return {
58
+ url: r.root + '/v1/messages',
59
+ headers: { 'x-api-key': r.key, 'anthropic-version': LLM_ANTHROPIC_VERSION, 'content-type': 'application/json' },
60
+ body: { model: r.model, max_tokens: r.maxTokens, messages: r.messages, ...(r.system ? { system: r.system } : {}), ...(stream ? { stream: true } : {}) },
61
+ }
62
+ return {
63
+ url: r.root + '/v1/responses',
64
+ headers: { authorization: 'Bearer ' + r.key, 'content-type': 'application/json' },
65
+ body: { model: r.model, max_output_tokens: r.maxTokens, input: r.messages, store: false, ...(r.system ? { instructions: r.system } : {}), ...(stream ? { stream: true } : {}) },
66
+ }
67
+ }
68
+
69
+ const llmStop = reason => ({ end_turn: 'end', stop_sequence: 'end', completed: 'end', max_tokens: 'length', max_output_tokens: 'length', content_filter: 'filter', refusal: 'filter' })[reason] || 'other'
70
+ const llmRedact = (text, key) => String(text ?? '').split(key).join('[key]').slice(0, LLM_MAX_DETAIL)
71
+
72
+ function llmParsed(r, json, requestId) {
73
+ if (r.provider === 'anthropic') {
74
+ if (!Array.isArray(json?.content)) throw llmFailure(502, 'The model provider sent an unexpected answer', { provider: r.provider })
75
+ return { provider: r.provider, model: json.model ?? r.model, text: json.content.filter(b => b?.type === 'text').map(b => b.text).join(''), stopReason: llmStop(json.stop_reason), usage: { inputTokens: json.usage?.input_tokens ?? null, outputTokens: json.usage?.output_tokens ?? null }, requestId }
76
+ }
77
+ if (json?.status === 'failed') throw llmFailure(502, 'The model provider could not complete the request', { provider: r.provider, detail: llmRedact(json.error?.message, r.key) })
78
+ if (!Array.isArray(json?.output)) throw llmFailure(502, 'The model provider sent an unexpected answer', { provider: r.provider })
79
+ const text = json.output.filter(i => i?.type === 'message').flatMap(i => i.content || []).filter(c => c?.type === 'output_text').map(c => c.text).join('')
80
+ return { provider: r.provider, model: json.model ?? r.model, text, stopReason: llmStop(json.incomplete_details?.reason ?? json.status), usage: { inputTokens: json.usage?.input_tokens ?? null, outputTokens: json.usage?.output_tokens ?? null }, requestId }
81
+ }
82
+
83
+ async function llmStatusError(r, res) {
84
+ let detail = ''
85
+ try { const raw = await res.text(); try { const j = JSON.parse(raw); detail = j?.error?.message ?? raw } catch { detail = raw } } catch {}
86
+ const providerStatus = res.status, extra = { provider: r.provider, providerStatus, detail: llmRedact(detail, r.key) }
87
+ if (providerStatus === 429) return llmFailure(429, 'The model provider is rate limiting requests; try again shortly', { ...extra, retryAfter: res.headers.get('retry-after') })
88
+ if (providerStatus === 401 || providerStatus === 403) return new LlmError('B0921', 503, `The model provider refused the key in ${LLM_PROVIDERS[r.provider].key}`, 'Check the key and its permissions', extra)
89
+ return llmFailure(providerStatus === 504 || providerStatus === 408 ? 504 : 502, 'The model provider could not complete the request', extra)
90
+ }
91
+
92
+ async function llmFetch(r, stream, fallback) {
93
+ const deadline = AbortSignal.timeout(r.timeoutMs)
94
+ const signal = AbortSignal.any([deadline, ...(fallback ? [fallback] : []), ...(r.signal ? [r.signal] : [])])
95
+ const q = llmRequest(r, stream)
96
+ const failed = e => deadline.aborted ? llmFailure(504, 'The model provider did not answer in time', { provider: r.provider }) : signal.aborted ? llmFailure(499, 'The LLM call was cancelled', { provider: r.provider, cancelled: true }) : e instanceof LlmError ? e : llmFailure(502, 'The model provider could not be reached', { provider: r.provider })
97
+ let res
98
+ try { res = await fetch(q.url, { method: 'POST', headers: q.headers, body: JSON.stringify(q.body), signal, redirect: 'error' }) } catch (e) { throw failed(e) }
99
+ return { res, failed, requestId: res.headers.get('request-id') ?? res.headers.get('x-request-id') }
100
+ }
101
+
102
+ async function llmReadAll(res) {
103
+ const reader = res.body.getReader(), parts = []
104
+ let total = 0
105
+ for (;;) {
106
+ const { done, value } = await reader.read()
107
+ if (done) break
108
+ total += value.length
109
+ if (total > LLM_MAX_BODY) { await reader.cancel(); throw llmFailure(502, 'The model provider answer is too large') }
110
+ parts.push(value)
111
+ }
112
+ return Buffer.concat(parts).toString('utf8')
113
+ }
114
+
115
+ // One `data:` payload per server-sent event; the event name is repeated inside each payload by both providers.
116
+ async function* llmEvents(res) {
117
+ const decoder = new TextDecoder(), reader = res.body.getReader()
118
+ let buffer = '', data = []
119
+ try {
120
+ for (;;) {
121
+ const { done, value } = await reader.read()
122
+ if (done) return
123
+ buffer += decoder.decode(value, { stream: true })
124
+ if (buffer.length > LLM_MAX_LINE) throw llmFailure(502, 'The model provider sent an oversized event')
125
+ for (let at; (at = buffer.indexOf('\n')) >= 0;) {
126
+ const line = buffer.slice(0, at).replace(/\r$/, '')
127
+ buffer = buffer.slice(at + 1)
128
+ if (line === '') { if (data.length) { const raw = data.join('\n'); data = []; let e; try { e = JSON.parse(raw) } catch { throw llmFailure(502, 'The model provider sent an unreadable event') } yield e } }
129
+ else if (line.startsWith('data:')) data.push(line.slice(5).trimStart())
130
+ }
131
+ }
132
+ } finally { await reader.cancel().catch(() => {}) }
133
+ }
134
+
135
+ function llmStream(r, parent) {
136
+ let settle, reject
137
+ const result = new Promise((a, b) => { settle = a; reject = b })
138
+ result.catch(() => {}) // reading `.result` is optional; the iterator throws the same failure
139
+ const run = async function* () {
140
+ let opened
141
+ try { opened = await llmFetch(r, true, parent) } catch (e) { reject(e); throw e }
142
+ const { res, failed, requestId } = opened
143
+ const out = { provider: r.provider, model: r.model, text: '', stopReason: 'other', usage: { inputTokens: null, outputTokens: null }, requestId }
144
+ let finished = false
145
+ try {
146
+ if (!res.ok) throw await llmStatusError(r, res)
147
+ for await (const e of llmEvents(res)) {
148
+ let piece = ''
149
+ if (r.provider === 'anthropic') {
150
+ if (e.type === 'error') throw e.error?.type === 'rate_limit_error' ? llmFailure(429, 'The model provider is rate limiting requests; try again shortly', { provider: r.provider, detail: llmRedact(e.error?.message, r.key) }) : llmFailure(502, 'The model provider failed during the answer', { provider: r.provider, detail: llmRedact(e.error?.message, r.key) })
151
+ if (e.type === 'message_start') { out.model = e.message?.model ?? out.model; out.usage.inputTokens = e.message?.usage?.input_tokens ?? null; out.usage.outputTokens = e.message?.usage?.output_tokens ?? null }
152
+ else if (e.type === 'content_block_delta' && e.delta?.type === 'text_delta') piece = e.delta.text
153
+ else if (e.type === 'message_delta') { out.stopReason = llmStop(e.delta?.stop_reason); if (e.usage?.output_tokens != null) out.usage.outputTokens = e.usage.output_tokens }
154
+ else if (e.type === 'message_stop') finished = true
155
+ } else {
156
+ if (e.type === 'error') throw llmFailure(502, 'The model provider failed during the answer', { provider: r.provider, detail: llmRedact(e.message, r.key) })
157
+ if (e.type === 'response.failed') throw llmFailure(502, 'The model provider could not complete the request', { provider: r.provider, detail: llmRedact(e.response?.error?.message, r.key) })
158
+ if (e.type === 'response.output_text.delta') piece = e.delta
159
+ else if (e.type === 'response.completed' || e.type === 'response.incomplete') {
160
+ finished = true
161
+ out.model = e.response?.model ?? out.model
162
+ out.stopReason = llmStop(e.response?.incomplete_details?.reason ?? e.response?.status)
163
+ out.usage = { inputTokens: e.response?.usage?.input_tokens ?? null, outputTokens: e.response?.usage?.output_tokens ?? null }
164
+ }
165
+ }
166
+ if (piece) { out.text += piece; yield piece }
167
+ }
168
+ if (!finished) throw llmFailure(502, 'The model provider ended the answer early', { provider: r.provider })
169
+ settle(out)
170
+ } catch (e) { const f = failed(e); reject(f); throw f }
171
+ }
172
+ const iterator = run()
173
+ // Leaving the loop early closes the provider connection; `.result` then rejects as cancelled.
174
+ const stop = iterator.return.bind(iterator)
175
+ return {
176
+ result,
177
+ [Symbol.asyncIterator]() { return this },
178
+ next: () => iterator.next(),
179
+ return: value => { reject(llmFailure(499, 'The LLM stream was closed before its end', { provider: r.provider, cancelled: true })); return stop(value) },
180
+ }
181
+ }
182
+
183
+ // ctx.llm for one request: `signal` is ctx.signal, so a closed client or a passed deadline stops the provider call.
184
+ export function llmClient(env, signal) {
185
+ return {
186
+ async complete(o) {
187
+ const r = llmResolve(env, o)
188
+ const { res, failed, requestId } = await llmFetch(r, false, signal)
189
+ try {
190
+ if (!res.ok) throw await llmStatusError(r, res)
191
+ let json
192
+ try { json = JSON.parse(await llmReadAll(res)) } catch (e) { if (e instanceof LlmError) throw e; if (e instanceof SyntaxError) throw llmFailure(502, 'The model provider sent an unreadable answer', { provider: r.provider }); throw e }
193
+ return llmParsed(r, json, requestId)
194
+ } catch (e) { throw failed(e) }
195
+ },
196
+ // Validates now, connects on the first read. `for await (const text of s)`, then `await s.result`.
197
+ stream(o) { return llmStream(llmResolve(env, o), signal) },
198
+ }
199
+ }
package/server/server.mjs CHANGED
@@ -6,6 +6,7 @@
6
6
  //
7
7
  // It serves, in this order:
8
8
  // GET /_health → 200 {"ok":true,"build":"<sig>"}
9
+ // GET /health[/live|/ready] → forwarded to the native backend, or 200 {"ok":true} without one
9
10
  // POST /_r/<hash>/<fn> → RPC to a `server:` function (same origin + X-Brain-RPC; body, rate and time limits from brain.json `server`)
10
11
  // GET routes in the manifest → SSR: load(ctx) → render the view on the DOM shim → page with props and SEO head
11
12
  // GET files in dist/ → static (the smallest accepted .zst/.br/.gz sibling; /_b/* immutable), clean URLs
@@ -21,6 +22,7 @@ import { createServer as http, STATUS_CODES, request as httpRequest, Agent as Ht
21
22
  import { AsyncLocalStorage } from 'node:async_hooks'
22
23
  import net from 'node:net'
23
24
  import { installNativeSockets } from './native-sockets.mjs'
25
+ import { llmClient } from './llm.mjs'
24
26
  import { BlockList, isIP } from 'node:net'
25
27
  import { Readable } from 'node:stream'
26
28
  import zlib from 'node:zlib'
@@ -550,7 +552,7 @@ export function nativeAuthBridge(raw, refused, serviceKey, report) {
550
552
  try { answer = JSON.parse(data.toString('utf8'))?.error } catch {}
551
553
  const code = typeof answer?.code === 'string' && /^[A-Z][A-Z0-9_]{0,63}$/.test(answer.code) ? answer.code : undefined
552
554
  const fix = code && typeof answer.fix === 'string' ? answer.fix.slice(0, 512) : undefined
553
- throw new HttpError(status >= 400 && status < 500 ? status : 503, 'Native resource request failed', { code, fix })
555
+ throw new HttpError(status >= 400 && status < 500 ? status : 503, NATIVE_FAILED, { code, fix })
554
556
  }
555
557
  if (status === 204) { value = null; return }
556
558
  if (!String(headers['content-type'] || '').startsWith('application/json')) throw new HttpError(502, 'Native resource response must be JSON')
@@ -852,6 +854,8 @@ export function createServer(o = {}) {
852
854
  const SL = mf.server || {}
853
855
  const TIMEOUT = +(o.timeout ?? env.BRAIN_TIMEOUT_MS ?? SL.timeoutMs ?? 10000)
854
856
  const LIMIT = +(o.rateLimit ?? env.BRAIN_RPC_LIMIT ?? SL.rpcPerMinute ?? 60) // per IP + function, per minute
857
+ // Off by default: nginx below 1.29 takes a 103 as the final response, and the real one reaches the client as body bytes.
858
+ const EARLY_HINTS = o.earlyHints ?? (env.BRAIN_EARLY_HINTS ? env.BRAIN_EARLY_HINTS === '1' : SL.earlyHints === true)
855
859
  const BODY_KB = +(o.maxBodyKB ?? env.BRAIN_MAX_BODY_KB ?? SL.maxBodyKB ?? 1024)
856
860
  if (!Number.isInteger(BODY_KB) || BODY_KB < 1 || BODY_KB > 65536) throw Error('brain.json server.maxBodyKB must be an integer from 1 to 65536 (KB)')
857
861
  const MAX_BODY = BODY_KB * 1024 // RPC and form bodies; bounded reads never buffer more
@@ -863,7 +867,7 @@ export function createServer(o = {}) {
863
867
  const hasManifestSockets = Object.hasOwn(mf,'nativeSockets')
864
868
  if (hasManifestSockets && typeof mf.nativeSockets !== 'boolean') throw Error('Manifest nativeSockets must be an explicit boolean')
865
869
  const nativeSockets = o.nativeSockets === undefined ? hasManifestSockets && mf.nativeSockets : o.nativeSockets
866
- if (nativeSockets && !native) throw Error('Native sockets require a configured native backend')
870
+ if (nativeSockets && !native) throw Error('Native sockets require a configured native backend; fix: run `brain backend serve` and set BRAIN_BACKEND_URL to its loopback URL')
867
871
  // the build's single route table (#187): already in match order, each with its RegExp source (named groups = params)
868
872
  const routes = mf.routes.map(r => ({ ...r, rx: r.rx ? new RegExp(r.rx) : exact(r.path) }))
869
873
  // The address RPC and form limits key on, ctx.req.ip and the logs: a client header counts only through a trusted proxy.
@@ -1025,12 +1029,13 @@ export function createServer(o = {}) {
1025
1029
  const context = (req, res, url, extra) => {
1026
1030
  const cookies = parseCookies(req.headers.cookie)
1027
1031
  const { user, session: ses } = nativeSession ? { user: req.$nativeUser ?? null, session: sessionSecrets(native.session(req, res, res.getHeader('x-request-id')), cookies, secret) } : sessionFrom(cookies, secret)
1032
+ const signal = lifetime(res)
1028
1033
  return {
1029
1034
  url, query: Object.fromEntries(url.searchParams), cookies, user, session: ses, env,
1030
1035
  ...(native ? { backend: native.backend(req, res, res.getHeader('x-request-id')) } : {}),
1031
1036
  // `prefetch`: the router fetched this page ahead of a click (§19); a `load` with side effects can skip them
1032
- prefetch: req.headers['x-brain-prefetch'] === '1',
1033
- req: { method: req.method, headers: req.headers, ip: ipOf(req) }, signal: lifetime(res), ...extra,
1037
+ prefetch: req.headers['x-brain-prefetch'] === '1' || /\bprefetch\b/.test(req.headers['sec-purpose'] ?? ''),
1038
+ req: { method: req.method, headers: req.headers, ip: ipOf(req) }, signal, llm: llmClient(env, signal), ...extra,
1034
1039
  }
1035
1040
  }
1036
1041
 
@@ -1084,12 +1089,17 @@ export function createServer(o = {}) {
1084
1089
  const anonymous = req => req.method === 'GET' && !req.headers.authorization && !req.headers.cookie
1085
1090
 
1086
1091
  async function ssr(req, res, route, params, url, rid, forms = null, status = 200, fixed = null, nocache = false) {
1087
- // 103 Early Hints (#232): the route's preloads (the same Link values as _headers) before any work, once
1088
- if (route.links && route.links.length && res.writeEarlyHints && !res.$capture && !res.$hinted) {
1092
+ // the route's preloads (the same Link values as _headers): on the response, where hosts turn them into early hints,
1093
+ // capped so a long list cannot trip a proxy's header buffer. A 103 (#232) only when opted in, and never to HTTP/1.0.
1094
+ if (route.links && route.links.length && !res.$capture && !res.$hinted) {
1089
1095
  res.$hinted = true
1090
- try {
1091
- res.writeEarlyHints({ link: route.links })
1092
- } catch {}
1096
+ const link = route.links.join(', ')
1097
+ if (link.length <= 2048 && !res.headersSent) res.setHeader('link', link)
1098
+ if (EARLY_HINTS && res.writeEarlyHints && req.httpVersion !== '1.0') {
1099
+ try {
1100
+ res.writeEarlyHints({ link: route.links })
1101
+ } catch {}
1102
+ }
1093
1103
  }
1094
1104
  if (!nocache && route.cache > 0 && !forms && !fixed && status === 200 && anonymous(req)) return cached(req, res, route, params, url, rid)
1095
1105
  if (native) await native.prepare(req, res, rid)
@@ -1147,7 +1157,9 @@ export function createServer(o = {}) {
1147
1157
  const page = readFileSync(file(route.page), 'utf8')
1148
1158
  .replace('<!--brain:ssr-->', () => body)
1149
1159
  .replace('<!--brain:props-->', () => jsonInHtml(props))
1150
- let out = head(page, seo, url.pathname)
1160
+ // a page behind a guard passes it only for its visitor: it stays out of the index even when it renders (§33)
1161
+ if (route.guards && route.guards.length) res.setHeader('x-robots-tag', NOINDEX)
1162
+ let out = head(page, seo, url.pathname, url.search)
1151
1163
  // route params for route() in the browser (the client has no route table)
1152
1164
  if (params && Object.keys(params).length) out = out.replace('</head>', () => `<meta name="brain-params" content="${esc(JSON.stringify(params))}"></head>`)
1153
1165
  if (forms) out = out.replace('</body>', () => `<script type="application/json" id="brain-forms">${jsonInHtml(forms)}</script></body>`)
@@ -1191,7 +1203,7 @@ export function createServer(o = {}) {
1191
1203
  globalThis.__brainLocale = null
1192
1204
  }
1193
1205
  const at = { path: url.pathname, params: params || {}, query: Object.fromEntries(url.searchParams) }
1194
- write(n(head(top, seo0, url.pathname)))
1206
+ write(n(head(top, seo0, url.pathname, url.search)))
1195
1207
  // QA #253: the folder's _loading.brain goes in the region with the first chunk (SPEC: shows while load runs),
1196
1208
  // between two marker comments; the last chunk starts with a script that removes them and what is between
1197
1209
  let clear = ''
@@ -1326,7 +1338,8 @@ export function createServer(o = {}) {
1326
1338
  let raw
1327
1339
  try {
1328
1340
  raw = await body(req, MAX_BODY)
1329
- } catch {
1341
+ } catch (e) {
1342
+ if (e.aborted) return
1330
1343
  return fail(413, tooBig(rid, url.pathname))
1331
1344
  }
1332
1345
  if (native) await native.prepare(req, res, rid)
@@ -1366,7 +1379,8 @@ export function createServer(o = {}) {
1366
1379
  let raw
1367
1380
  try {
1368
1381
  raw = await body(req, MAX_BODY)
1369
- } catch {
1382
+ } catch (e) {
1383
+ if (e.aborted) return
1370
1384
  return plain(413, tooBig(rid, url.pathname))
1371
1385
  }
1372
1386
  const id = new URLSearchParams(raw).get('_brain') || '', modPath = mf.rpc[id.split('/')[0]]
@@ -1546,6 +1560,17 @@ export function createServer(o = {}) {
1546
1560
  return false
1547
1561
  }
1548
1562
 
1563
+ // /health is reserved for the backend (B0735), so an app cannot declare it and deploy gates ask this port for it. An app
1564
+ // page at that path still wins: this only runs where the answer would have been a 404. Nothing here names the backend.
1565
+ const isProbe = path => path === '/health' || path === '/health/live' || path === '/health/ready'
1566
+ const answerProbe = async (req, res, url, rid) => {
1567
+ const headers = { 'content-type': MIME['.json'], 'cache-control': 'no-store' }
1568
+ if (req.method !== 'GET' && req.method !== 'HEAD') return notFound(req, res)
1569
+ if (!native) return send(res, 200, json({ ok: true }), headers)
1570
+ try { await native.proxy(req, res, new URL(url.pathname, url), rid, send) }
1571
+ catch { if (!res.headersSent) send(res, 503, json({ ok: false }), headers) }
1572
+ }
1573
+
1549
1574
  // ---------- dispatch ----------
1550
1575
  const server = http(async (req, res) => {
1551
1576
  // Only a file upload may outlast the 30-second body budget; the dispatcher lifts this guard for that path alone.
@@ -1574,7 +1599,7 @@ export function createServer(o = {}) {
1574
1599
  let url = full
1575
1600
  if (BASE !== '/') {
1576
1601
  if (full.pathname === BASE.slice(0, -1)) return send(res, 301, '', { location: BASE + full.search })
1577
- if (!full.pathname.startsWith(BASE)) return notFound(req, res)
1602
+ if (!full.pathname.startsWith(BASE)) return isProbe(full.pathname) ? await answerProbe(req, res, full, rid) : notFound(req, res)
1578
1603
  url = new URL('/' + full.pathname.slice(BASE.length) + full.search, full) // the app path
1579
1604
  }
1580
1605
  req.$brainPath = url.pathname
@@ -1609,6 +1634,7 @@ export function createServer(o = {}) {
1609
1634
  if (serveStatic(req, res, url)) return
1610
1635
  // browsers still ask for /favicon.ico: the app's SVG icon answers it
1611
1636
  if (url.pathname === '/favicon.ico' && file('favicon.svg')) return send(res, 301, '', { location: withBase('/favicon.svg'), 'cache-control': 'public, max-age=86400' })
1637
+ if (isProbe(url.pathname)) return await answerProbe(req, res, url, rid)
1612
1638
  notFound(req, res)
1613
1639
  } catch (e) {
1614
1640
  log('error', 'unhandled', { rid, path: req.url, ...trace(e) })
@@ -1877,7 +1903,7 @@ export async function callRpc({ fn, args, ctx, key, limit = 60, timeout = 10000
1877
1903
  const pub = publicError(e)
1878
1904
  if (pub) return { status: pub.status, body: { error: pub.message } }
1879
1905
  const deliberate = intendedStatus(e)
1880
- if (deliberate && deliberate < 500) return { status: deliberate, body: { error: e instanceof HttpError ? e.message : STATUS_CODES[deliberate] } }
1906
+ if (deliberate && deliberate < 500) return { status: deliberate, body: { error: e instanceof HttpError && e.message !== NATIVE_FAILED ? e.message : STATUS_CODES[deliberate] } }
1881
1907
  if (deliberate) return {status:deliberate,body:{error:STATUS_CODES[deliberate]},cause:e}
1882
1908
  return { status: 500, body: { error: 'Internal error' }, cause: e }
1883
1909
  }
@@ -1888,6 +1914,8 @@ export async function callRpc({ fn, args, ctx, key, limit = 60, timeout = 10000
1888
1914
  // `locale` (§37): the page's language, so the schema messages (`form.*`) and the generic error come from its dictionary
1889
1915
  // (globalThis.__brainDict, filled by the page's chunk) like form() in the browser. It is set only around the synchronous
1890
1916
  // validation, so concurrent requests in other languages never see it.
1917
+ // English text for the server's log: a form or a server function never shows it to a visitor
1918
+ const NATIVE_FAILED = 'Native resource request failed'
1891
1919
  export async function callForm({ module, raw, ctx, rules, key, limit = 60, timeout = 10000, locale }) {
1892
1920
  const q = new URLSearchParams(raw || ''), id = q.get('_brain') || '', fn = id.split('/')[1] || ''
1893
1921
  if (key) {
@@ -1914,7 +1942,7 @@ export async function callForm({ module, raw, ctx, rules, key, limit = 60, timeo
1914
1942
  r = await withTimeout(pageLocale.run(locale, () => Promise.resolve(module[fn](ctx, values))), timeout, ctx)
1915
1943
  } catch (e) {
1916
1944
  if (e && e.errors && typeof e.errors === 'object') return again(e.errors, '')
1917
- if (e instanceof HttpError && e.status >= 400 && e.status < 500) return again({}, e.message)
1945
+ if (e instanceof HttpError && e.status >= 400 && e.status < 500) return again({}, e.message === NATIVE_FAILED ? generic : e.message)
1918
1946
  const pub = publicError(e)
1919
1947
  if (pub) return again({}, pub.message)
1920
1948
  const deliberate = intendedStatus(e)
@@ -2004,6 +2032,7 @@ export function resolveClient(peer, forwarded, nets) {
2004
2032
  }
2005
2033
  return client
2006
2034
  }
2035
+ export function readBody(req, max) { return body(req, max) }
2007
2036
  function body(req, max) {
2008
2037
  return new Promise((ok, fail) => {
2009
2038
  let n = 0
@@ -2015,7 +2044,10 @@ function body(req, max) {
2015
2044
  else if (n <= max) parts.push(c)
2016
2045
  })
2017
2046
  req.on('end', () => (n > max ? fail(new Error('too large')) : ok(Buffer.concat(parts).toString('utf8'))))
2018
- req.on('error', fail)
2047
+ // a disconnect or the cut above never reaches 'end': the reader settles anyway and nobody is left to answer
2048
+ const aborted = () => fail(Object.assign(new Error('body aborted'), { aborted: true }))
2049
+ req.on('error', aborted)
2050
+ req.on('close', aborted)
2019
2051
  })
2020
2052
  }
2021
2053
  // bind:value / checked are properties; copy them into attributes so the SSR HTML shows them
@@ -2064,17 +2096,25 @@ export function prefersMarkdown(accept) {
2064
2096
  // SEO head from View.seo(props): title, description, canonical, og:*/twitter:*, JSON-LD and the view's <head> block
2065
2097
  // F5: the build writes an SSR route's canonical/og:url from its pattern (/posts/:slug); the request has the real path.
2066
2098
  // A view's own <head> tags replace the generated ones with the same name/property (one tag per key, SPEC «SEO»).
2067
- function head(page, seo, path) {
2099
+ function head(page, seo, path, search = '') {
2068
2100
  let h = page
2101
+ // `<meta seo query="page">`: these parameters make a different page; any other (a dialog, a tracking tag) does not
2102
+ let identity = ''
2103
+ h = h.replace(/<meta name="brain-query" content="([^"]*)">/, (_, keys) => {
2104
+ const given = new URLSearchParams(search), kept = new URLSearchParams()
2105
+ for (const key of keys.split(/[\s,]+/)) for (const value of key ? given.getAll(key) : []) kept.append(key, value)
2106
+ if (kept.size) identity = esc('?' + kept)
2107
+ return ''
2108
+ })
2069
2109
  if (path) {
2070
2110
  // the attribute is already escaped HTML: only the request path that replaces a pattern is escaped
2071
- const real = v => v.replace(/^(https?:\/\/[^/]+)?(\/[^"]*)$/, (m, o = '', p) => (/\/[:*]/.test(p) ? o + esc(path) : m))
2111
+ const real = v => v.replace(/^(https?:\/\/[^/]+)?(\/[^"]*)$/, (m, o = '', p) => (/\/[:*]/.test(p) ? o + esc(path) : m)) + identity
2072
2112
  h = h.replace(/(<link[^>]*rel=["']canonical["'][^>]*href=")([^"]*)(")/i, (m, a, v, b) => a + real(v) + b)
2073
2113
  h = h.replace(/(<meta[^>]*property=["']og:url["'][^>]*content=")([^"]*)(")/i, (m, a, v, b) => a + real(v) + b)
2074
2114
  }
2075
2115
  if (!seo) return h
2076
- const meta = (attr, key, val) => {
2077
- const tag = `<meta ${attr}="${esc(key)}" content="${esc(val)}">`
2116
+ const meta = (attr, key, val, escaped) => {
2117
+ const tag = `<meta ${attr}="${esc(key)}" content="${escaped ? val.replace(/"/g, '&quot;') : esc(val)}">`
2078
2118
  const rx = new RegExp(`<meta[^>]*\\b${attr}=["']${key.replace(/[.*+?^${}()|[\]\\:]/g, '\\$&')}["'][^>]*>`, 'i')
2079
2119
  h = rx.test(h) ? h.replace(rx, () => tag) : h.replace('</head>', () => tag + '</head>')
2080
2120
  }
@@ -2090,6 +2130,17 @@ function head(page, seo, path) {
2090
2130
  else if (/^(og|twitter|article):/.test(k)) meta('property', k, v)
2091
2131
  else if (typeof v === 'string') meta('name', k, v)
2092
2132
  }
2133
+ // What the build writes for a prerendered page and can only be known here: the social card of this request's values
2134
+ // (brain-seo social::head, prerender seo_head). The view's own value always wins.
2135
+ const shared = /<title>([\s\S]*?)<\/title>/i.exec(h)?.[1]
2136
+ const origin = /<link[^>]*rel=["']canonical["'][^>]*href="(https?:\/\/[^/"]+)/i.exec(h)?.[1]
2137
+ if (typeof seo['og:image'] === 'string' && origin && /^\/(?!\/)/.test(seo['og:image'])) meta('property', 'og:image', encodeURI(origin + seo['og:image']))
2138
+ if (seo.title != null && seo['og:title'] == null) meta('property', 'og:title', shared, true)
2139
+ if (seo.description != null && seo['og:description'] == null) meta('property', 'og:description', seo.description)
2140
+ if (seo['og:image'] != null) {
2141
+ if (seo['og:image:alt'] == null) meta('property', 'og:image:alt', seo['og:title'] ?? shared, seo['og:title'] == null)
2142
+ if (seo['twitter:card'] == null) meta('name', 'twitter:card', 'summary_large_image')
2143
+ }
2093
2144
  // `<meta seo schema="Article">` without `jsonld` (§32): one node with this request's values. It replaces the node
2094
2145
  // the build wrote into the template from the static attributes (same @type), so the page carries exactly one.
2095
2146
  if (typeof seo.schema === 'string' && seo.jsonld == null && /^[A-Za-z]+$/.test(seo.schema)) {
package/shim/dom.js CHANGED
@@ -34,6 +34,11 @@ export class MouseEvent extends Event {}
34
34
 
35
35
  // Minimal MutationObserver: only records for nodes connected to the document, plus takeRecords() (used by brain-inspect).
36
36
  const MOS = new Set()
37
+ // A node's position in its parent, from a hint the walk keeps: `_at` may be stale after any mutation, so it is checked.
38
+ const indexIn = (parent, node) => {
39
+ if (parent.childNodes[node._at] === node) return node._at
40
+ return (node._at = parent.childNodes.indexOf(node))
41
+ }
37
42
  const rec = (type, target) => {
38
43
  if (MOS.size && target.isConnected) for (const o of MOS) o._q.push({ type, target })
39
44
  }
@@ -62,6 +67,7 @@ class Node {
62
67
  this.ownerDocument = doc
63
68
  this.parentNode = null
64
69
  this.childNodes = []
70
+ this._at = 0
65
71
  this._l = { cap: {}, bub: {} }
66
72
  }
67
73
  get firstChild() {
@@ -72,11 +78,19 @@ class Node {
72
78
  }
73
79
  get nextSibling() {
74
80
  const p = this.parentNode
75
- return p ? p.childNodes[p.childNodes.indexOf(this) + 1] || null : null
81
+ if (!p) return null
82
+ const i = indexIn(p, this) + 1
83
+ const s = p.childNodes[i]
84
+ if (s) s._at = i
85
+ return s || null
76
86
  }
77
87
  get previousSibling() {
78
88
  const p = this.parentNode
79
- return p ? p.childNodes[p.childNodes.indexOf(this) - 1] || null : null
89
+ if (!p) return null
90
+ const i = indexIn(p, this) - 1
91
+ const s = p.childNodes[i]
92
+ if (s) s._at = i
93
+ return s || null
80
94
  }
81
95
  get isConnected() {
82
96
  let n = this
@@ -85,14 +99,26 @@ class Node {
85
99
  }
86
100
  insertBefore(n, ref) {
87
101
  if (n.nodeType === 11) {
88
- for (const c of [...n.childNodes]) this.insertBefore(c, ref)
102
+ const moved = n.childNodes
103
+ if (!moved.length) return n
104
+ const at = ref ? indexIn(this, ref) : this.childNodes.length
105
+ if (at < 0) throw Error('ref is not a child')
106
+ const kids = this.childNodes
107
+ const tail = kids.splice(at)
108
+ for (const c of moved) kids.push(c)
109
+ for (const c of tail) kids.push(c)
110
+ for (const c of moved) {
111
+ c.parentNode = this
112
+ rec('childList', this)
113
+ }
114
+ moved.length = 0
89
115
  return n
90
116
  }
91
117
  if (n.parentNode) {
92
118
  if (n.isConnected) stats.moves++
93
119
  n.parentNode.removeChild(n)
94
120
  }
95
- const i = ref ? this.childNodes.indexOf(ref) : -1
121
+ const i = ref ? indexIn(this, ref) : -1
96
122
  if (ref && i < 0) throw Error('ref is not a child')
97
123
  i < 0 ? this.childNodes.push(n) : this.childNodes.splice(i, 0, n)
98
124
  n.parentNode = this
@@ -103,7 +129,7 @@ class Node {
103
129
  return this.insertBefore(n, null)
104
130
  }
105
131
  removeChild(n) {
106
- const i = this.childNodes.indexOf(n)
132
+ const i = indexIn(this, n)
107
133
  if (i < 0) throw Error('not a child')
108
134
  this.childNodes.splice(i, 1)
109
135
  n.parentNode = null