@christianirack/brainllm-runtime 0.5.0 → 0.6.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-ai.js +15 -7
- package/brain-analytics.js +401 -0
- package/brain-budget.js +46 -25
- package/brain-ch.js +17 -4
- package/brain-data.js +4 -1
- package/brain-env.js +5 -4
- package/brain-guard.js +6 -6
- package/brain-live.js +6 -3
- package/brain-mcp.js +87 -18
- package/brain-outbound.js +244 -0
- package/brain-router.js +8 -12
- package/brain-update.js +37 -0
- package/brain-url.js +7 -5
- package/brain.js +6 -3
- package/package.json +1 -1
- package/server/server.mjs +310 -85
- package/sw/brain-sw.js +51 -2
- package/ui/error-state.brain +26 -13
- package/ui/tabs.brain +4 -3
package/brain-guard.js
CHANGED
|
@@ -24,22 +24,22 @@ const TEXT = 'This section could not load.'
|
|
|
24
24
|
const BUDGET = { nodes: 200000, rows: 100000, runs: 1000, ms: 200 }
|
|
25
25
|
const STOPPED = 'This part stopped to protect the page.'
|
|
26
26
|
const OWN = new WeakMap() // root owner → instance
|
|
27
|
-
const now = () =>
|
|
27
|
+
const now = () => performance.now()
|
|
28
28
|
// work per flush: the time between two effect runs belongs to the earlier one (the hook fires as a run starts); the
|
|
29
29
|
// last run of a flush is settled by a microtask queued at the flush's first run
|
|
30
30
|
let LX = null, LT = 0, inFlush = false
|
|
31
31
|
const touched = new Set()
|
|
32
32
|
// elements under a root, and the most element children any one of them has (the rows of a list)
|
|
33
33
|
const count = (e, c) => {
|
|
34
|
-
if (e.nodeType
|
|
34
|
+
if (e.nodeType != 1) return
|
|
35
35
|
c.nodes++
|
|
36
36
|
let k = 0
|
|
37
|
-
for (const n of e.childNodes) if (n.nodeType
|
|
37
|
+
for (const n of e.childNodes) if (n.nodeType == 1) k++, count(n, c)
|
|
38
38
|
if (k > c.rows) c.rows = k
|
|
39
39
|
}
|
|
40
40
|
// elements under a root: native when the DOM has it (the shim walks)
|
|
41
41
|
const elems = r => {
|
|
42
|
-
if (r.nodeType
|
|
42
|
+
if (r.nodeType != 1) return 0
|
|
43
43
|
if (r.getElementsByTagName) return 1 + r.getElementsByTagName('*').length
|
|
44
44
|
const c = { nodes: 0, rows: 0 }
|
|
45
45
|
count(r, c)
|
|
@@ -219,7 +219,7 @@ export function guard(C, fb, budget) {
|
|
|
219
219
|
report(pending[0], pending[1])
|
|
220
220
|
pending = null
|
|
221
221
|
}
|
|
222
|
-
for (const n of nodes) if (n.nodeType
|
|
222
|
+
for (const n of nodes) if (n.nodeType == 1) (n.$g = handler), (n.$c = reqs)
|
|
223
223
|
}, own)
|
|
224
224
|
function again() {
|
|
225
225
|
const p = end.parentNode
|
|
@@ -232,7 +232,7 @@ export function guard(C, fb, budget) {
|
|
|
232
232
|
if (own)
|
|
233
233
|
I.root(() => fx(() => () => {
|
|
234
234
|
if (dispose) dispose()
|
|
235
|
-
for (const n of nodes) if (n.nodeType
|
|
235
|
+
for (const n of nodes) if (n.nodeType == 1) (n.$g = null), (n.$c = null) // see brain-dev: no node → instance links
|
|
236
236
|
I.rm(nodes)
|
|
237
237
|
}), own)
|
|
238
238
|
return [...nodes, end]
|
package/brain-live.js
CHANGED
|
@@ -4,6 +4,8 @@ import { sig, fx, untrack, _rt } from './brain.js'
|
|
|
4
4
|
const G = globalThis, I = _rt()
|
|
5
5
|
const MAX_OWNERS = 128, MAX_ROOMS = 32, FRAME = 4096, BUFFER = 32768
|
|
6
6
|
const COALESCE = 100, READY_MS = 8000, REFRESH_MS = 10000, RETRY_MS = 60000
|
|
7
|
+
// A joined room proves health only once the connection stays up this long; shorter sessions keep spending the budget.
|
|
8
|
+
const STABLE_MS = 30000
|
|
7
9
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
8
10
|
const own = (value, key) => Object.prototype.hasOwnProperty.call(value, key)
|
|
9
11
|
const RESERVED = new Set(['constructor', 'prototype', '__proto__', 'id', 'owner', 'created_at', 'updated_at', 'search'])
|
|
@@ -66,7 +68,7 @@ function createBroker() {
|
|
|
66
68
|
const members = new Set(), joined = new Map(), waiting = [], events = new Map()
|
|
67
69
|
let socket = null, epoch = 0, scope = null, terminal = '', ready = false
|
|
68
70
|
let reconcileQueued = false, retryTimer, readyTimer, heartbeat, pongTimer, flushTimer
|
|
69
|
-
let attempts = 0, retryStart = 0, lastConnect = 0
|
|
71
|
+
let attempts = 0, retryStart = 0, lastConnect = 0, healthySince = 0
|
|
70
72
|
|
|
71
73
|
const update = () => {
|
|
72
74
|
for (const member of members) {
|
|
@@ -89,7 +91,7 @@ function createBroker() {
|
|
|
89
91
|
}
|
|
90
92
|
}
|
|
91
93
|
const stop = () => {
|
|
92
|
-
epoch++; ready = false
|
|
94
|
+
epoch++; ready = false; healthySince = 0
|
|
93
95
|
clearTimeout(retryTimer); retryTimer = null
|
|
94
96
|
clearTimeout(readyTimer); clearInterval(heartbeat); clearTimeout(pongTimer)
|
|
95
97
|
readyTimer = heartbeat = pongTimer = null
|
|
@@ -160,6 +162,7 @@ function createBroker() {
|
|
|
160
162
|
}
|
|
161
163
|
}
|
|
162
164
|
const retry = () => {
|
|
165
|
+
if (healthySince && Date.now() - healthySince >= STABLE_MS) { attempts = 0; retryStart = 0 }
|
|
163
166
|
stop()
|
|
164
167
|
if (++attempts > 5 || (retryStart && Date.now() - retryStart >= RETRY_MS)) { terminal = 'exhausted'; update(); return }
|
|
165
168
|
if (!retryStart) retryStart = Date.now()
|
|
@@ -227,7 +230,7 @@ function createBroker() {
|
|
|
227
230
|
const key = waiting.shift()
|
|
228
231
|
if (!key) { reject('protocol-error'); return }
|
|
229
232
|
joined.set(key, value.type === 'resync' ? 'ready' : 'forbidden')
|
|
230
|
-
if (value.type === 'resync'
|
|
233
|
+
if (value.type === 'resync' && !healthySince) healthySince = Date.now()
|
|
231
234
|
update()
|
|
232
235
|
if (value.type === 'resync') invalidate(value, key)
|
|
233
236
|
} else {
|
package/brain-mcp.js
CHANGED
|
@@ -7,8 +7,10 @@
|
|
|
7
7
|
// anything destructive should still ask for confirmation in the UI.
|
|
8
8
|
// Optional 5th argument (WebMCP draft 2026-10-27): { title, readOnly, consequential, untrusted } → `title` and
|
|
9
9
|
// `annotations: { readOnlyHint, consequentialHint, untrustedContentHint }` on the registration; only the keys given.
|
|
10
|
+
// `<meta tools>` (#106) compiles to registerTools([{ name, description, inputSchema, execute }]): the schema is built by
|
|
11
|
+
// the compiler from the tool's parameter list, the rest (detection, lifetime, validation, answers) is the same as tool().
|
|
10
12
|
import { fx } from './brain.js'
|
|
11
|
-
import { rule, check } from './brain-schema.js'
|
|
13
|
+
import { rule, check, msg } from './brain-schema.js'
|
|
12
14
|
|
|
13
15
|
const G = globalThis
|
|
14
16
|
export const context = () => (G.document && G.document.modelContext) || (G.navigator && G.navigator.modelContext) || null
|
|
@@ -36,9 +38,8 @@ const text = v => ({ content: [{ type: 'text', text: typeof v === 'string' ? v :
|
|
|
36
38
|
|
|
37
39
|
const HINTS = [['readOnly', 'readOnlyHint'], ['consequential', 'consequentialHint'], ['untrusted', 'untrustedContentHint']]
|
|
38
40
|
|
|
39
|
-
//
|
|
40
|
-
|
|
41
|
-
const def = { name, description, inputSchema: schema(params) }
|
|
41
|
+
// title/annotations onto a registration dictionary, only the keys given.
|
|
42
|
+
function extras(def, opts) {
|
|
42
43
|
if (!opts) return def
|
|
43
44
|
if (typeof opts.title === 'string' && opts.title) def.title = opts.title
|
|
44
45
|
let annotations = null
|
|
@@ -50,24 +51,92 @@ export function definition(name, description, params, opts) {
|
|
|
50
51
|
return def
|
|
51
52
|
}
|
|
52
53
|
|
|
53
|
-
|
|
54
|
+
// The registration dictionary: name, description, inputSchema, then title/annotations only when given.
|
|
55
|
+
export function definition(name, description, params, opts) {
|
|
56
|
+
return extras({ name, description, inputSchema: schema(params) }, opts)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// The browser's model context, or null where tools must not be registered (no WebMCP, server render, prerender).
|
|
60
|
+
const live = () => {
|
|
54
61
|
const mc = context()
|
|
55
|
-
|
|
56
|
-
|
|
62
|
+
return mc && mc.registerTool && !G.__brainSSR && !G.__brainRoute ? mc : null
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// `execute` for a registration: invalid arguments answer with the field's message and never run the function; its
|
|
66
|
+
// result (or error) is the tool's text answer.
|
|
67
|
+
const answer = (invalid, run) => async (args = {}) => {
|
|
68
|
+
const m = invalid(args || {})
|
|
69
|
+
if (m) return text(m)
|
|
70
|
+
try {
|
|
71
|
+
return text(await run(args || {}))
|
|
72
|
+
} catch (e) {
|
|
73
|
+
return text(`Error: ${(e && e.message) || e}`)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Registers the dictionaries for the lifetime of the calling component (one effect, cleaned up on dispose). A
|
|
78
|
+
// registration the browser refuses (a name another mounted instance already holds) is skipped: the page keeps working
|
|
79
|
+
// and unmounting this instance leaves the other one's tool in place.
|
|
80
|
+
function own(mc, make) {
|
|
57
81
|
fx(() => {
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
if (m) return text(`${k}: ${m}`)
|
|
63
|
-
}
|
|
82
|
+
const defs = make(), offs = []
|
|
83
|
+
for (let i = 0; i < defs.length; i++) {
|
|
84
|
+
const name = defs[i].name
|
|
85
|
+
let reg
|
|
64
86
|
try {
|
|
65
|
-
|
|
66
|
-
} catch
|
|
67
|
-
|
|
87
|
+
reg = mc.registerTool(defs[i])
|
|
88
|
+
} catch {
|
|
89
|
+
continue
|
|
68
90
|
}
|
|
91
|
+
offs.push(() => (reg && reg.unregister ? reg.unregister() : mc.unregisterTool && mc.unregisterTool(name)))
|
|
92
|
+
}
|
|
93
|
+
return () => {
|
|
94
|
+
for (let i = 0; i < offs.length; i++) offs[i]()
|
|
69
95
|
}
|
|
70
|
-
const reg = mc.registerTool(def)
|
|
71
|
-
return () => (reg && reg.unregister ? reg.unregister() : mc.unregisterTool && mc.unregisterTool(name))
|
|
72
96
|
})
|
|
73
97
|
}
|
|
98
|
+
|
|
99
|
+
export function tool(name, description, params, run, opts) {
|
|
100
|
+
const mc = live()
|
|
101
|
+
if (!mc) return
|
|
102
|
+
const rules = Object.fromEntries(Object.entries(params || {}).map(([k, s]) => [k, rule(s)]))
|
|
103
|
+
const invalid = args => {
|
|
104
|
+
for (const k in rules) {
|
|
105
|
+
const m = check(rules[k], args[k])
|
|
106
|
+
if (m) return `${k}: ${m}`
|
|
107
|
+
}
|
|
108
|
+
return ''
|
|
109
|
+
}
|
|
110
|
+
own(mc, () => [extras({ name, description, inputSchema: schema(params), execute: answer(invalid, run) }, opts)])
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// A compiled parameter's JSON Schema → the message for a bad value ('' when it fits). Required means present: `false`
|
|
114
|
+
// and `0` are values; a missing or empty one is not.
|
|
115
|
+
const FORMAT = { email: 'email', uri: 'url', date: 'date' }, KIND = { number: 'num', boolean: 'bool' }
|
|
116
|
+
function argError(p, v, req) {
|
|
117
|
+
if (v == null || v === '') return req ? msg('form.required', 'Required') : ''
|
|
118
|
+
if (p.type === 'array') return Array.isArray(v) ? '' : msg('form.list', 'Must be a list')
|
|
119
|
+
if (p.type === 'object') return typeof v === 'object' && !Array.isArray(v) ? '' : 'Must be an object'
|
|
120
|
+
const t = p.type === 'string' ? FORMAT[p.format] || 'str' : KIND[p.type]
|
|
121
|
+
return t ? check(rule(t), v) : ''
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// `<meta tools>`: [{ name, description, inputSchema, execute, title?, readOnly?, consequential?, untrusted? }], emitted
|
|
125
|
+
// by the compiler with `execute` bound to the component's same-named function.
|
|
126
|
+
export function registerTools(list) {
|
|
127
|
+
const mc = live()
|
|
128
|
+
if (!mc) return
|
|
129
|
+
own(mc, () =>
|
|
130
|
+
list.map(t => {
|
|
131
|
+
const s = t.inputSchema || {}, props = s.properties || {}, req = s.required || []
|
|
132
|
+
const invalid = args => {
|
|
133
|
+
for (const k in props) {
|
|
134
|
+
const m = argError(props[k], args[k], req.includes(k))
|
|
135
|
+
if (m) return `${k}: ${m}`
|
|
136
|
+
}
|
|
137
|
+
return ''
|
|
138
|
+
}
|
|
139
|
+
return extras({ name: t.name, description: t.description, inputSchema: s, execute: answer(invalid, t.execute) }, t)
|
|
140
|
+
}),
|
|
141
|
+
)
|
|
142
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
// Outbound HTTP for server logic: bounded attempts, deadlines, safe retries and redacted failures.
|
|
2
|
+
// Built on node:http(s) with a resolver guard: every address a name resolves to is checked at connect time, so a
|
|
3
|
+
// public name that resolves (or rebinds) to a private address is refused. No client library is bundled.
|
|
4
|
+
const RETRY_STATUS = new Set([408, 425, 429, 500, 502, 503, 504])
|
|
5
|
+
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE'])
|
|
6
|
+
const MAX_TIMEOUT = 60000, MAX_RETRIES = 5, MAX_BYTES = 16777216, MAX_WAIT = 30000
|
|
7
|
+
|
|
8
|
+
export class HttpCallError extends Error {
|
|
9
|
+
constructor(code, message, detail = {}) {
|
|
10
|
+
super(message)
|
|
11
|
+
this.name = 'HttpCallError'
|
|
12
|
+
this.code = code
|
|
13
|
+
this.status = detail.status ?? null
|
|
14
|
+
this.attempts = detail.attempts ?? 0
|
|
15
|
+
this.url = detail.url ?? ''
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function ipv4(host) {
|
|
20
|
+
const parts = host.split('.')
|
|
21
|
+
if (parts.length !== 4 || !parts.every(p => /^\d{1,3}$/.test(p) && +p < 256)) return null
|
|
22
|
+
return parts.map(Number)
|
|
23
|
+
}
|
|
24
|
+
// 0/8, 10/8, 127/8, 169.254/16 (cloud metadata), 172.16/12, 192.0.0/24, 192.168/16, 100.64/10 (CGNAT),
|
|
25
|
+
// 198.18/15 (benchmark), 224/4 multicast and 240/4 reserved
|
|
26
|
+
function privateV4([a, b, c]) {
|
|
27
|
+
return a === 0 || a === 10 || a === 127 || (a === 169 && b === 254) || (a === 172 && b >= 16 && b < 32) || (a === 192 && b === 168) || (a === 192 && b === 0 && c === 0) || (a === 100 && b >= 64 && b < 128) || (a === 198 && (b === 18 || b === 19)) || a >= 224
|
|
28
|
+
}
|
|
29
|
+
function ipv6(host) {
|
|
30
|
+
let text = host
|
|
31
|
+
const tail = text.match(/:(\d+\.\d+\.\d+\.\d+)$/)
|
|
32
|
+
if (tail) {
|
|
33
|
+
const v4 = ipv4(tail[1])
|
|
34
|
+
if (!v4) return null
|
|
35
|
+
text = `${text.slice(0, -tail[1].length)}${((v4[0] << 8) | v4[1]).toString(16)}:${((v4[2] << 8) | v4[3]).toString(16)}`
|
|
36
|
+
}
|
|
37
|
+
const halves = text.split('::')
|
|
38
|
+
if (halves.length > 2) return null
|
|
39
|
+
const head = halves[0] ? halves[0].split(':') : [], rest = halves.length === 2 && halves[1] ? halves[1].split(':') : []
|
|
40
|
+
const fill = 8 - head.length - rest.length
|
|
41
|
+
if (halves.length === 1 ? fill !== 0 : fill < 1) return null
|
|
42
|
+
const words = [...head, ...Array(halves.length === 2 ? fill : 0).fill('0'), ...rest]
|
|
43
|
+
return words.every(w => /^[0-9a-f]{1,4}$/.test(w)) ? words.map(w => parseInt(w, 16)) : null
|
|
44
|
+
}
|
|
45
|
+
// Loopback, unspecified, IPv4-mapped/compatible, NAT64 and 6to4 (checked against the embedded IPv4), unique local,
|
|
46
|
+
// link-local, site-local, multicast, Teredo and documentation ranges
|
|
47
|
+
function privateV6(w) {
|
|
48
|
+
const quad = (hi, lo) => [hi >> 8, hi & 255, lo >> 8, lo & 255]
|
|
49
|
+
if (w.slice(0, 5).every(x => x === 0) && (w[5] === 0 || w[5] === 0xffff)) return privateV4(quad(w[6], w[7]))
|
|
50
|
+
if (w[0] === 0x64 && w[1] === 0xff9b) return privateV4(quad(w[6], w[7]))
|
|
51
|
+
if (w[0] === 0x2002) return privateV4(quad(w[1], w[2]))
|
|
52
|
+
const top = w[0]
|
|
53
|
+
return (top & 0xfe00) === 0xfc00 || (top & 0xffc0) === 0xfe80 || (top & 0xffc0) === 0xfec0 || (top & 0xff00) === 0xff00 || (top === 0x2001 && (w[1] === 0 || w[1] === 0xdb8))
|
|
54
|
+
}
|
|
55
|
+
// True for an IP literal (v4 or v6, brackets allowed) in a non-public range; false for names and public addresses
|
|
56
|
+
function privateAddress(address) {
|
|
57
|
+
const host = String(address).toLowerCase().replace(/^\[|\]$/g, '').replace(/%.*$/, '')
|
|
58
|
+
const v4 = ipv4(host)
|
|
59
|
+
if (v4) return privateV4(v4)
|
|
60
|
+
const v6 = host.includes(':') ? ipv6(host) : null
|
|
61
|
+
return v6 ? privateV6(v6) : host.includes(':')
|
|
62
|
+
}
|
|
63
|
+
function privateHost(hostname) {
|
|
64
|
+
const host = hostname.toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, '')
|
|
65
|
+
if (host === 'localhost' || /\.(localhost|internal|local|home\.arpa)$/.test(host)) return true
|
|
66
|
+
return privateAddress(host)
|
|
67
|
+
}
|
|
68
|
+
function redact(url) {
|
|
69
|
+
return `${url.origin}${url.pathname}`
|
|
70
|
+
}
|
|
71
|
+
function bounded(value, name, min, max, fallback) {
|
|
72
|
+
if (value === undefined) return fallback
|
|
73
|
+
if (!Number.isInteger(value) || value < min || value > max) throw RangeError(`httpCall: ${name} must be an integer in ${min}..${max}`)
|
|
74
|
+
return value
|
|
75
|
+
}
|
|
76
|
+
function retryAfter(header) {
|
|
77
|
+
if (header == null) return null
|
|
78
|
+
const secs = Number(header)
|
|
79
|
+
const ms = Number.isFinite(secs) ? secs * 1000 : Date.parse(header) - Date.now()
|
|
80
|
+
return Number.isFinite(ms) ? Math.min(Math.max(ms, 0), MAX_WAIT) : null
|
|
81
|
+
}
|
|
82
|
+
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
|
|
83
|
+
|
|
84
|
+
const PRIVATE = 'BRAIN_PRIVATE_ADDRESS'
|
|
85
|
+
const REDIRECT_STATUS = new Set([301, 302, 303, 307, 308])
|
|
86
|
+
const CREDENTIAL_HEADERS = ['authorization', 'cookie', 'proxy-authorization']
|
|
87
|
+
let net
|
|
88
|
+
const nodeNet = () => (net ??= Promise.all([import('node:http'), import('node:https'), import('node:dns')]).then(([http, https, dns]) => ({ http, https, dns })))
|
|
89
|
+
|
|
90
|
+
// Private targets are allowed by `allowPrivate: true`, by naming the host in `allowPrivate: [...]`, or in development
|
|
91
|
+
// only by BRAIN_OUTBOUND_ALLOW_PRIVATE=1 (ignored when NODE_ENV is production)
|
|
92
|
+
function allowsPrivate(option, hostname) {
|
|
93
|
+
if (option === true) return true
|
|
94
|
+
const host = hostname.toLowerCase().replace(/^\[|\]$/g, '')
|
|
95
|
+
if (Array.isArray(option) && option.some(h => String(h).toLowerCase() === host)) return true
|
|
96
|
+
const env = globalThis.process?.env ?? {}
|
|
97
|
+
return env.BRAIN_OUTBOUND_ALLOW_PRIVATE === '1' && env.NODE_ENV !== 'production'
|
|
98
|
+
}
|
|
99
|
+
function checkTarget(url, option) {
|
|
100
|
+
const safe = redact(url)
|
|
101
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) throw new HttpCallError('UNSAFE_URL', `httpCall: ${safe} must be http(s) without credentials`, { url: safe })
|
|
102
|
+
const allow = allowsPrivate(option, url.hostname)
|
|
103
|
+
if (!allow && privateHost(url.hostname)) throw new HttpCallError('UNSAFE_URL', `httpCall: ${safe} targets a private or loopback address; pass allowPrivate for internal services`, { url: safe })
|
|
104
|
+
return allow
|
|
105
|
+
}
|
|
106
|
+
function guardedLookup(resolver, allow) {
|
|
107
|
+
return (hostname, options, callback) => {
|
|
108
|
+
resolver(hostname, { ...options, all: true }, (error, found) => {
|
|
109
|
+
if (error) return callback(error)
|
|
110
|
+
const list = (Array.isArray(found) ? found : [{ address: found, family: String(found).includes(':') ? 6 : 4 }]).filter(a => a && a.address)
|
|
111
|
+
if (!list.length) return callback(Object.assign(new Error(`${hostname} has no address`), { code: 'ENOTFOUND' }))
|
|
112
|
+
if (!allow && list.some(a => privateAddress(a.address))) return callback(Object.assign(new Error(`${hostname} resolves to a private address`), { code: PRIVATE }))
|
|
113
|
+
return options.all ? callback(null, list) : callback(null, list[0].address, list[0].family)
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
function payload(body, headers) {
|
|
118
|
+
if (body == null) return undefined
|
|
119
|
+
if (typeof body === 'string' || body instanceof Uint8Array) return body
|
|
120
|
+
if (body instanceof ArrayBuffer) return new Uint8Array(body)
|
|
121
|
+
if (ArrayBuffer.isView(body)) return new Uint8Array(body.buffer, body.byteOffset, body.byteLength)
|
|
122
|
+
if (body instanceof URLSearchParams) {
|
|
123
|
+
if (!headers.has('content-type')) headers.set('content-type', 'application/x-www-form-urlencoded;charset=UTF-8')
|
|
124
|
+
return body.toString()
|
|
125
|
+
}
|
|
126
|
+
throw TypeError('httpCall: body must be a string, bytes or URLSearchParams')
|
|
127
|
+
}
|
|
128
|
+
// One request on a fresh connection (no pooling, so a socket opened for an allowed private host is never reused)
|
|
129
|
+
async function exchange(url, { method, headers, body, signal, lookup, allow }) {
|
|
130
|
+
const { http, https, dns } = await nodeNet()
|
|
131
|
+
return new Promise((resolve, reject) => {
|
|
132
|
+
const request = (url.protocol === 'https:' ? https : http).request(url, { method, headers: Object.fromEntries(headers), signal, agent: false, lookup: guardedLookup(lookup ?? dns.lookup, allow) })
|
|
133
|
+
request.on('response', resolve)
|
|
134
|
+
request.on('error', reject)
|
|
135
|
+
request.end(body)
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
function plainHeaders(raw) {
|
|
139
|
+
return Object.fromEntries(Object.entries(raw).map(([k, v]) => [k, Array.isArray(v) ? v.join(', ') : v]))
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function readBody(response, maxBytes, url, attempts) {
|
|
143
|
+
const chunks = []
|
|
144
|
+
let size = 0
|
|
145
|
+
for await (const value of response) {
|
|
146
|
+
size += value.byteLength
|
|
147
|
+
if (size > maxBytes) {
|
|
148
|
+
response.destroy()
|
|
149
|
+
throw new HttpCallError('TOO_LARGE', `httpCall: response from ${url} exceeds ${maxBytes} bytes`, { status: response.statusCode, attempts, url })
|
|
150
|
+
}
|
|
151
|
+
chunks.push(value)
|
|
152
|
+
}
|
|
153
|
+
const out = new Uint8Array(size)
|
|
154
|
+
let at = 0
|
|
155
|
+
for (const c of chunks) { out.set(c, at); at += c.byteLength }
|
|
156
|
+
return out
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* httpCall(url, {method, headers, json, body, timeout, retries, idempotencyKey, maxBytes, backoff, allowPrivate, redirects, lookup, signal})
|
|
161
|
+
* → {status, ok, headers, text, json, attempts, url}. Network failures, deadlines, oversized bodies and final
|
|
162
|
+
* retryable statuses (5xx, 408, 425, 429) throw HttpCallError; other statuses are returned.
|
|
163
|
+
*/
|
|
164
|
+
export async function httpCall(target, options = {}) {
|
|
165
|
+
const method = String(options.method ?? 'GET').toUpperCase()
|
|
166
|
+
const timeout = bounded(options.timeout, 'timeout', 1, MAX_TIMEOUT, 10000)
|
|
167
|
+
const retries = bounded(options.retries, 'retries', 0, MAX_RETRIES, 2)
|
|
168
|
+
const maxBytes = bounded(options.maxBytes, 'maxBytes', 1, MAX_BYTES, 1048576)
|
|
169
|
+
const backoff = bounded(options.backoff, 'backoff', 1, 10000, 250)
|
|
170
|
+
const redirects = bounded(options.redirects, 'redirects', 0, 10, 0)
|
|
171
|
+
if (options.lookup !== undefined && typeof options.lookup !== 'function') throw TypeError('httpCall: lookup must be a dns.lookup-compatible function')
|
|
172
|
+
let url
|
|
173
|
+
try { url = new URL(target) } catch { throw new HttpCallError('UNSAFE_URL', 'httpCall: an absolute http(s) URL is required') }
|
|
174
|
+
const safe = redact(url)
|
|
175
|
+
const allowFirst = checkTarget(url, options.allowPrivate)
|
|
176
|
+
|
|
177
|
+
const headers = new Headers(options.headers ?? {})
|
|
178
|
+
let body = options.body
|
|
179
|
+
if (options.json !== undefined) {
|
|
180
|
+
body = JSON.stringify(options.json)
|
|
181
|
+
if (!headers.has('content-type')) headers.set('content-type', 'application/json')
|
|
182
|
+
if (!headers.has('accept')) headers.set('accept', 'application/json')
|
|
183
|
+
}
|
|
184
|
+
body = payload(body, headers)
|
|
185
|
+
if (options.idempotencyKey) headers.set('idempotency-key', options.idempotencyKey === true ? globalThis.crypto.randomUUID() : String(options.idempotencyKey))
|
|
186
|
+
const replayable = SAFE_METHODS.has(method) || headers.has('idempotency-key')
|
|
187
|
+
const allowed = replayable ? retries : 0
|
|
188
|
+
|
|
189
|
+
// One attempt: the request plus up to `redirects` hops, each target checked again before it is contacted
|
|
190
|
+
async function attemptOnce(signal) {
|
|
191
|
+
let hop = { url, method, headers: new Headers(headers), body, allow: allowFirst }
|
|
192
|
+
for (let hops = 0; ; hops++) {
|
|
193
|
+
const response = await exchange(hop.url, { ...hop, signal, lookup: options.lookup })
|
|
194
|
+
const location = response.headers.location
|
|
195
|
+
if (!redirects || !REDIRECT_STATUS.has(response.statusCode) || !location) return { response, url: hop.url }
|
|
196
|
+
response.resume()
|
|
197
|
+
if (hops >= redirects) throw new HttpCallError('REDIRECT', `httpCall: ${safe} redirected more than ${redirects} times`, { status: response.statusCode, url: safe })
|
|
198
|
+
let next
|
|
199
|
+
try { next = new URL(location, hop.url) } catch { throw new HttpCallError('REDIRECT', `httpCall: ${redact(hop.url)} sent an invalid redirect`, { status: response.statusCode, url: safe }) }
|
|
200
|
+
if (hop.url.protocol === 'https:' && next.protocol === 'http:') throw new HttpCallError('UNSAFE_URL', `httpCall: ${redact(hop.url)} redirected from https to http`, { url: redact(next) })
|
|
201
|
+
const allow = checkTarget(next, options.allowPrivate)
|
|
202
|
+
const nextHeaders = new Headers(hop.headers)
|
|
203
|
+
let nextMethod = hop.method, nextBody = hop.body
|
|
204
|
+
if (response.statusCode === 303 || ((response.statusCode === 301 || response.statusCode === 302) && hop.method === 'POST')) {
|
|
205
|
+
nextMethod = hop.method === 'HEAD' ? 'HEAD' : 'GET'
|
|
206
|
+
nextBody = undefined
|
|
207
|
+
for (const h of ['content-type', 'content-length']) nextHeaders.delete(h)
|
|
208
|
+
}
|
|
209
|
+
if (next.origin !== hop.url.origin) for (const h of CREDENTIAL_HEADERS) nextHeaders.delete(h)
|
|
210
|
+
hop = { url: next, method: nextMethod, headers: nextHeaders, body: nextBody, allow }
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
for (let attempt = 1; ; attempt++) {
|
|
215
|
+
const signal = AbortSignal.any([AbortSignal.timeout(timeout), options.signal].filter(Boolean))
|
|
216
|
+
let result = null, wait = null
|
|
217
|
+
try {
|
|
218
|
+
result = await attemptOnce(signal)
|
|
219
|
+
const status = result.response.statusCode
|
|
220
|
+
if (RETRY_STATUS.has(status)) {
|
|
221
|
+
result.response.resume()
|
|
222
|
+
if (attempt > allowed) throw new HttpCallError('HTTP_STATUS', `httpCall: ${safe} answered ${status}`, { status, attempts: attempt, url: safe })
|
|
223
|
+
wait = retryAfter(result.response.headers['retry-after'])
|
|
224
|
+
result = null
|
|
225
|
+
} else {
|
|
226
|
+
const bytes = await readBody(result.response, maxBytes, safe, attempt)
|
|
227
|
+
const text = new TextDecoder().decode(bytes)
|
|
228
|
+
const headersOut = plainHeaders(result.response.headers)
|
|
229
|
+
let json = null
|
|
230
|
+
if ((headersOut['content-type'] ?? '').includes('json') && text) {
|
|
231
|
+
try { json = JSON.parse(text) } catch { json = null }
|
|
232
|
+
}
|
|
233
|
+
return { status, ok: status >= 200 && status < 300, headers: headersOut, text, json, attempts: attempt, url: redact(result.url) }
|
|
234
|
+
}
|
|
235
|
+
} catch (error) {
|
|
236
|
+
if (error instanceof HttpCallError) throw error.attempts ? error : Object.assign(error, { attempts: attempt })
|
|
237
|
+
if (error?.code === PRIVATE || error?.cause?.code === PRIVATE) throw new HttpCallError('UNSAFE_URL', `httpCall: ${safe} resolves to a private or loopback address; pass allowPrivate for internal services`, { attempts: attempt, url: safe })
|
|
238
|
+
if (options.signal?.aborted) throw new HttpCallError('ABORTED', `httpCall: request to ${safe} was cancelled`, { attempts: attempt, url: safe })
|
|
239
|
+
const timedOut = error?.name === 'TimeoutError' || error?.name === 'AbortError'
|
|
240
|
+
if (attempt > allowed) throw new HttpCallError(timedOut ? 'TIMEOUT' : 'NETWORK', `httpCall: ${safe} ${timedOut ? `did not answer within ${timeout} ms` : 'is unreachable'}`, { attempts: attempt, url: safe })
|
|
241
|
+
}
|
|
242
|
+
await sleep(wait ?? Math.random() * Math.min(5000, backoff * 2 ** (attempt - 1)))
|
|
243
|
+
}
|
|
244
|
+
}
|
package/brain-router.js
CHANGED
|
@@ -206,25 +206,21 @@ export function router(o = {}) {
|
|
|
206
206
|
if (!cur.$d && [...cur.querySelectorAll('script[type=module]')].some(sc => /\$d\s*=/.test(sc.textContent))) hold(cur)
|
|
207
207
|
for (const a of next.attributes || []) cur.setAttribute(a.name, a.value)
|
|
208
208
|
if (doc.title) document.title = doc.title
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
}
|
|
213
|
-
// SEO (§32): og:*, twitter:*, robots and JSON-LD follow the page; the region's own tags stay where they are
|
|
209
|
+
// SEO (§32, S04): the route owns description, og:*, twitter:*, robots, JSON-LD, canonical, hreflang and Markdown
|
|
210
|
+
// alternates, prev/next and its route params (brain-params, read by route()): the current page's go, the next
|
|
211
|
+
// page's come (none when it has none). Site links (icons, stylesheets, the RSS feed) and the region's own tags stay.
|
|
214
212
|
const tags = (r, skip) =>
|
|
215
|
-
[...((r && r.querySelectorAll && r.querySelectorAll('meta,script')) || [])].filter(e => {
|
|
213
|
+
[...((r && r.querySelectorAll && r.querySelectorAll('meta,script,link')) || [])].filter(e => {
|
|
216
214
|
for (let p = e.parentNode; p; p = p.parentNode) if (p === skip) return false
|
|
217
|
-
const
|
|
218
|
-
return
|
|
215
|
+
const a = n => e.getAttribute(n) || '', t = e.tagName
|
|
216
|
+
return t == 'SCRIPT' ? a('type') == 'application/ld+json'
|
|
217
|
+
: t == 'LINK' ? /^(canonical|prev|next)$/.test(a('rel')) || (a('rel') == 'alternate' && (a('hreflang') || a('type') == 'text/markdown'))
|
|
218
|
+
: /^(og:|twitter:|robots$|description$|brain-params$)/.test(a('property') || a('name'))
|
|
219
219
|
})
|
|
220
220
|
if (document.head) {
|
|
221
221
|
for (const e of tags(document.head)) e.remove()
|
|
222
222
|
for (const e of tags(doc.head || doc, next)) document.head.appendChild(document.adoptNode ? document.adoptNode(e) : e)
|
|
223
223
|
}
|
|
224
|
-
// the next page's route params (or none): route() reads them
|
|
225
|
-
const pa = document.head && document.head.querySelector('meta[name=brain-params]'), pb = doc.querySelector('meta[name=brain-params]')
|
|
226
|
-
if (pa) pa.remove()
|
|
227
|
-
if (pb && document.head) document.head.appendChild(document.adoptNode ? document.adoptNode(pb) : pb)
|
|
228
224
|
routed()
|
|
229
225
|
current()
|
|
230
226
|
const oldPlan = document.getElementById('brain-next'), newPlan = doc.querySelector('#brain-next')
|
package/brain-update.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// BRAINLLM app updates with user control (brain.json `"cache": { "update": "prompt" }`, INTERNALS §35.8).
|
|
2
|
+
// `updateReady()` is a signal read: true once a new build's service worker is installed and waiting while this page is
|
|
3
|
+
// controlled by the previous one. `applyUpdate()` tells that worker to take over (`brain:skip-waiting`) and reloads
|
|
4
|
+
// the page once it controls it. Without a service worker both are inert: false / a resolved promise.
|
|
5
|
+
import { sig } from './brain.js'
|
|
6
|
+
|
|
7
|
+
const G = globalThis
|
|
8
|
+
let READY = null, reg = null
|
|
9
|
+
function start() {
|
|
10
|
+
READY = sig(false)
|
|
11
|
+
const sw = G.navigator?.serviceWorker
|
|
12
|
+
if (!sw?.getRegistration) return
|
|
13
|
+
const seen = r => {
|
|
14
|
+
if (!r || reg === r) return
|
|
15
|
+
reg = r
|
|
16
|
+
const mark = () => (READY.v = !!(r.waiting && sw.controller))
|
|
17
|
+
mark()
|
|
18
|
+
r.addEventListener?.('updatefound', () => {
|
|
19
|
+
const n = r.installing
|
|
20
|
+
n?.addEventListener?.('statechange', () => n.state === 'installed' && mark())
|
|
21
|
+
})
|
|
22
|
+
}
|
|
23
|
+
sw.getRegistration().then(seen, () => {})
|
|
24
|
+
}
|
|
25
|
+
export function updateReady() {
|
|
26
|
+
if (!READY) start()
|
|
27
|
+
return READY.v
|
|
28
|
+
}
|
|
29
|
+
// reloads once, when the new worker controls the page; resolves false when there is nothing to apply
|
|
30
|
+
export function applyUpdate() {
|
|
31
|
+
const sw = G.navigator?.serviceWorker
|
|
32
|
+
if (!reg?.waiting || !sw) return Promise.resolve(false)
|
|
33
|
+
return new Promise(ok => {
|
|
34
|
+
sw.addEventListener('controllerchange', () => (ok(true), G.location?.reload()), { once: true })
|
|
35
|
+
reg.waiting.postMessage('brain:skip-waiting')
|
|
36
|
+
})
|
|
37
|
+
}
|
package/brain-url.js
CHANGED
|
@@ -15,11 +15,13 @@ function decode(raw, base) {
|
|
|
15
15
|
else if (Array.isArray(base)) { try { v = JSON.parse(raw) } catch { return base }; if (!Array.isArray(v) || (base.length && v.some(x => typeof x !== typeof base[0]))) return base }
|
|
16
16
|
return encode(v) == null ? base : v
|
|
17
17
|
}
|
|
18
|
+
// `values`: an allowed set; a query value outside it reads as the first allowed value
|
|
19
|
+
const allow = (v, b) => !Array.isArray(b?.values) || !b.values.length || b.values.includes(v) ? v : b.values[0]
|
|
18
20
|
function restore() {
|
|
19
21
|
if (!G.location) return
|
|
20
22
|
const url = new URL(G.location.href)
|
|
21
23
|
for (const [key, b] of bindings) if (b.path === url.pathname) {
|
|
22
|
-
const value = decode(url.searchParams.get(key), b.base)
|
|
24
|
+
const value = allow(decode(url.searchParams.get(key), b.base), b)
|
|
23
25
|
b.last = encode(value)
|
|
24
26
|
b.cell.v = value
|
|
25
27
|
}
|
|
@@ -37,7 +39,7 @@ G.__brainURLOnly = (from, to) => {
|
|
|
37
39
|
G.__brainURLRestore = restore
|
|
38
40
|
|
|
39
41
|
export function urlState(initial, options) {
|
|
40
|
-
const cell = sig(initial)
|
|
42
|
+
const cell = sig(allow(initial, options))
|
|
41
43
|
if (!options?.disabled) urlBind(cell, options?.key, options)
|
|
42
44
|
return cell
|
|
43
45
|
}
|
|
@@ -78,7 +80,7 @@ export function urlBind(cell, key, options = {}) {
|
|
|
78
80
|
if (G.__brainRoute || G.__brainSSR) {
|
|
79
81
|
if (typeof key === 'string' && key.length <= 200 && !privateKey.test(key) && encode(untrack(() => cell.v)) != null) {
|
|
80
82
|
const raw = G.__brainRoute?.query?.[key]
|
|
81
|
-
cell.v = decode(typeof raw === 'string' ? raw : null, untrack(() => cell.v))
|
|
83
|
+
cell.v = allow(decode(typeof raw === 'string' ? raw : null, untrack(() => cell.v)), options)
|
|
82
84
|
}
|
|
83
85
|
return cell
|
|
84
86
|
}
|
|
@@ -97,8 +99,8 @@ export function urlBind(cell, key, options = {}) {
|
|
|
97
99
|
if (bindings.size >= 128 || known.size >= 512) { report('URL state key capacity exceeded; reduce public state or reuse stable keys'); return cell }
|
|
98
100
|
const base = untrack(() => cell.v), url = new URL(G.location.href)
|
|
99
101
|
if (url.search.length > 8192) { report('URL state exceeds 8 KiB; shorten public selections'); return cell }
|
|
100
|
-
cell.v = decode(url.searchParams.get(key), base)
|
|
101
|
-
const b = { cell, base, path: url.pathname, last: encode(cell.v) }
|
|
102
|
+
cell.v = allow(decode(url.searchParams.get(key), base), options)
|
|
103
|
+
const b = { cell, base, path: url.pathname, last: encode(cell.v), values: options.values }
|
|
102
104
|
bindings.set(key, b); known.add(key)
|
|
103
105
|
if (!listening) { G.addEventListener('popstate', restore); listening = true }
|
|
104
106
|
fx(() => {
|
package/brain.js
CHANGED
|
@@ -131,6 +131,9 @@ class N {
|
|
|
131
131
|
}
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
+
// Owners first (guard-first, as Solid and Svelte): an {#if} re-evaluates its condition, and disposes its block, before
|
|
135
|
+
// an effect inside the block runs, even when the inner effect was queued first.
|
|
136
|
+
const up = n => (n.p && up(n.p), n.u())
|
|
134
137
|
function flush() {
|
|
135
138
|
P = 0
|
|
136
139
|
B++
|
|
@@ -147,7 +150,7 @@ function flush() {
|
|
|
147
150
|
continue
|
|
148
151
|
}
|
|
149
152
|
}
|
|
150
|
-
n
|
|
153
|
+
up(n)
|
|
151
154
|
}
|
|
152
155
|
} finally {
|
|
153
156
|
Q = []
|
|
@@ -209,7 +212,7 @@ export const _err = h => ((E = h), _rt())
|
|
|
209
212
|
const mk = p => p.appendChild(document.createComment(''))
|
|
210
213
|
// Yield to the browser: scheduler.yield (Chrome 129+), else a MessageChannel task (no 4 ms setTimeout clamp)
|
|
211
214
|
const later = f => {
|
|
212
|
-
if (globalThis.scheduler
|
|
215
|
+
if (globalThis.scheduler?.yield) return scheduler.yield().then(f)
|
|
213
216
|
const c = new MessageChannel()
|
|
214
217
|
c.port1.onmessage = () => (c.port1.close(), f())
|
|
215
218
|
c.port2.postMessage(0)
|
|
@@ -219,7 +222,7 @@ const rm = a => {
|
|
|
219
222
|
for (const n of a) n.$out ? n.$out().then(() => n.remove()) : n.remove()
|
|
220
223
|
}
|
|
221
224
|
function nodes(v, a = []) {
|
|
222
|
-
if (v == null || v ===
|
|
225
|
+
if (v == null || v === !!v) return a // null, undefined, true, false
|
|
223
226
|
if (Array.isArray(v)) for (const x of v) nodes(x, a)
|
|
224
227
|
else if (typeof v == 'function') nodes(v(), a)
|
|
225
228
|
else if (v.nodeType == 11) a.push(...v.childNodes)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@christianirack/brainllm-runtime",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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": {
|