@christianirack/brainllm-runtime 0.1.0 → 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-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 } 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
 
@@ -23,7 +23,7 @@ const I = _rt()
23
23
 
24
24
  const D = globalThis.document
25
25
  let seq = 0
26
- const GENERIC = 'Something went wrong. Try again.' // the text callForm (server.mjs) shows without JS
26
+ const GENERIC = 'Something went wrong. Try again.' // the text callForm (server.mjs) shows without JS (key form.error)
27
27
  // navigate through the client router when it is loaded (no reload, base path applied), else a normal load
28
28
  const go = u => {
29
29
  const G = globalThis
@@ -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) || '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
@@ -165,9 +200,24 @@ export function form(schema, o = {}) {
165
200
  })
166
201
  }
167
202
 
203
+ // `f.v` and `f.err` are the form's live fields: replacing the object (`f.v = { … }`) would leave the inputs bound to
204
+ // signals the form no longer reads (submit then sees every field empty), so it fails loudly instead (§14, B0133)
205
+ const fixed = k => {
206
+ throw Error(`form: \`${k}\` cannot be replaced; start from values with form(schema, { init }) or load them later with f.reset(values)`)
207
+ }
168
208
  const f = {
169
- v,
170
- err,
209
+ get v() {
210
+ return v
211
+ },
212
+ set v(_) {
213
+ fixed('f.v')
214
+ },
215
+ get err() {
216
+ return err
217
+ },
218
+ set err(_) {
219
+ fixed('f.err')
220
+ },
171
221
  get busy() {
172
222
  return busy.v
173
223
  },
@@ -181,7 +231,7 @@ export function form(schema, o = {}) {
181
231
  return checking.v > 0
182
232
  },
183
233
  get ok() {
184
- 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
185
235
  return true
186
236
  },
187
237
  id: path => fid(path),
@@ -229,8 +279,8 @@ export function form(schema, o = {}) {
229
279
  busy.v = true
230
280
  if (fm) fm.setAttribute('aria-busy', 'true') // assistive tech hears the form is working
231
281
  try {
232
- if (o.check) {
233
- await Promise.all(F.map(remote))
282
+ if (remote && o.check) {
283
+ await Promise.all(F.map(x => remote(o, x, checking)))
234
284
  first = F.find(x => untrack(() => x.e.v))
235
285
  if (first) return void (shown.v++, first.el && first.el.focus && first.el.focus())
236
286
  }
@@ -247,7 +297,7 @@ export function form(schema, o = {}) {
247
297
  if (x && x.errors) mark(x.errors)
248
298
  // a server failure reads the same as without JS: 4xx and the author's own errors keep their message; 5xx and a
249
299
  // TypeError (the network dropped, or a bug) get the generic one
250
- else if (!(x && x.kind === 'aborted')) error.v = (x && x.message && !(x.status >= 500) && x.name !== 'TypeError' && x.message) || GENERIC
300
+ else if (!(x && x.kind === 'aborted')) error.v = (x && x.message && !(x.status >= 500) && x.name !== 'TypeError' && x.message) || msg('form.error', GENERIC)
251
301
  } finally {
252
302
  busy.v = false
253
303
  if (fm) fm.removeAttribute('aria-busy')
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-mcp.js CHANGED
@@ -5,6 +5,8 @@
5
5
  // parameters use form()'s mini-language and become a JSON Schema; arguments are validated before `run` is called and
6
6
  // a bad call answers with the field's message instead of running. Tools act with the page's permissions only:
7
7
  // anything destructive should still ask for confirmation in the UI.
8
+ // Optional 5th argument (WebMCP draft 2026-10-27): { title, readOnly, consequential, untrusted } → `title` and
9
+ // `annotations: { readOnlyHint, consequentialHint, untrustedContentHint }` on the registration; only the keys given.
8
10
  import { fx } from './brain.js'
9
11
  import { rule, check } from './brain-schema.js'
10
12
 
@@ -32,27 +34,40 @@ export function schema(params = {}) {
32
34
 
33
35
  const text = v => ({ content: [{ type: 'text', text: typeof v === 'string' ? v : JSON.stringify(v ?? null) }] })
34
36
 
35
- export function tool(name, description, params, run) {
37
+ const HINTS = [['readOnly', 'readOnlyHint'], ['consequential', 'consequentialHint'], ['untrusted', 'untrustedContentHint']]
38
+
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) }
42
+ if (!opts) return def
43
+ if (typeof opts.title === 'string' && opts.title) def.title = opts.title
44
+ let annotations = null
45
+ for (let i = 0; i < HINTS.length; i++) {
46
+ const v = opts[HINTS[i][0]]
47
+ if (v != null) (annotations || (annotations = {}))[HINTS[i][1]] = !!v
48
+ }
49
+ if (annotations) def.annotations = annotations
50
+ return def
51
+ }
52
+
53
+ export function tool(name, description, params, run, opts) {
36
54
  const mc = context()
37
55
  if (!mc || !mc.registerTool || G.__brainSSR || G.__brainRoute) return
38
56
  const rules = Object.fromEntries(Object.entries(params || {}).map(([k, s]) => [k, rule(s)]))
39
57
  fx(() => {
40
- const reg = mc.registerTool({
41
- name,
42
- description,
43
- inputSchema: schema(params),
44
- async execute(args = {}) {
45
- for (const k in rules) {
46
- const m = check(rules[k], args[k])
47
- if (m) return text(`${k}: ${m}`)
48
- }
49
- try {
50
- return text(await run(args))
51
- } catch (e) {
52
- return text(`Error: ${(e && e.message) || e}`)
53
- }
54
- },
55
- })
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
+ }
64
+ try {
65
+ return text(await run(args))
66
+ } catch (e) {
67
+ return text(`Error: ${(e && e.message) || e}`)
68
+ }
69
+ }
70
+ const reg = mc.registerTool(def)
56
71
  return () => (reg && reg.unregister ? reg.unregister() : mc.unregisterTool && mc.unregisterTool(name))
57
72
  })
58
73
  }
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
+ }
@@ -0,0 +1,36 @@
1
+ // Pure bounded transport shared by the page fallback and its dedicated module worker.
2
+ // No owners, DOM hooks, listeners or app modules execute when this module is imported.
3
+ export async function prefetchPage(url, signal) {
4
+ try {
5
+ const r = await fetch(url, { headers: { 'x-brain-nav': '1', 'x-brain-prefetch': '1' }, credentials: 'same-origin', priority: 'low', redirect: 'error', signal })
6
+ const limit = 512 * 1024
7
+ if (!r.ok || r.redirected || !/text\/html/i.test(r.headers?.get('content-type') || '') || +(r.headers.get('content-length') || 0) > limit) {
8
+ await r.body?.cancel?.(); return null
9
+ }
10
+ let text, bytes = 0
11
+ if (r.body?.getReader) {
12
+ const reader = r.body.getReader(), decoder = new TextDecoder(), parts = []
13
+ try {
14
+ for (;;) {
15
+ const { done, value } = await reader.read()
16
+ if (done) break
17
+ bytes += value.byteLength
18
+ if (bytes > limit) { await reader.cancel(); return null }
19
+ parts.push(decoder.decode(value, { stream: true }))
20
+ }
21
+ parts.push(decoder.decode()); text = parts.join('')
22
+ } finally { reader.releaseLock?.() }
23
+ } else {
24
+ text = await r.text(); bytes = text.length * 2
25
+ if (bytes > limit) return null
26
+ }
27
+ const assets = [], base = new URL(url)
28
+ const pattern = /<script[^>]+src=["']([^"']+)["']|\bfrom\s*["']([^"']+\.(?:js|jsbrain))["']|\bimport\(\s*["']([^"']+\.(?:js|jsbrain))["']/g
29
+ let hit
30
+ while ((hit = pattern.exec(text)) && assets.length < 16) {
31
+ const asset = new URL(hit[1] || hit[2] || hit[3], base)
32
+ if (asset.origin === base.origin && asset.pathname.includes('/_b/')) assets.push(asset.href)
33
+ }
34
+ return { ok: true, status: r.status, url: r.url, headers: r.headers, text, bytes, assets }
35
+ } catch { return null }
36
+ }
@@ -0,0 +1,19 @@
1
+ // Dedicated speculative-fetch worker. Only bounded HTML/assets cross the bridge; app scripts never run.
2
+ import { prefetchPage } from './brain-prefetch-shared.js'
3
+ const jobs = new Map()
4
+ self.onmessage = async e => {
5
+ const m = e.data
6
+ if (!m || !Number.isSafeInteger(m.id)) return
7
+ if (m.cancel) { jobs.get(m.id)?.abort(); return }
8
+ if (jobs.size >= 2 || jobs.has(m.id) || typeof m.url !== 'string') return
9
+ let url
10
+ try { url = new URL(m.url); if (url.origin !== self.location.origin) return } catch { return }
11
+ const controller = new AbortController()
12
+ jobs.set(m.id, controller)
13
+ const timer = setTimeout(() => controller.abort(), 6000)
14
+ try {
15
+ const result = await prefetchPage(url.href, controller.signal)
16
+ self.postMessage(result ? { id: m.id, ...result, headers: [...result.headers] } : { id: m.id, ok: false })
17
+ } catch { self.postMessage({ id: m.id, ok: false }) }
18
+ finally { clearTimeout(timer); controller.abort(); jobs.delete(m.id) }
19
+ }