@christianirack/brainllm-runtime 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 = () => (G.performance ? performance.now() : Date.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 !== 1) return
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 === 1) k++, count(n, c)
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 !== 1) return 0
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 === 1) (n.$g = handler), (n.$c = reqs)
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 === 1) (n.$g = null), (n.$c = null) // see brain-dev: no node → instance links
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') { attempts = 0; retryStart = 0 }
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
- // The registration dictionary: name, description, inputSchema, then title/annotations only when given.
40
- export function definition(name, description, params, opts) {
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
- export function tool(name, description, params, run, opts) {
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
- if (!mc || !mc.registerTool || G.__brainSSR || G.__brainRoute) return
56
- const rules = Object.fromEntries(Object.entries(params || {}).map(([k, s]) => [k, rule(s)]))
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 def = definition(name, description, params, opts)
59
- def.execute = async (args = {}) => {
60
- for (const k in rules) {
61
- const m = check(rules[k], args[k])
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
- return text(await run(args))
66
- } catch (e) {
67
- return text(`Error: ${(e && e.message) || e}`)
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
- for (const s of ['meta[name=description]', 'link[rel=canonical]']) {
210
- const a = document.head && document.head.querySelector(s), b = doc.querySelector(s)
211
- if (a && b) a.replaceWith(document.adoptNode ? document.adoptNode(b) : b)
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 k = e.getAttribute('property') || e.getAttribute('name') || ''
218
- return e.tagName === 'SCRIPT' ? e.getAttribute('type') === 'application/ld+json' : /^(og:|twitter:|robots$)/.test(k)
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')
@@ -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.u()
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 && scheduler.yield) return scheduler.yield().then(f)
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 === true || v === false) return a
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.5.0",
3
+ "version": "0.6.1",
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": {