@christianirack/brainllm-runtime 0.3.1 → 0.4.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-err.js CHANGED
@@ -12,7 +12,7 @@ const RT = new URL('.', import.meta.url).href
12
12
  // Core hook: records (capture active) or console.error, and falls back to the nearest boundary's fallback (not for
13
13
  // event handlers).
14
14
  const I = _err((e, x, w) => {
15
- on ? record(e, x, w) : G.console && console.error(e)
15
+ on ? record(e, x, w) : print(e)
16
16
  // §42: a failing event handler never destroys its component (it is reported; brain-guard shows the toast)
17
17
  if (String(w).startsWith('on:')) return
18
18
  for (; x; x = x.p || x.parentNode) {
@@ -20,6 +20,7 @@ const I = _err((e, x, w) => {
20
20
  if (b) return fall(b)
21
21
  }
22
22
  })
23
+ const print = (e, ref) => G.console && console.error(I.development?.() ? e : `BRAIN_ERROR: Operation failed. Reference: ${ref || 'unavailable'}`)
23
24
  // Boundaries: owner N and its root elements → { fb, n: nodes, r: owner, f: already fell }.
24
25
  const BW = new WeakMap()
25
26
  const fbn = fb => {
@@ -50,7 +51,7 @@ export const fallback = (C, fb) =>
50
51
  try {
51
52
  a = I.nodes(C($p))
52
53
  } catch (e) {
53
- on ? record(e, r, 'render') : G.console && console.error(e)
54
+ on ? record(e, r, 'render') : print(e)
54
55
  r.q()
55
56
  ;(b.f = 1), (a = fbn(fb))
56
57
  }
@@ -67,6 +68,7 @@ const crumbs = []
67
68
  let opts = {}, on = false, sent = 0, F0 = null
68
69
 
69
70
  export function crumb(k, d) {
71
+ k = clean(k, 40); d = clean(d, 200)
70
72
  const l = crumbs[crumbs.length - 1]
71
73
  if (l && l.k === k && l.d === d) return void l.n++ // the same repeated action is counted, not stacked
72
74
  crumbs.push({ k, d, n: 1, at: Date.now() })
@@ -82,22 +84,49 @@ const path = u => {
82
84
  try {
83
85
  return new URL(u, G.location && G.location.href).pathname // no query: it may carry sensitive data
84
86
  } catch {
85
- return String(u).split('?')[0]
87
+ return String(u).split(/[?#]/)[0]
86
88
  }
87
89
  }
88
- // "url:line:col" of each frame; the first one outside the runtime is the app line.
90
+ const LIMIT = { frames: 24, causes: 6, stackBytes: 16384, stackLines: 256, maps: 64, sourceBytes: 1048576, mapLines: 65536, loads: 2, pending: 64, deadline: 2000 }
91
+ const get = (o, k) => { try { return o && o[k] } catch { return null } }
92
+ const clean = (s, max = 500) => String(s == null ? '' : s).slice(0, max).replace(/(?:https?|file):\/\/[^\s)]+/g, u => u.split(/[?#]/)[0]).replace(/([/\w.:%-]+)[?#][^\s)'"]*/g, '$1')
93
+ // Only structural stack locations are retained, never argument lists or the raw stack header.
89
94
  function frames(e) {
90
- const out = []
91
- for (const l of String((e && e.stack) || '').split('\n')) {
95
+ const raw = String(get(e, 'stack') || ''), out = []
96
+ let omitted = raw.length > LIMIT.stackBytes
97
+ const lines = raw.slice(0, LIMIT.stackBytes).split('\n')
98
+ if (lines.length > LIMIT.stackLines) omitted = true
99
+ for (const l of lines.slice(0, LIMIT.stackLines)) {
92
100
  const m = l.match(/((?:https?|file):\/\/[^\s)]+):(\d+):(\d+)/)
93
- if (m) out.push({ url: m[1], line: +m[2], col: +m[3], rt: m[1].startsWith(RT) && !m[1].slice(RT.length).includes('/') })
101
+ if (!m) continue
102
+ if (![+m[2], +m[3]].every(n => Number.isSafeInteger(n) && n > 0)) { omitted = true; continue }
103
+ if (out.length >= LIMIT.frames) { omitted = true; break }
104
+ out.push({ url: m[1], line: +m[2], col: +m[3], rt: m[1].startsWith(RT) && !m[1].slice(RT.length).includes('/') })
94
105
  }
95
- return out
106
+ return { frames: out, truncated: omitted }
107
+ }
108
+ const generated = t => t ? { file: path(t.url).slice(-1024), line: t.line, column: t.col } : null
109
+ const location = (t, c) => ({ component: c ? clean(c.name || c.h || '', 120) || null : null, file: t ? path(t.url).slice(-1024) : c && c.file ? c.file : null, line: t ? t.line : null, column: t ? t.col : null, precision: t ? 'generated' : 'component-only' })
110
+ function trace(e, c) {
111
+ const f = frames(e), top = f.frames.find(t => !t.rt) || null
112
+ return { rawFrames: f.frames, location: location(top, c), generatedLocation: generated(top), frames: f.frames.map(t => ({ generated: generated(t), source: null, runtime: t.rt })), truncated: { stack: f.truncated, frames: f.truncated, locations: f.frames.some(t => path(t.url).length > 1024) } }
113
+ }
114
+ function causes(e, c) {
115
+ const seen = new Set([e]), out = []
116
+ let next = get(e, 'cause'), truncated = false
117
+ while (next != null) {
118
+ if (seen.has(next) || out.length >= LIMIT.causes) { truncated = true; break }
119
+ seen.add(next)
120
+ const t = trace(next, c)
121
+ out.push({ depth: out.length + 1, type: clean(get(next, 'name') || typeof next, 80), code: clean(get(next, 'code') || '', 80) || null, msg: clean(get(next, 'message') || (typeof next === 'string' ? next : ''), 300), ...t })
122
+ next = get(next, 'cause')
123
+ }
124
+ return { entries: out, truncated }
96
125
  }
97
126
  // Component via the DOM: roots with data-b="<hash>" (prod) → parent chain via ancestors with data-b.
98
127
  function byDom(el) {
99
128
  const chain = []
100
- for (; el; el = el.parentNode) if (el.getAttribute && el.getAttribute('data-b')) chain.push({ h: el.getAttribute('data-b') })
129
+ for (; el && chain.length < 33; el = el.parentNode) if (el.getAttribute && el.getAttribute('data-b')) chain.push({ h: el.getAttribute('data-b') })
101
130
  return chain.length ? { ...chain[0], chain: chain.map(c => c.h) } : null
102
131
  }
103
132
  // Prod chunk /_b/<hash>.brain → component hash.
@@ -110,18 +139,21 @@ const byStack = f => {
110
139
  // line to its .brain line. With `maps`, a recorded error is enriched asynchronously: `src` ('views/home.brain:12', the
111
140
  // first app frame) and `brainStack`; subscribers (brain_errors, overlay, doctor) are notified again. The file path
112
141
  // comes from the module URL (two `index.brain` in different folders never mix); prod chunks carry no map: no-op.
113
- const MAPS = new Map()
142
+ const MAPS = new Map(), pending = []
143
+ let loading = 0, enriching = 0
114
144
  const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
115
145
  export function decode(m) {
146
+ if (typeof m !== 'string' || m.length > LIMIT.sourceBytes) return []
116
147
  const lines = []
117
148
  let src = 0
118
- for (const seg of m.split(';')) {
149
+ for (const seg of m.split(';').slice(0, LIMIT.mapLines)) {
119
150
  let line = null
120
151
  for (const part of seg.split(',')) {
121
152
  if (!part) continue
122
153
  const v = []
123
154
  for (let i = 0, n = 0, sh = 0; i < part.length; i++) {
124
155
  const d = B64.indexOf(part[i])
156
+ if (d < 0 || sh > 30) return []
125
157
  n += (d & 31) << sh
126
158
  if (d & 32) sh += 5
127
159
  else (v.push(n & 1 ? -(n >> 1) : n >> 1), (n = 0), (sh = 0))
@@ -132,54 +164,147 @@ export function decode(m) {
132
164
  }
133
165
  return lines
134
166
  }
135
- const mapOf = url => {
136
- if (!MAPS.has(url))
137
- MAPS.set(
138
- url,
139
- Promise.resolve(opts.load ? opts.load(url) : F0(url).then(r => r.text()))
140
- .then(t => {
141
- const m = /sourceMappingURL=data:application\/json;base64,([A-Za-z0-9+/=]+)/.exec(t)
142
- return m ? decode(JSON.parse(atob(m[1])).mappings) : null
143
- })
144
- .catch(() => null)
145
- )
146
- return MAPS.get(url)
147
- }
148
- async function toBrain(r, f) {
149
- const out = []
150
- for (const t of f.slice(0, 12)) {
151
- const lines = t.rt ? null : await mapOf(t.url)
152
- const l = lines && lines[t.line - 1]
153
- if (l) out.push(`${path(t.url).replace(/^\//, '')}:${l}`)
167
+ async function source(url, signal) {
168
+ if (opts.load) {
169
+ const text = await opts.load(url, { signal })
170
+ return typeof text === 'string' && text.length <= LIMIT.sourceBytes && new TextEncoder().encode(text).byteLength <= LIMIT.sourceBytes ? text : null
154
171
  }
155
- if (!out.length) return
156
- r.src = out[0]
157
- r.brainStack = out
172
+ if (!F0) return null
173
+ const r = await F0(url, { signal, credentials: 'same-origin' })
174
+ if (r.ok === false || +(r.headers && r.headers.get('content-length')) > LIMIT.sourceBytes) return null
175
+ if (!r.body || !r.body.getReader) { const text = await r.text(); return text.length <= LIMIT.sourceBytes && new TextEncoder().encode(text).byteLength <= LIMIT.sourceBytes ? text : null }
176
+ const reader = r.body.getReader(), decoder = new TextDecoder()
177
+ let text = '', bytes = 0
178
+ try {
179
+ for (;;) {
180
+ const part = await reader.read()
181
+ if (part.done) break
182
+ bytes += part.value.byteLength
183
+ if (bytes > LIMIT.sourceBytes) { await reader.cancel(); return null }
184
+ text += decoder.decode(part.value, { stream: true })
185
+ }
186
+ return text + decoder.decode()
187
+ } finally { reader.releaseLock() }
188
+ }
189
+ function pump() {
190
+ while (loading < LIMIT.loads && pending.length) {
191
+ const task = pending.shift()
192
+ if (task.done) continue
193
+ loading++
194
+ Promise.resolve().then(() => source(task.url, task.controller.signal)).then(text => {
195
+ const m = text && /sourceMappingURL=data:application\/json;base64,([A-Za-z0-9+/=]+)/.exec(text)
196
+ const map = m && JSON.parse(atob(m[1]))
197
+ const lines = map && map.version === 3 && decode(map.mappings)
198
+ task.finish({ lines: lines && lines.length ? lines : null, status: lines && lines.length ? 'mapped' : 'unavailable' })
199
+ }).catch(() => task.finish({ lines: null, status: 'unavailable' })).finally(() => { loading--; pump() })
200
+ }
201
+ }
202
+ function mapOf(input) {
203
+ let url
204
+ try {
205
+ const u = new URL(input), origin = G.location && G.location.origin
206
+ if (!['http:', 'https:', 'file:'].includes(u.protocol) || (u.protocol !== 'file:' && origin && origin !== 'null' && u.origin !== origin)) return Promise.resolve({ lines: null, status: 'unavailable' })
207
+ u.hash = ''; url = u.href
208
+ } catch { return Promise.resolve({ lines: null, status: 'unavailable' }) }
209
+ const hit = MAPS.get(url)
210
+ if (hit) { MAPS.delete(url); MAPS.set(url, hit); return hit.promise }
211
+ if (pending.length >= LIMIT.pending) return Promise.resolve({ lines: null, status: 'limited' })
212
+ if (MAPS.size >= LIMIT.maps) {
213
+ const old = [...MAPS].find(([, e]) => e.done)
214
+ if (!old) return Promise.resolve({ lines: null, status: 'limited' })
215
+ MAPS.delete(old[0])
216
+ }
217
+ const entry = { done: false }
218
+ entry.promise = new Promise(resolve => {
219
+ const task = { url, done: false, controller: new AbortController(), finish(result) {
220
+ if (task.done) return
221
+ task.done = entry.done = true
222
+ clearTimeout(timer)
223
+ const at = pending.indexOf(task)
224
+ if (at >= 0) pending.splice(at, 1)
225
+ if (result.status !== 'mapped' && MAPS.get(url) === entry) MAPS.delete(url)
226
+ resolve(result)
227
+ } }
228
+ const timer = setTimeout(() => { task.controller.abort(); task.finish({ lines: null, status: 'timeout' }) }, LIMIT.deadline)
229
+ pending.push(task)
230
+ })
231
+ MAPS.set(url, entry)
232
+ pump()
233
+ return entry.promise
234
+ }
235
+ async function toBrain(r, traces) {
236
+ const jobs = []
237
+ for (const item of traces) for (const [index, t] of item.rawFrames.entries()) {
238
+ if (t.rt) continue
239
+ jobs.push(mapOf(t.url).then(result => {
240
+ const line = result.lines && result.lines[t.line - 1]
241
+ if (Number.isSafeInteger(line) && line > 0 && item.frames[index]) item.frames[index].source = { file: path(t.url).replace(/^\//, '').slice(-1024), line, column: null }
242
+ return result.status
243
+ }))
244
+ }
245
+ const status = await Promise.all(jobs)
246
+ for (const item of traces) {
247
+ const first = item.frames.find(t => t.source)
248
+ if (first) item.location = { component: item.location.component, ...first.source, precision: 'source-line' }
249
+ delete item.rawFrames
250
+ }
251
+ for (let i = 1; i < traces.length; i++) r.causes[i - 1].location = traces[i].location
252
+ r.location = traces[0].location
253
+ r.frames = traces[0].frames
254
+ const out = r.frames.filter(t => t.source).map(t => `${t.source.file}:${t.source.line}`)
255
+ if (out.length) { r.src = out[0]; r.brainStack = out }
256
+ r.mapping = { status: status.includes('timeout') ? 'timeout' : status.includes('limited') ? 'limited' : out.length ? 'mapped' : 'unavailable' }
257
+ r.truncated.mapping = status.some(s => s === 'timeout' || s === 'limited')
258
+ boundRecord(r)
158
259
  for (const s of sub) s(r)
159
260
  }
160
261
 
262
+ // Bound the serialized notification too, including mapped locations and nested causes.
263
+ function boundRecord(r) {
264
+ const size = () => new TextEncoder().encode(JSON.stringify(r)).byteLength
265
+ r.truncated.output ||= false
266
+ while (size() > 65536) {
267
+ r.truncated.output = true
268
+ const nested = [...r.causes].reverse().find(c => c.frames.length)
269
+ if (nested) { nested.frames.pop(); nested.truncated.frames = true; continue }
270
+ if (r.frames.length > 1) { r.frames.pop(); r.stack.pop(); if (r.brainStack) r.brainStack.pop(); r.truncated.frames = true; continue }
271
+ if (r.causes.length) { r.causes.pop(); r.truncated.causes = true; continue }
272
+ break
273
+ }
274
+ }
161
275
  function record(e, x, w) {
162
276
  if (e && e.kind === 'aborted') return // a cancelled request (brain-data ApiError): not an error
163
- const f = frames(e), top = f.find(t => !t.rt) || null
164
- const c = (opts.who && opts.who(x)) || (x && x.nodeType ? byDom(x) : null) || byStack(top)
165
- const type = (e && e.code) || (e && e.name) || typeof e
166
- const line = top ? `${path(top.url)}:${top.line}` : null
277
+ const extracted = frames(e), f = extracted.frames, top = f.find(t => !t.rt) || null
278
+ const owner = (opts.who && opts.who(x)) || (x && x.nodeType ? byDom(x) : null) || byStack(top)
279
+ const c = owner ? { id: typeof owner.id === 'number' ? owner.id : null, h: clean(owner.h || '', 120) || null, name: clean(owner.name || '', 120) || null, file: owner.file ? clean(owner.file, 500).split(/[?#]/)[0] : null, chain: Array.isArray(owner.chain) ? owner.chain.slice(0, 32).map(n => clean(n, 120)) : [] } : null
280
+ const type = clean(get(e, 'code') || get(e, 'name') || typeof e, 80)
281
+ const line = top ? `${path(top.url).slice(-1024)}:${top.line}` : null
167
282
  const fp = hash([type, c && c.h, line, w].join('|'))
168
283
  let r = errors.find(r => r.fp === fp)
169
284
  if (r) (r.n++, (r.last = Date.now()))
170
285
  else {
286
+ const t = trace(e, c), chain = causes(e, c)
171
287
  r = {
172
- fp, code: (e && e.code) || null, type, msg: MSG[e && e.code] || String((e && e.message) || e).slice(0, 500), where: w || null, comp: c, line,
173
- stack: f.slice(0, 12).map(t => `${path(t.url)}:${t.line}:${t.col}`), crumbs: crumbs.map(c => ({ ...c })),
174
- build: opts.build || null, ua: G.navigator ? navigator.userAgent : null, at: Date.now(), last: Date.now(), n: 1,
288
+ fp, code: clean(get(e, 'code') || '', 80) || null, type, msg: MSG[get(e, 'code')] || clean(get(e, 'message') || e), where: clean(w || '', 120) || null, comp: c, line,
289
+ stack: f.slice(0, LIMIT.frames).map(t => `${path(t.url).slice(-1024)}:${t.line}:${t.col}`), location: t.location, generatedLocation: t.generatedLocation, frames: t.frames, causes: chain.entries.map(({ rawFrames, ...item }) => item), truncated: { ...t.truncated, causes: chain.truncated, componentChain: !!(owner && owner.chain && owner.chain.length > 32), breadcrumbs: crumbs.length >= 30, locations: f.some(t => path(t.url).length > 1024), mapping: false }, mapping: { status: opts.maps ? 'pending' : 'disabled' }, crumbs: crumbs.map(c => ({ ...c })),
290
+ build: clean(opts.build || '', 200) || null, ua: G.navigator ? clean(navigator.userAgent, 300) : null, at: Date.now(), last: Date.now(), n: 1,
175
291
  }
292
+ boundRecord(r)
176
293
  errors.push(r)
177
294
  if (errors.length > 100) errors.shift()
178
- if (opts.maps) toBrain(r, f)
295
+ if (opts.maps && enriching < 100) {
296
+ enriching++
297
+ const traces = [t, ...chain.entries.slice(0, r.causes.length)]
298
+ for (let i = 0; i < r.causes.length; i++) chain.entries[i].frames = r.causes[i].frames
299
+ toBrain(r, traces).finally(() => { enriching-- })
300
+ } else {
301
+ if (opts.maps) { r.mapping.status = 'limited'; r.truncated.mapping = true }
302
+ for (const item of chain.entries) delete item.rawFrames
303
+ }
179
304
  if (opts.report && G.navigator && navigator.sendBeacon && sent < (opts.max || 20)) sent++, navigator.sendBeacon(opts.report, JSON.stringify(r))
180
305
  }
181
306
  for (const s of sub) s(r)
182
- if (opts.log !== false && G.console) console.error(e)
307
+ if (opts.log !== false) print(e, r.fp)
183
308
  return r
184
309
  }
185
310
 
@@ -200,7 +325,7 @@ export function capture(o = {}) {
200
325
  G.addEventListener('error', ev => record(ev.error || ev.message, null, 'onerror'))
201
326
  G.addEventListener('unhandledrejection', ev => record(ev.reason, null, 'promise'))
202
327
  G.addEventListener('popstate', () => crumb('nav', path(location.href)))
203
- G.addEventListener('hashchange', () => crumb('nav', path(location.href) + location.hash))
328
+ G.addEventListener('hashchange', () => crumb('nav', path(location.href)))
204
329
  }
205
330
  const d = G.document
206
331
  if (d && d.addEventListener) {
package/brain-form.js CHANGED
@@ -15,7 +15,7 @@
15
15
  // Unsaved changes (§41.5): `f.dirty` is true while values differ from the last saved ones (init, reset() or a successful
16
16
  // submit); leaving the page (client navigation or closing the tab) then asks first. No API.
17
17
  import { sig, fx, batch, untrack, _rt } from './brain.js'
18
- import { rule, check, msg } from './brain-schema.js'
18
+ import { rule, check, checkText, checkScalar, msg } from './brain-schema.js'
19
19
 
20
20
  export { rule, check } // server.mjs validates posted forms with the same rules (§38)
21
21
 
@@ -41,9 +41,18 @@ if (globalThis.addEventListener && !globalThis.__brainDirtyHook) {
41
41
  for (const f of DIRTY) if (f.dirty && !f.busy) return void (e.preventDefault(), (e.returnValue = ''))
42
42
  })
43
43
  }
44
- const empty = v => v == null || v === '' || (Array.isArray(v) && !v.length)
44
+ const empty = v => v == null || typeof v === 'string' && v.trim() === '' || Array.isArray(v) && !v.length
45
45
  const init0 = r => (r.list ? [] : r.t === 'bool' ? false : '')
46
- const out = (r, v) => (r.list ? v || [] : r.t === 'num' ? (empty(v) ? null : Number(v)) : r.t === 'bool' ? !!v : v)
46
+ const out = (r, v) => {
47
+ if (r.list) {
48
+ if (!Array.isArray(v)) return []
49
+ if (r.t !== 'num') return v
50
+ const numbers = new Array(v.length)
51
+ for (let i = 0; i < v.length; i++) numbers[i] = Number(v[i])
52
+ return numbers
53
+ }
54
+ return r.t === 'num' ? (empty(v) ? null : Number(v)) : r.t === 'bool' ? !!v : v
55
+ }
47
56
 
48
57
  // state left by the server: globalThis.__brainForms while it renders, or the page's #brain-forms JSON
49
58
  let SEEN
@@ -62,6 +71,46 @@ function posted(id) {
62
71
  }
63
72
 
64
73
  export function form(schema, o = {}) {
74
+ if (rule(schema).t !== 'obj') throw Error('form: schema must be a nested field object')
75
+ return createForm(schema, o, rule, check, remoteCheck)
76
+ }
77
+ // Compiler-only flat literal schema bytecode. Dynamic/public form() keeps the full parser.
78
+ export function formStatic(schema, o = {}) {
79
+ return compiledForm(schema, o, ['str', 'email'], checkText, remoteCheck)
80
+ }
81
+ export function formScalars(schema, o = {}) {
82
+ return compiledForm(schema, o, ['str', 'email', 'bool', 'num'], checkScalar, remoteCheck)
83
+ }
84
+ // Compiler-only options proven not to configure or dynamically install async checks.
85
+ export function formStaticSync(schema, o = {}) {
86
+ return compiledForm(schema, o, ['str', 'email'], checkText, null)
87
+ }
88
+ export function formScalarsSync(schema, o = {}) {
89
+ return compiledForm(schema, o, ['str', 'email', 'bool', 'num'], checkScalar, null)
90
+ }
91
+ function compiledForm(schema, o, types, validate, remote) {
92
+ if (remote === null && ('check' in o || 'validate' in o)) throw Error('form: invalid compiled synchronous options')
93
+ if (!schema || Object.getPrototypeOf(schema) !== Object.prototype || Object.keys(schema).length > 4096) throw Error('form: invalid compiled schema')
94
+ return createForm(schema, o, (value, key) => {
95
+ if (['__proto__', 'prototype', 'constructor'].includes(key) || !Array.isArray(value) || value.length !== 2 || !types.includes(value[0]) || typeof value[1] !== 'boolean') throw Error('form: invalid compiled schema')
96
+ return { t: value[0], req: value[1], list: false, min: null, max: null }
97
+ }, validate, remote)
98
+ }
99
+ // Only public/dynamic or check-configured forms retain this optional capability.
100
+ function remoteCheck(o, x, checking) {
101
+ const c = o.check && o.check[x.path], v = untrack(() => x.s.v)
102
+ if (!c || untrack(() => x.e.v)) return Promise.resolve(untrack(() => x.e.v))
103
+ checking.v++
104
+ return Promise.resolve()
105
+ .then(() => c(v))
106
+ .then(m => m || '', e => (e && e.kind === 'aborted' ? '' : (e && e.message) || msg('form.check_failed', 'Could not check this value.')))
107
+ .then(m => {
108
+ if (untrack(() => x.s.v) === v) x.e.v = m
109
+ return untrack(() => x.e.v)
110
+ })
111
+ .finally(() => checking.v--)
112
+ }
113
+ function createForm(schema, o, readRule, validateRule, remote) {
65
114
  const id = 'f' + ++seq, owner = I.owner() // aria effects are created later (in handlers) but live with the component
66
115
  const busy = sig(false), error = sig(''), shown = sig(0) // shown: bumped when touched set changes
67
116
  const checking = sig(0), saved = sig('') // async checks running · JSON of the last saved values
@@ -72,7 +121,7 @@ export function form(schema, o = {}) {
72
121
  function build(sch, base, init) {
73
122
  const v = {}, err = {}
74
123
  for (const k of Object.keys(sch)) {
75
- const r = rule(sch[k]), path = base ? base + '.' + k : k
124
+ const r = readRule(sch[k], k), path = base ? base + '.' + k : k
76
125
  if (r.t === 'obj') {
77
126
  const sub = build(r.s, path, (init && init[k]) || {})
78
127
  Object.defineProperty(v, k, { enumerable: true, value: sub.v })
@@ -115,23 +164,9 @@ export function form(schema, o = {}) {
115
164
  const touch = x => {
116
165
  validate(x)
117
166
  if (!x.touched) (x.touched = true), shown.v++
118
- remote(x)
119
- }
120
- // async check of one field (only when its own rule passes); resolves to its message
121
- const remote = x => {
122
- const c = o.check && o.check[x.path], v = untrack(() => x.s.v)
123
- if (!c || untrack(() => x.e.v)) return Promise.resolve(untrack(() => x.e.v))
124
- checking.v++
125
- return Promise.resolve()
126
- .then(() => c(v))
127
- .then(m => m || '', e => (e && e.kind === 'aborted' ? '' : (e && e.message) || msg('form.check_failed', 'Could not check this value.')))
128
- .then(m => {
129
- if (untrack(() => x.s.v) === v) x.e.v = m // a stale answer (the value changed meanwhile) is ignored
130
- return untrack(() => x.e.v)
131
- })
132
- .finally(() => checking.v--)
167
+ if (remote) remote(o, x, checking)
133
168
  }
134
- const validate = x => (x.e.v = untrack(() => check(x.r, x.s.v)))
169
+ const validate = x => (x.e.v = untrack(() => validateRule(x.r, x.s.v)))
135
170
  const fid = p => `${id}-${p.replace(/\./g, '-')}-err`
136
171
 
137
172
  // A form posted without JS (§38) comes back from the server with its values and messages: start from them, already
@@ -196,7 +231,7 @@ export function form(schema, o = {}) {
196
231
  return checking.v > 0
197
232
  },
198
233
  get ok() {
199
- for (const x of F) if (check(x.r, x.s.v)) return false
234
+ for (const x of F) if (validateRule(x.r, x.s.v)) return false
200
235
  return true
201
236
  },
202
237
  id: path => fid(path),
@@ -244,8 +279,8 @@ export function form(schema, o = {}) {
244
279
  busy.v = true
245
280
  if (fm) fm.setAttribute('aria-busy', 'true') // assistive tech hears the form is working
246
281
  try {
247
- if (o.check) {
248
- await Promise.all(F.map(remote))
282
+ if (remote && o.check) {
283
+ await Promise.all(F.map(x => remote(o, x, checking)))
249
284
  first = F.find(x => untrack(() => x.e.v))
250
285
  if (first) return void (shown.v++, first.el && first.el.focus && first.el.focus())
251
286
  }
package/brain-hash.js ADDED
@@ -0,0 +1,17 @@
1
+ // Web Crypto owns cryptography in browsers and Node. No hashing implementation is bundled.
2
+ export async function hashText(value, algorithm = 'SHA-256') {
3
+ if (!['SHA-256', 'SHA-384', 'SHA-512'].includes(algorithm)) throw RangeError('hashText: SHA-256, SHA-384 or SHA-512 required')
4
+ if (typeof value !== 'string') throw TypeError('hashText: text input required')
5
+ if (value.length > 8388608) throw RangeError('hashText: input exceeds 8 MiB')
6
+ const bytes = new TextEncoder().encode(value)
7
+ if (bytes.byteLength > 8388608) throw RangeError('hashText: UTF-8 input exceeds 8 MiB')
8
+ if (!globalThis.crypto?.subtle) throw Error('hashText: Web Crypto requires a secure browser context or Node 22+')
9
+ const digest = new Uint8Array(await globalThis.crypto.subtle.digest(algorithm, bytes))
10
+ let hex = ''
11
+ for (let i = 0; i < digest.length; i++) hex += digest[i].toString(16).padStart(2, '0')
12
+ return hex
13
+ }
14
+ export async function shortHash(value, length = 32) {
15
+ if (!Number.isInteger(length) || length < 8 || length > 64) throw RangeError('shortHash: length must be 8..64 hex characters')
16
+ return (await hashText(value)).slice(0, length)
17
+ }
package/brain-inspect.js CHANGED
@@ -16,8 +16,8 @@ const now = () => Date.now()
16
16
  const pnow = () => (G.performance ? performance.now() : now())
17
17
  const syncHeap = () => (G.performance && performance.memory ? performance.memory.usedJSHeapSize : G.process && process.memoryUsage ? process.memoryUsage().heapUsed : null)
18
18
 
19
- let out = [], seq = 0, opts = {}, started = false
20
- const push = r => out.push(r)
19
+ let out = [], seq = 0, opts = {}, started = false, dropped = 0
20
+ const push = r => { if (out.length >= 2000) { out.shift(); dropped++ } out.push(r) }
21
21
 
22
22
  // ---------- instance identity and current owner ----------
23
23
  const ids = new WeakMap()
@@ -612,6 +612,8 @@ export function tick() {
612
612
  export function drain() {
613
613
  if (!out.length) return null
614
614
  const m = { t: 'm', v: 2, seq: seq++, at: now(), d: out }
615
+ if (dropped) m.dropped = dropped
616
+ dropped = 0
615
617
  out = []
616
618
  if (opts.send) opts.send(JSON.stringify(m))
617
619
  return m
@@ -629,7 +631,7 @@ export function inspect(o) {
629
631
  else i = instOf(x)
630
632
  if (!i) return null
631
633
  const chain = []
632
- for (let j = i; j; j = instOf(j.p)) chain.push(nameOf(j))
634
+ for (let j = i; j && chain.length < 33; j = instOf(j.p)) chain.push(nameOf(j))
633
635
  return { id: idOf(i), h: i.h, name: nameOf(i), file: (i.C && (i.C.f || (i.C.C && i.C.C.f))) || null, chain }
634
636
  },
635
637
  sigName,
@@ -643,7 +645,7 @@ export function inspect(o) {
643
645
  if (done) done.add(key)
644
646
  push({ k: 'chan', kind, name, role, id: i ? idOf(i) : null, h: i ? i.h : null, comp: i ? nameOf(i) : null })
645
647
  })
646
- sub.add(r => push({ k: 'err', fp: r.fp, code: r.code, msg: r.msg, where: r.where, comp: r.comp, line: r.line, src: r.src || null, stack: r.brainStack || r.stack, crumbs: r.crumbs, n: r.n, at: r.last }))
648
+ sub.add(r => push({ k: 'err', fp: r.fp, code: r.code, msg: r.msg, where: r.where, comp: r.comp, line: r.line, src: r.src || null, stack: r.brainStack || r.stack, brainStack: r.brainStack || null, location: r.location, generatedLocation: r.generatedLocation, frames: r.frames, causes: r.causes, truncated: r.truncated, mapping: r.mapping, crumbs: r.crumbs, n: r.n, at: r.last }))
647
649
  patchTimers()
648
650
  patchGlobalListeners()
649
651
  patchHandlers()
@@ -0,0 +1,50 @@
1
+ // Explicit foreign-DOM ownership. The adapter, SDK and any React runtime belong to the app.
2
+ import { _rt, fx, untrack } from './brain.js'
3
+
4
+ const I = _rt()
5
+
6
+ // Call during component setup. Resolve the bound element only after insertion.
7
+ // mount must synchronously return a cleanup function or { update?, destroy }.
8
+ export function interop(element, adapter, props = {}, options = {}) {
9
+ const owner = I.owner()
10
+ if (!owner) throw Error('interop: call during component setup')
11
+ let alive = true, instance = null, pending = props, ready = false
12
+ const report = (error, phase) => {
13
+ if (typeof options.onError === 'function') options.onError(error, phase)
14
+ else I.oops(error, owner, 'interop:' + phase)
15
+ }
16
+ const destroy = () => {
17
+ if (!alive) return
18
+ alive = false
19
+ const old = instance
20
+ instance = null
21
+ if (old) try { old.destroy() } catch (error) { report(error, 'destroy') }
22
+ }
23
+ const controller = {
24
+ update(next) {
25
+ if (!alive) throw Error('interop: boundary has been destroyed')
26
+ pending = next
27
+ if (!ready) return
28
+ if (!instance || typeof instance.update !== 'function') throw Error('interop: adapter does not support updates')
29
+ try { untrack(() => instance.update(next)) } catch (error) { report(error, 'update') }
30
+ },
31
+ destroy,
32
+ }
33
+ // No SDK execution during SSR or prerender. Disposal still invalidates this controller.
34
+ fx(() => destroy)
35
+ if (globalThis.__brainSSR || globalThis.__brainRoute) return controller
36
+ queueMicrotask(() => {
37
+ if (!alive) return
38
+ try {
39
+ const el = typeof element === 'function' ? untrack(element) : element
40
+ if (!el || el.nodeType !== 1) throw Error('interop: expected a bound DOM element')
41
+ if (!adapter || typeof adapter.mount !== 'function') throw Error('interop: adapter.mount is required')
42
+ const result = untrack(() => adapter.mount(el, pending))
43
+ if (typeof result === 'function') instance = { destroy: result }
44
+ else if (result && typeof result.destroy === 'function' && typeof result.then !== 'function') instance = result
45
+ else throw Error('interop: mount must return cleanup or { update, destroy }; asynchronous mounts are unsupported')
46
+ ready = true
47
+ } catch (error) { report(error, 'mount') }
48
+ })
49
+ return controller
50
+ }
package/brain-overlay.js CHANGED
@@ -24,10 +24,21 @@ export function forLLM(d) {
24
24
  })
25
25
  if (d.fix) out.push(`fix: ${d.fix}`)
26
26
  if (d.brainStack) out.push('stack:', ...[].concat(d.brainStack))
27
+ if (d.location || d.frames || d.causes || d.rid) out.push('structured trace (development only):', JSON.stringify(traceOf(d)))
28
+ if (d.mapping?.status && d.mapping.status !== 'mapped') out.push('Location mapping is incomplete; do not treat generated coordinates as original source coordinates.')
29
+ out.push('Agent next steps: inspect the exact source revision, apply a focused change, run brain check --json, then reproduce the failing action. Record evidence with brain history and refresh brain quality.')
27
30
  if (d.file) out.push('brain check --json:', JSON.stringify((byFile.get(d.file) || [d]).map(({ snippet, ...x }) => x)))
28
31
  return out.join('\n')
29
32
  }
30
33
 
34
+ function traceOf(d) {
35
+ return { schemaVersion: 1, requestId: d.rid || d.requestId || null, component: d.comp || null,
36
+ location: d.location || null, generatedLocation: d.generatedLocation || null,
37
+ mapping: d.mapping || null, truncated: d.truncated || null,
38
+ frames: Array.isArray(d.frames) ? d.frames.slice(0, 24) : [],
39
+ causes: Array.isArray(d.causes) ? d.causes.slice(0, 6) : [] }
40
+ }
41
+
31
42
  function item(d, i) {
32
43
  const loc = d.file ? `${d.file}:${d.line || ''}${d.col ? ':' + d.col : ''}` : d.src || ''
33
44
  const snip = d.snippet && d.snippet.text
@@ -35,6 +46,7 @@ function item(d, i) {
35
46
  : ''
36
47
  return `<li class="${d.level === 'warning' || d.level === 'info' ? d.level : 'error'}"><p class="t"><b>${esc(d.code || 'Error')}</b> <span>${esc(loc)}</span></p>` +
37
48
  `<p>${esc(d.msg)}</p>${snip}${d.fix ? `<p class="fix">fix: ${esc(d.fix)}</p>` : ''}` +
49
+ `${d.location || d.frames || d.causes ? `<details><summary>Development trace and mapping</summary><pre>${esc(JSON.stringify(traceOf(d), null, 2))}</pre></details>` : ''}` +
38
50
  `<button type="button" data-copy="${i}">Copy for the LLM</button></li>`
39
51
  }
40
52
 
@@ -0,0 +1,64 @@
1
+ // Small public preferences only. Authentication belongs to HttpOnly server cookies, never Web Storage.
2
+ const G = globalThis
3
+ const privateName = /password|secret|token|authorization|sessionid|csrf|jwt|credit/i
4
+ const valid = name => typeof name === 'string' && /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,79}$/.test(name) && !privateName.test(name)
5
+ const problem = fix => Object.assign(new Error(fix), { code: 'B0913', fix })
6
+
7
+ export function storage(name, options = {}) {
8
+ if (!valid(name) || !valid(options.namespace || 'app')) throw problem('storage: use public preference names; keep authentication in server sessions')
9
+ if (options.scope && !['local', 'session'].includes(options.scope)) throw problem('storage: scope must be local or session')
10
+ const prefix = `brain:pref:${options.namespace || 'app'}:${name}:`, version = options.version || 1
11
+ const backend = () => {
12
+ let s
13
+ try { s = options.scope === 'session' ? G.sessionStorage : G.localStorage } catch {}
14
+ if (!s) throw problem('Web Storage is unavailable; use an in-memory preference or a server data source')
15
+ return s
16
+ }
17
+ const key = k => { if (!valid(k)) throw problem('storage: keys must be bounded public preference names'); return prefix + k }
18
+ const keys = s => {
19
+ if (s.length > 4096) throw problem('storage: origin contains too many keys; reduce stored preferences')
20
+ const result = []
21
+ for (let i = 0; i < s.length; i++) { const k = s.key(i); if (k?.startsWith(prefix)) result.push(k) }
22
+ return result
23
+ }
24
+ return {
25
+ get(k, fallback = null) {
26
+ const s = backend(), raw = s.getItem(key(k))
27
+ if (raw == null) return fallback
28
+ if (raw.length > 8192) throw problem('storage: preference exceeds 8 KiB; use database() for structured data')
29
+ let r; try { r = JSON.parse(raw) } catch { throw problem('storage: malformed preference; explicitly delete or replace this key') }
30
+ return r?.v === version ? r.data : fallback
31
+ },
32
+ set(k, value) {
33
+ const s = backend(), target = key(k)
34
+ let raw
35
+ try { raw = JSON.stringify({v:version,data:value}); if (value === undefined) throw Error() } catch { throw problem('storage: values must be JSON-serializable') }
36
+ if (new TextEncoder().encode(raw).length > 8192) throw problem('storage: preference exceeds 8 KiB; use database() for structured data')
37
+ const existing = keys(s)
38
+ if (existing.length >= 128 && !existing.includes(target)) throw problem('storage: namespace exceeds 128 preference keys')
39
+ let total = new TextEncoder().encode(raw).length
40
+ for (const name of existing) if (name !== target) total += new TextEncoder().encode(s.getItem(name) || '').length
41
+ if (total > 65536) throw problem('storage: namespace exceeds 64 KiB; move structured data to database()')
42
+ try { s.setItem(target, raw) } catch { throw problem('storage: write failed or quota exceeded; reduce stored preferences') }
43
+ },
44
+ delete(k) { backend().removeItem(key(k)) },
45
+ clear() { const s = backend(); for (const k of keys(s)) s.removeItem(k) },
46
+ }
47
+ }
48
+
49
+ export function cookie(name, value, options = {}) {
50
+ if (!valid(name)) throw problem('cookie: use a public preference name; authentication cookies belong to the backend')
51
+ if (!G.document) throw problem('cookie: browser document unavailable; use ctx.cookies on the server')
52
+ const key = `brain_pref_${name}`
53
+ if (value === undefined) {
54
+ const matches = G.document.cookie.split(';').map(x => x.trim()).filter(x => x.startsWith(key + '='))
55
+ if (matches.length !== 1) return null
56
+ try { return decodeURIComponent(matches[0].slice(key.length + 1)) } catch { return null }
57
+ }
58
+ const encoded = value === null ? '' : encodeURIComponent(String(value))
59
+ if (encoded.length > 2048) throw problem('cookie: preferences must be at most 2 KiB; use database() for larger data')
60
+ const age = value === null ? 0 : options.maxAge ?? 2592000
61
+ if (!Number.isSafeInteger(age) || age < 0 || age > 31536000) throw problem('cookie: maxAge must be between zero and one year in seconds')
62
+ G.document.cookie = `${key}=${encoded}; Path=/; Secure; SameSite=Lax; Max-Age=${age}`
63
+ return value
64
+ }