@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 +4 -3
- package/brain-form.js +10 -8
- package/brain-observe.js +120 -0
- package/brain-router.js +1 -0
- package/brain-schema.js +41 -9
- package/brain-vitals.js +60 -0
- package/package.json +1 -1
- package/server/llm.mjs +199 -0
- package/server/server.mjs +72 -21
- package/shim/dom.js +31 -5
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
|
|
384
|
-
|
|
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
|
|
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
|
-
|
|
282
|
-
|
|
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
|
-
|
|
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')
|
package/brain-observe.js
ADDED
|
@@ -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
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
}
|
package/brain-vitals.js
ADDED
|
@@ -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.
|
|
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,
|
|
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:
|
|
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
|
-
//
|
|
1088
|
-
|
|
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
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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, '"') : 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|