@christianirack/brainllm-runtime 0.0.0-stage → 0.3.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.
Files changed (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +4 -2
  3. package/brain-ai.js +129 -0
  4. package/brain-anim.js +21 -0
  5. package/brain-ch.js +254 -0
  6. package/brain-consent.js +38 -0
  7. package/brain-data.js +549 -0
  8. package/brain-dev.js +100 -0
  9. package/brain-env.js +160 -0
  10. package/brain-err.js +242 -0
  11. package/brain-form.js +275 -0
  12. package/brain-guard.js +242 -0
  13. package/brain-hydrate.js +72 -0
  14. package/brain-i18n.js +111 -0
  15. package/brain-inspect.js +745 -0
  16. package/brain-island.js +106 -0
  17. package/brain-lazy.js +88 -0
  18. package/brain-mcp.js +73 -0
  19. package/brain-mock.js +80 -0
  20. package/brain-overlay.js +123 -0
  21. package/brain-persist.js +65 -0
  22. package/brain-router.js +503 -0
  23. package/brain-rpc.js +20 -0
  24. package/brain-schema.js +93 -0
  25. package/brain-wc.js +69 -0
  26. package/brain-why.js +198 -0
  27. package/brain-worker.js +66 -0
  28. package/brain.js +421 -0
  29. package/package.json +20 -4
  30. package/server/server.mjs +1046 -0
  31. package/shim/dom.js +448 -0
  32. package/shim/probe.js +111 -0
  33. package/sw/brain-sw.js +103 -0
  34. package/themes/contrast.cssbrain +39 -0
  35. package/themes/dark.cssbrain +39 -0
  36. package/themes/glass.cssbrain +39 -0
  37. package/themes/paper.cssbrain +39 -0
  38. package/ui/button.brain +31 -0
  39. package/ui/center.brain +11 -0
  40. package/ui/checkbox.brain +13 -0
  41. package/ui/container.brain +11 -0
  42. package/ui/dialog.brain +29 -0
  43. package/ui/error-state.brain +53 -0
  44. package/ui/field.brain +34 -0
  45. package/ui/grid.brain +11 -0
  46. package/ui/input.brain +23 -0
  47. package/ui/lang-switch.brain +38 -0
  48. package/ui/menu.brain +64 -0
  49. package/ui/row.brain +12 -0
  50. package/ui/select.brain +29 -0
  51. package/ui/sidebar.brain +17 -0
  52. package/ui/spacer.brain +12 -0
  53. package/ui/stack.brain +10 -0
  54. package/ui/table.brain +73 -0
  55. package/ui/tabs.brain +33 -0
  56. package/ui/toast.brain +45 -0
package/brain-data.js ADDED
@@ -0,0 +1,549 @@
1
+ // BRAINLLM data module. Raw HTML (INTERNALS §19, B0210): `{@html sanitize(x)}` compiles to `html(el, () => sanitize(x))`.
2
+ // `sanitize` only marks the string as reviewed; the actual cleaning happens at insertion: native `Element.setHTML()`
3
+ // (Sanitizer API, Chrome 146+ / Firefox 148+) when present, else the minimal allowlist sanitizer below.
4
+ // Data with contract (§15, §35.3): `api(path, schema)` client and `resource(fn)` for `{#await}`.
5
+ import { sig, fx, batch, untrack, _rt } from './brain.js'
6
+ import { rule, check } from './brain-schema.js' // not brain-form.js: fetching data never loads the form module
7
+
8
+ const SAFE = Symbol('brain.safe')
9
+ export const sanitize = s => ({ [SAFE]: String(s == null ? '' : s) })
10
+
11
+ // Fallback allowlist: formatting, lists, links, images and tables. Everything else is unwrapped (text kept) or, for
12
+ // script-like elements, dropped with its content. Attributes: only the listed ones; URLs only http(s), mailto, tel,
13
+ // relative or #fragment.
14
+ const TAGS = new Set('a abbr b blockquote br caption code col colgroup dd div dl dt em figcaption figure h1 h2 h3 h4 h5 h6 hr i img ins kbd li mark ol p pre q s small span strong sub sup table tbody td tfoot th thead tr u ul'.split(' '))
15
+ const DROP = new Set('script style template iframe object embed noscript svg math link meta base frame frameset form input button select textarea'.split(' '))
16
+ const ATTRS = new Set('href src alt title width height colspan rowspan lang dir cite datetime start reversed'.split(' '))
17
+ const URL_ATTR = new Set(['href', 'src', 'cite'])
18
+ const okUrl = v => !/^\s*[a-z][a-z0-9+.-]*:/i.test(v) || /^\s*(https?|mailto|tel):/i.test(v)
19
+
20
+ export function clean(h) {
21
+ const t = document.createElement('template')
22
+ t.innerHTML = h // inert: nothing runs or loads inside a template
23
+ walk(t.content)
24
+ return t.content
25
+ }
26
+ function walk(p) {
27
+ for (const n of [...p.childNodes]) {
28
+ if (n.nodeType === 3) continue
29
+ if (n.nodeType !== 1) {
30
+ n.remove() // comments, processing instructions
31
+ continue
32
+ }
33
+ const tag = n.tagName.toLowerCase()
34
+ if (DROP.has(tag)) {
35
+ n.remove()
36
+ continue
37
+ }
38
+ walk(n)
39
+ if (!TAGS.has(tag)) {
40
+ n.replaceWith(...n.childNodes) // unknown wrapper: keep its (already cleaned) content
41
+ continue
42
+ }
43
+ for (const a of [...(n.attributes || [])]) {
44
+ const k = a.name.toLowerCase()
45
+ if (!ATTRS.has(k) || (URL_ATTR.has(k) && !okUrl(a.value))) n.removeAttribute(a.name)
46
+ }
47
+ if (tag === 'a' && n.getAttribute('href')) n.setAttribute('rel', 'noopener noreferrer')
48
+ }
49
+ }
50
+
51
+ // Inserts sanitized HTML as the content of `el`, reactively. A value not produced by `sanitize` is inserted as text.
52
+ export function html(el, f) {
53
+ fx(() => {
54
+ const v = f()
55
+ if (!v || typeof v !== 'object' || !(SAFE in v)) return void (el.textContent = v == null ? '' : String(v))
56
+ if (el.setHTML) el.setHTML(v[SAFE])
57
+ else el.replaceChildren(clean(v[SAFE]))
58
+ })
59
+ }
60
+
61
+ // ---------- api / resource ----------
62
+ // `const users = api('/users/:id', { get: { out: [User], cache: '10m' }, post: { in: User, out: User } })` →
63
+ // `users.get({ id: 7, q: 'x' })` (path params from the argument, the rest as query) · `users.post({ id, ...body })`.
64
+ // Every request (§40, §41.4): same-origin credentials, a timeout, JSON with __proto__/constructor/prototype dropped,
65
+ // `out` validated (a mismatch is reported with the shape diff), one typed ApiError, safe retries, identical GETs in
66
+ // flight shared, a per-origin queue with priorities, offline answers from the cache, `.cancel()`. GET responses are
67
+ // cached in memory per URL for the session (stale-while-revalidate, instant on back/forward); `cache: '10m'` also
68
+ // keeps them in the Cache API for the next visit (opt-in per endpoint; logout's Clear-Site-Data wipes it); `cache:
69
+ // 'no'` turns caching off. A successful write drops the cached GETs of its path and re-runs the resources reading it.
70
+ const G = globalThis, MEM = new Map(), I = _rt()
71
+ const MEM_BYTES = new Map(), MEM_MAX = 8 * 1024 * 1024, MEM_ENTRY = 512 * 1024
72
+ let memBytes = 0, cacheEpoch = 0
73
+ const forget = u => { memBytes -= MEM_BYTES.get(u) || 0; MEM_BYTES.delete(u); MEM.delete(u) }
74
+ const remember = (u, value) => {
75
+ const size = new TextEncoder().encode(JSON.stringify(value)).length
76
+ forget(u)
77
+ if (size > MEM_ENTRY) return
78
+ MEM.set(u, value); MEM_BYTES.set(u, size); memBytes += size
79
+ while (MEM.size > 256 || memBytes > MEM_MAX) forget(MEM.keys().next().value)
80
+ }
81
+ // rec(f): runs f and returns the signal writes it made ([sig, previous, …]) so they can be undone. The core has no hook
82
+ // for it (#288): the setter of every signal is wrapped only while f runs.
83
+ const SP = sig(0).constructor.prototype
84
+ const rec = f => {
85
+ const d = Object.getOwnPropertyDescriptor(SP, 'v'), w = []
86
+ Object.defineProperty(SP, 'v', {
87
+ configurable: true,
88
+ get: d.get,
89
+ set(x) {
90
+ if (!this.f && !Object.is(x, this._)) w.push(this, this._)
91
+ d.set.call(this, x)
92
+ },
93
+ })
94
+ try {
95
+ f()
96
+ } finally {
97
+ Object.defineProperty(SP, 'v', d)
98
+ }
99
+ return w
100
+ }
101
+ let CUR = null // abort signal of the resource() run that is calling an endpoint right now
102
+ const ttl = c => {
103
+ const m = /^(\d+)(ms|s|m|h)$/.exec(c || '')
104
+ return m ? m[1] * { ms: 1, s: 1e3, m: 6e4, h: 36e5 }[m[2]] : 0
105
+ }
106
+ // `cache.data: false` in brain.json → the build emits <meta name="brain-data-cache" content="off">: no memory SWR, no
107
+ // Cache API (§35 opt-out)
108
+ const dataOff = () => !!(G.document && document.querySelector('meta[name=brain-data-cache][content=off]'))
109
+ const reviver = (k, v) => (k === '__proto__' || k === 'constructor' || k === 'prototype' ? undefined : v)
110
+
111
+ // Every rejection: `kind` 'offline' · 'network' · 'timeout' · 'http' (status, errors on 422, retryAfter in seconds on
112
+ // 429/503) · 'schema' (diff); `id` is the server's request id (x-request-id) for support. `e.is(403)`.
113
+ // A cancelled request rejects with kind 'aborted' (the author's try/finally runs); framework surfaces ignore it:
114
+ // resource()'s {:catch}, the boundary toast, <ErrorState>, reportError and brain_errors.
115
+ export class ApiError extends Error {
116
+ constructor(kind, message, x) {
117
+ super(message)
118
+ this.name = 'ApiError'
119
+ this.kind = kind
120
+ this.code = 'api'
121
+ Object.assign(this, x)
122
+ }
123
+ is(status) {
124
+ return this.status === status
125
+ }
126
+ }
127
+
128
+ // App-wide policy, declared once: `<meta http base="/api" timeout="10s" retry="2" login="/login">` in app.brain
129
+ // compiles to `http({ … })`; `api/_http.jsbrain` adds headers that depend on state: `http({ headers: () => ({ … }) })`.
130
+ const P = { base: '', timeout: 10000, retry: 2, login: null, headers: null, mock: null }
131
+ // `mock: fn` (brain run --mock: TOOLCHAIN #266 loads brain-mock.js, dev only): api() answers with
132
+ // fn(method, url, def, body) instead of the network
133
+ export function http(o = {}) {
134
+ if (o.base !== undefined || o.headers !== undefined || o.mock !== undefined) {
135
+ cacheEpoch++; MEM.clear(); MEM_BYTES.clear(); memBytes = 0
136
+ }
137
+ if (o.mock !== undefined) P.mock = typeof o.mock === 'function' ? o.mock : null
138
+ if (o.base != null) P.base = String(o.base).replace(/\/$/, '')
139
+ if (o.timeout != null) P.timeout = typeof o.timeout === 'number' ? o.timeout : ttl(o.timeout) || P.timeout
140
+ if (o.retry != null) P.retry = +o.retry
141
+ if (o.login !== undefined) P.login = o.login
142
+ if (o.headers !== undefined) P.headers = o.headers
143
+ return P
144
+ }
145
+
146
+ // online(): navigator.onLine + events, as a signal read
147
+ const ON = sig(!G.navigator || G.navigator.onLine !== false)
148
+ if (G.addEventListener) G.addEventListener('online', () => (ON.v = true)), G.addEventListener('offline', () => (ON.v = false))
149
+ export const online = () => ON.v
150
+
151
+ // a version signal per path: a write bumps it and every resource() that read the path runs again
152
+ const VER = new Map()
153
+ const root = p => p.split('/:')[0].split('?')[0]
154
+ const ver = p => VER.get(p) || (VER.set(p, sig(0)), VER.get(p))
155
+
156
+ export function api(path, spec) {
157
+ const out = {}
158
+ for (const method of Object.keys(spec)) out[method] = (arg, o) => request(path, method.toUpperCase(), spec[method] || {}, arg || {}, o || {})
159
+ return out
160
+ }
161
+
162
+ function request(path, M, def, arg, o) {
163
+ const used = new Set(), file = /^\[?file\]?$/.test(def.in || '')
164
+ let u = path.replace(/:(\w+)/g, (_, k) => (used.add(k), encodeURIComponent(arg[k] == null ? '' : arg[k])))
165
+ let body
166
+ if (M === 'GET' || M === 'HEAD') {
167
+ const q = new URLSearchParams()
168
+ for (const k in arg) if (!used.has(k) && arg[k] != null) q.set(k, arg[k])
169
+ if (String(q)) u += (u.includes('?') ? '&' : '?') + q
170
+ ver(root(path)).v // tracked by the resource() calling it
171
+ } else if (file) {
172
+ body = new FormData()
173
+ for (const k in arg) if (!used.has(k)) for (const v of [].concat(arg[k])) body.append(k, v)
174
+ } else {
175
+ const b = {}
176
+ for (const k in arg) if (!used.has(k)) b[k] = arg[k]
177
+ const bad = def.in && diff(def.in, b)
178
+ if (bad) return Promise.reject(new ApiError('schema', `${M} ${path}: invalid input: ${bad}`, { diff: bad }))
179
+ body = JSON.stringify(b)
180
+ }
181
+ // Dynamic headers may carry identity. Do not share or retain their responses. Cache the resolved URL, so API
182
+ // base/origin changes cannot reuse another endpoint's data. Servers must mark cookie-auth responses no-store.
183
+ const cacheKey = full(u), epoch = cacheEpoch
184
+ const get = M === 'GET', off = dataOff() || def.cache === 'no' || !!P.headers || !!(G.document && document.cookie), keep = get && !off ? ttl(def.cache) : 0
185
+ const ctl = new AbortController(), up = sig(0)
186
+ // optimistic: () => (list = [...list, item]) — applied now, undone if the request fails
187
+ const undo = o.optimistic ? rec(o.optimistic) : null
188
+ // a call made from an event handler is cancelled when its component unmounts (brain-guard keeps `$c` on its nodes)
189
+ if (!CUR && G.event && G.event.target) for (let el = G.event.target; el; el = el.parentNode) if (el.$c) {
190
+ const c = el.$c
191
+ c.add(ctl)
192
+ ctl.signal.addEventListener('abort', () => c.delete(ctl))
193
+ break
194
+ }
195
+ const sg = o.signal || CUR ? any([ctl.signal, o.signal || CUR]) : ctl.signal
196
+ // action (a user write or an imperative GET) > visible (a resource on screen) > background (prefetch)
197
+ const pri = o.priority === 'low' || o.prefetch ? 2 : CUR && get ? 1 : 0
198
+ let p
199
+ if (P.mock) {
200
+ p = Promise.resolve(P.mock(M, u, def, typeof body === 'string' ? JSON.parse(body) : {}))
201
+ } else if (!ON.v) {
202
+ // offline: GETs answer from the cache (marked stale), writes fail at once
203
+ const err = new ApiError('offline', 'You are offline.')
204
+ p = get && !off && MEM.has(cacheKey) ? Promise.resolve(MEM.get(cacheKey)) : get && keep ? load(cacheKey, keep).then(v => (v === undefined ? Promise.reject(err) : v)) : Promise.reject(err)
205
+ p.stale = get
206
+ } else {
207
+ const go = s => run(u, M, body, s, pri, up)
208
+ p = (get && !off ? shared(M + ' ' + cacheKey + ' ' + epoch, go, sg) : go(sg)).then(answer => {
209
+ const j = answer.value
210
+ const bad = def.out && diff(def.out, j)
211
+ if (bad) {
212
+ const e = new ApiError('schema', `${M} ${path}: the response does not match its schema: ${bad}`, { diff: bad })
213
+ G.reportError && G.reportError(e)
214
+ throw e
215
+ }
216
+ if (get) {
217
+ if (answer.cacheable && epoch === cacheEpoch) {
218
+ if (!off) remember(cacheKey, j)
219
+ if (keep) save(cacheKey, j)
220
+ } else if (!answer.cacheable) {
221
+ forget(cacheKey)
222
+ dropStored(cacheKey)
223
+ }
224
+ } else {
225
+ cacheEpoch++ // An older GET cannot repopulate data after a successful write.
226
+ for (const k of MEM.keys()) if (k.split('?')[0].startsWith(full(root(path)))) forget(k)
227
+ dropStored(full(root(path)), true)
228
+ ver(root(path)).v++
229
+ }
230
+ return j
231
+ })
232
+ if (get && !off) {
233
+ if (MEM.has(cacheKey)) p.$stale = MEM.get(cacheKey)
234
+ else if (keep) p.$stored = load(cacheKey, keep)
235
+ }
236
+ }
237
+ if (undo) p = p.catch(e => {
238
+ batch(() => { for (let i = undo.length - 2; i >= 0; i -= 2) undo[i].v = undo[i + 1] })
239
+ throw e
240
+ })
241
+ p.cancel = () => ctl.abort()
242
+ if (file) Object.defineProperty(p, 'progress', { get: () => up.v }) // 0–1, a signal read
243
+ return p
244
+ }
245
+ const any = a => (AbortSignal.any ? AbortSignal.any(a) : a[1] || a[0])
246
+
247
+ // identical GETs in flight share one request; it is aborted only when every caller has left
248
+ const FLY = new Map()
249
+ function shared(key, go, signal) {
250
+ let e = FLY.get(key)
251
+ if (!e) {
252
+ const c = new AbortController()
253
+ e = { n: 0, c, p: go(c.signal) }
254
+ e.p.then(() => {}, () => {}).then(() => FLY.get(key) === e && FLY.delete(key))
255
+ FLY.set(key, e)
256
+ }
257
+ e.n++
258
+ return new Promise((ok, no) => {
259
+ let left = false
260
+ const leave = () => {
261
+ if (left) return
262
+ left = true
263
+ no(new ApiError('aborted', 'The request was cancelled.'))
264
+ if (--e.n === 0) FLY.get(key) === e && FLY.delete(key), e.c.abort()
265
+ }
266
+ if (signal.aborted) return leave()
267
+ signal.addEventListener('abort', leave, { once: true })
268
+ const settle = (fn, value) => {
269
+ if (left) return
270
+ left = true
271
+ signal.removeEventListener('abort', leave)
272
+ fn(value)
273
+ }
274
+ e.p.then(v => settle(ok, v), x => settle(no, x))
275
+ })
276
+ }
277
+
278
+ // retries only where safe: GET/HEAD/PUT/DELETE on network, timeout, 502/503/504, backoff 300 ms ×2 with jitter, up to
279
+ // `retry`; 429/503 wait Retry-After (up to 10 s). POST and PATCH are never retried.
280
+ async function run(u, M, body, sg, pri, up) {
281
+ for (let i = 0; ; i++) {
282
+ try {
283
+ return await once(u, M, body, sg, pri, up)
284
+ } catch (e) {
285
+ const again = /^(GET|HEAD|PUT|DELETE)$/.test(M) && i < P.retry && (e.kind === 'network' || e.kind === 'timeout' || (e.kind === 'http' && /^(429|502|503|504)$/.test(e.status)))
286
+ const wait = e.retryAfter != null ? e.retryAfter * 1e3 : e.status === 429 ? Infinity : 300 * 2 ** i * (0.75 + Math.random() / 2)
287
+ if (!again || wait > 1e4) throw e
288
+ await new Promise((ok, no) => {
289
+ const t = setTimeout(ok, wait)
290
+ sg.addEventListener('abort', () => (clearTimeout(t), no(new ApiError('aborted', 'The request was cancelled.'))), { once: true })
291
+ })
292
+ }
293
+ }
294
+ }
295
+
296
+ // per-origin concurrency queue (6) by priority; navigation aborts the previous route's background requests
297
+ const Q = new Map(), LOW = new Set(), LIMIT = 6, WAIT_MAX = 128
298
+ if (G.addEventListener) G.addEventListener('brain:navigate', () => LOW.forEach(c => c.abort()))
299
+ function slot(origin, pri, sg) {
300
+ if (sg.aborted) return Promise.reject(new ApiError('aborted', 'The request was cancelled.'))
301
+ const q = Q.get(origin) || (Q.set(origin, { n: 0, w: [] }), Q.get(origin))
302
+ let done = false
303
+ const release = () => {
304
+ if (done) return
305
+ done = true
306
+ const w = q.w.shift()
307
+ w ? w.go() : q.n--
308
+ if (!q.n && !q.w.length) Q.delete(origin)
309
+ }
310
+ if (q.n < LIMIT) return q.n++, Promise.resolve(release)
311
+ if (q.w.length >= WAIT_MAX) return Promise.reject(new ApiError('http', 'The request queue is full.', { status: 503, retryAfter: 1 }))
312
+ return new Promise((ok, no) => {
313
+ let timer
314
+ const cleanup = () => { clearTimeout(timer); sg.removeEventListener('abort', abort) }
315
+ const fail = error => {
316
+ const j = q.w.indexOf(w)
317
+ if (j >= 0) { q.w.splice(j, 1); cleanup(); no(error) }
318
+ }
319
+ const abort = () => fail(new ApiError('aborted', 'The request was cancelled.'))
320
+ const w = { pri, go: () => { cleanup(); ok(release) } }, i = q.w.findIndex(x => x.pri > pri)
321
+ q.w.splice(i < 0 ? q.w.length : i, 0, w)
322
+ sg.addEventListener('abort', abort, { once: true })
323
+ timer = setTimeout(() => fail(new ApiError('timeout', 'The request expired while waiting for a connection.')), P.timeout)
324
+ })
325
+ }
326
+
327
+ // paths in api() are the app's own (`/users`): `<meta http base>` then the app's base path (<meta name="brain-base">)
328
+ const appBase = () => {
329
+ const m = G.document && document.querySelector('meta[name=brain-base]')
330
+ return m ? m.getAttribute('content').replace(/\/$/, '') : ''
331
+ }
332
+ const full = u => (u.startsWith('/') && !u.startsWith('//') ? appBase() + P.base + u : u)
333
+
334
+ async function once(u, M, body, sg, pri, up) {
335
+ const url = full(u), origin = new URL(url, G.location ? location.href : 'http://localhost').origin
336
+ const low = pri === 2 && new AbortController()
337
+ if (low) LOW.add(low), (sg = any([sg, low.signal]))
338
+ let release
339
+ try { release = await slot(origin, pri, sg) } catch (e) { if (low) LOW.delete(low); throw e }
340
+ if (sg.aborted) { release(); if (low) LOW.delete(low); throw new ApiError('aborted', 'The request was cancelled.') }
341
+ const tm = AbortSignal.timeout(P.timeout), t0 = Date.now()
342
+ const headers = { accept: 'application/json', ...(typeof body === 'string' ? { 'content-type': 'application/json' } : {}), ...(P.headers ? P.headers() : {}) }
343
+ let st = 0, text = '', h = () => null
344
+ try {
345
+ if (typeof body === 'object' && G.XMLHttpRequest) ({ st, text, h } = await xhr(url, M, body, headers, any([sg, tm]), up))
346
+ else {
347
+ const r = await fetch(url, { method: M, credentials: 'same-origin', headers, body, signal: any([sg, tm]) })
348
+ st = r.status
349
+ h = k => (r.headers && r.headers.get ? r.headers.get(k) : null)
350
+ text = await r.text()
351
+ up.v = 1
352
+ }
353
+ } catch (e) {
354
+ throw sg.aborted ? new ApiError('aborted', 'The request was cancelled.') : tm.aborted ? new ApiError('timeout', 'The server took too long to answer.') : new ApiError('network', 'Connection lost.', { cause: e })
355
+ } finally {
356
+ release()
357
+ if (low) LOW.delete(low)
358
+ // a write tells the router's prefetch that pages it fetched may be stale (§19)
359
+ if (M !== 'GET' && G.dispatchEvent && G.Event) G.dispatchEvent(new Event('brain:write'))
360
+ if (G.__brainDev && G.dispatchEvent && G.CustomEvent) G.dispatchEvent(new CustomEvent('brain:net', { detail: { method: M, url, status: st, ms: Date.now() - t0, size: text.length } }))
361
+ }
362
+ let j
363
+ try {
364
+ j = text ? JSON.parse(text, reviver) : null
365
+ } catch {
366
+ j = null
367
+ }
368
+ if (st >= 200 && st < 300) return { value: j, cacheable: !/no-store|private/i.test(h('cache-control') || '') }
369
+ const ra = h('retry-after'), retryAfter = ra == null ? undefined : isNaN(ra) ? Math.max(0, (Date.parse(ra) - Date.now()) / 1e3) : +ra
370
+ if (st === 401 && P.login && G.location && !location.pathname.endsWith(P.login)) location.assign(appBase() + P.login + '?next=' + encodeURIComponent(location.pathname + location.search))
371
+ throw new ApiError('http', (j && j.error) || `${M} ${u}: ${st}`, { status: st, errors: j && j.errors, retryAfter, id: h('x-request-id') || undefined })
372
+ }
373
+
374
+ // multipart upload with progress (fetch has no upload progress)
375
+ function xhr(url, M, body, headers, sg, up) {
376
+ return new Promise((ok, no) => {
377
+ const x = new XMLHttpRequest()
378
+ x.open(M, url)
379
+ x.withCredentials = false
380
+ for (const k in headers) if (k !== 'content-type') x.setRequestHeader(k, headers[k])
381
+ if (x.upload) x.upload.onprogress = e => e.lengthComputable && (up.v = e.loaded / e.total)
382
+ x.onload = () => ((up.v = 1), ok({ st: x.status, text: x.responseText || '', h: k => x.getResponseHeader(k) }))
383
+ x.onerror = x.ontimeout = () => no(new TypeError('upload failed'))
384
+ x.onabort = () => no(new DOMException('aborted', 'AbortError'))
385
+ sg.addEventListener('abort', () => x.abort(), { once: true })
386
+ x.send(body)
387
+ })
388
+ }
389
+
390
+ // Shape check with form()'s mini-language: 'num!' · '[str]' · { field: rule | schema } · [Schema] (list of objects).
391
+ // Returns the first difference as text, or ''. Names the compiler could not resolve ('User') are not checked.
392
+ function diff(s, v, at = '') {
393
+ if (Array.isArray(s)) {
394
+ if (!Array.isArray(v)) return `${at || 'value'}: expected a list`
395
+ for (let i = 0; i < v.length; i++) {
396
+ const d = diff(s[0], v[i], `${at}[${i}]`)
397
+ if (d) return d
398
+ }
399
+ return ''
400
+ }
401
+ if (s && typeof s === 'object') {
402
+ if (!v || typeof v !== 'object' || Array.isArray(v)) return `${at || 'value'}: expected an object`
403
+ for (const k of Object.keys(s)) {
404
+ const d = diff(s[k], v[k], at ? `${at}.${k}` : k)
405
+ if (d) return d
406
+ }
407
+ return ''
408
+ }
409
+ let r
410
+ try {
411
+ r = rule(s)
412
+ const m = r.t === 'obj' ? '' : check(r, v)
413
+ return m ? `${at || 'value'}: ${m}` : ''
414
+ } catch {
415
+ return ''
416
+ }
417
+ }
418
+
419
+ const CACHE = 'brain:data' // not `brain-…`: those are per-build caches the service worker rotates
420
+ let storageTail = Promise.resolve(), storagePending = 0, storageBlocked = false
421
+ const storageTask = work => {
422
+ if (storageBlocked) return Promise.resolve()
423
+ if (storagePending >= 128) {
424
+ // Coalesce overload into one full invalidation after queued writes. Never drop an invalidation and retain
425
+ // data that a successful mutation made stale. Readers stay disabled until cleanup succeeds.
426
+ storageBlocked = true
427
+ storageTail = storageTail.then(async () => { try { if (G.caches) await G.caches.delete(CACHE); storageBlocked = false } catch {} })
428
+ return Promise.resolve()
429
+ }
430
+ storagePending++
431
+ const task = storageTail.then(work, work)
432
+ storageTail = task.catch(() => {}).finally(() => storagePending--)
433
+ return task
434
+ }
435
+ function save(u, j) { return storageTask(() => saveNow(u, j)) }
436
+ async function saveNow(u, j) {
437
+ try {
438
+ if (!G.caches) return
439
+ const c = await G.caches.open(CACHE)
440
+ const text = JSON.stringify(j)
441
+ if (new TextEncoder().encode(text).length > MEM_ENTRY) return
442
+ await c.put(new URL(u, G.location && location.href).href, new Response(text, { headers: { 'content-type': 'application/json', 'x-brain-t': String(Date.now()) } }))
443
+ const keys = await c.keys()
444
+ for (let i = 0; i < keys.length - 128; i++) await c.delete(keys[i])
445
+ } catch {}
446
+ }
447
+ function dropStored(u, prefix = false) { return storageTask(() => dropStoredNow(u, prefix)) }
448
+ async function dropStoredNow(u, prefix = false) {
449
+ try {
450
+ if (!G.caches) return
451
+ const c = await G.caches.open(CACHE), url = new URL(u, G.location && location.href).href
452
+ if (!prefix) await c.delete(url)
453
+ else for (const key of await c.keys()) if (key.url.startsWith(url)) await c.delete(key)
454
+ } catch {}
455
+ }
456
+ async function load(u, keep) {
457
+ try {
458
+ if (!G.caches || storageBlocked) return undefined
459
+ await storageTail
460
+ const r = await (await G.caches.open(CACHE)).match(new URL(u, G.location && location.href).href)
461
+ if (!r) return undefined
462
+ const stamp = Number(r.headers.get('x-brain-t')), now = Date.now()
463
+ if (!Number.isFinite(stamp) || stamp <= 0 || stamp > now + 60000 || now - stamp > keep) { await dropStored(u); return undefined }
464
+ return JSON.parse(await r.text(), reviver)
465
+ } catch {
466
+ return undefined
467
+ }
468
+ }
469
+
470
+ // `resource(fn, { page, every })`: a read-only node whose value is a Promise, for `{#await x}`. Signals read inside fn
471
+ // make it refetch (so does a write to the same path, or `r.refetch()`); the previous request is aborted and its answer
472
+ // ignored. A cached answer is shown at once and replaced only if the network brings something different; a network
473
+ // error keeps the cached answer on screen.
474
+ // `page: true` → fn is called with `{ page: 1 }`, `r.more()` appends the next page, `r.done` turns true on a short or
475
+ // empty page. `every: '30s'` refetches on an interval, paused while the tab is hidden or offline.
476
+ // a cancelled run is not an error to show: {#await} keeps waiting for the run that replaced it
477
+ const quiet = p => p.catch(e => (e && e.kind === 'aborted' ? new Promise(() => {}) : Promise.reject(e)))
478
+ export function resource(f, o = {}) {
479
+ const n = sig(new Promise(() => {})), force = sig(0), done = sig(false)
480
+ let tok = 0, acc = [], pg = 1, size = 0, loading = false
481
+ const call = (a, c) => {
482
+ CUR = c
483
+ try {
484
+ return f(a)
485
+ } catch (e) {
486
+ return Promise.reject(e)
487
+ } finally {
488
+ CUR = null
489
+ }
490
+ }
491
+ fx(() => {
492
+ force.v // refetch()
493
+ const t = ++tok, c = new AbortController()
494
+ let p = call(o.page ? { page: (pg = 1) } : undefined, c.signal)
495
+ if (o.page) {
496
+ untrack(() => (done.v = false))
497
+ p = Promise.resolve(p).then(l => ((acc = l || []), (size = acc.length), t === tok && untrack(() => (done.v = !size)), acc))
498
+ }
499
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b)
500
+ const later = (old, q) => q.then(v => t === tok && !same(v, old) && (n.v = Promise.resolve(v)), () => {})
501
+ if (p && p.$stale !== undefined) (n.v = Promise.resolve(p.$stale)), later(p.$stale, p)
502
+ else if (p && p.$stored) n.v = p.$stored.then(s => (s === undefined ? quiet(p) : (later(s, p), s)))
503
+ else n.v = quiet(Promise.resolve(p))
504
+ return () => c.abort() // re-run or unmount: the request is cancelled
505
+ })
506
+ n.refetch = () => force.v++
507
+ n.more = () => {
508
+ if (!o.page || loading || untrack(() => done.v)) return Promise.resolve()
509
+ const t = tok
510
+ loading = true
511
+ return Promise.resolve(untrack(() => call({ page: pg + 1 }, null)))
512
+ .then(l => {
513
+ if (t !== tok) return
514
+ l = l || []
515
+ pg++
516
+ if (!l.length || l.length < size) done.v = true
517
+ n.v = Promise.resolve((acc = [...acc, ...l]))
518
+ })
519
+ .finally(() => (loading = false))
520
+ }
521
+ Object.defineProperty(n, 'done', { get: () => done.v })
522
+ if (o.every && !(G.__brainSSR || G.__brainRoute)) {
523
+ const id = setInterval(() => ON.v && !(G.document && document.hidden) && force.v++, ttl(o.every) || +o.every)
524
+ fx(() => () => clearInterval(id))
525
+ }
526
+ return n
527
+ }
528
+
529
+ // `let feed = stream('/events')` (§41.4): Server-Sent Events as a read-only node. `.v` is the last message (JSON when
530
+ // it parses, else the text; `{ reduce: (acc, msg) => … , initial }` accumulates instead), `.connected` follows the
531
+ // connection. EventSource reconnects by itself; the stream closes with the component. Nothing while the server renders.
532
+ export function stream(path, o = {}) {
533
+ const n = sig(o.initial ?? null), up = sig(false), out = { get v() { return n.v }, get connected() { return up.v } }
534
+ if (!G.EventSource || G.__brainSSR || G.__brainRoute) return out
535
+ const es = new EventSource(full(path))
536
+ es.onopen = () => (up.v = true)
537
+ es.onerror = () => (up.v = false)
538
+ es.addEventListener(o.event || 'message', e => {
539
+ let m
540
+ try {
541
+ m = JSON.parse(e.data, reviver)
542
+ } catch {
543
+ m = e.data
544
+ }
545
+ n.v = o.reduce ? o.reduce(n.v, m) : m
546
+ })
547
+ fx(() => () => es.close())
548
+ return out
549
+ }
package/brain-dev.js ADDED
@@ -0,0 +1,100 @@
1
+ // BRAINLLM dev (SPEC §6.2): HMR that keeps sig state by name. Development only.
2
+ // Served next to the core under /@brain/ and imports ./brain.js relatively: a single instance of the signal graph.
3
+ // The dev codegen wraps each component: `export default dev(Counter)` and emits `sig(v, 'name')`.
4
+ // v0 limit: if after an hmr a parent removes that row or branch, it removes the old nodes; then do a full reload.
5
+ // Registry for brain-inspect (§9): `live` (hash → instances), `inst` (root node N → instance) and the event `bus`.
6
+ import { _dev, fx } from './brain.js'
7
+
8
+ let cur = null // instance under construction: { m: Map name→sig, r: Map name→value to restore | null }
9
+ let quiet = 0 // >0 during hmr: the bus emits `hmr` instead of mount/dispose
10
+ export const live = new Map()
11
+ export const inst = new WeakMap()
12
+ export const bus = new Set() // fn(kind, data): 'mount' | 'dispose' | 'hmr' | 'hydrate'
13
+ // sig → { name, comp } for every named signal (dev codegen `sig(v, 'name')`, store fields): brain-why reads it
14
+ export const names = new WeakMap()
15
+ // last component mounts with their mount time, HMR remounts included (brain_impact: Δ mount time per save)
16
+ export const mounts = []
17
+ const emit = (k, d) => {
18
+ for (const f of bus) f(k, d)
19
+ }
20
+ const { root, owner } = _dev((n, k) => {
21
+ if (!n) return emit('hydrate', { url: k })
22
+ names.set(n, { name: k, comp: (cur && cur.f) || null })
23
+ if (cur) {
24
+ if (cur.r && cur.r.has(k)) n._ = cur.r.get(k)
25
+ cur.m.set(k, n)
26
+ }
27
+ return n
28
+ })
29
+ export { owner }
30
+
31
+ function build(i, restore) {
32
+ const pc = cur
33
+ cur = { m: new Map(), r: restore, f: i.C.f || i.h || null }
34
+ try {
35
+ return root(d => {
36
+ i.r = owner() // register before C(): fx from the first run already have an owner (inspector)
37
+ inst.set(i.r, i)
38
+ const t = performance.now()
39
+ const out = i.C(i.$p)
40
+ i.$mt = performance.now() - t
41
+ mounts.unshift({ comp: i.C.f || i.h || null, ms: Math.round(i.$mt * 100) / 100, at: Date.now(), hmr: quiet > 0 })
42
+ if (mounts.length > 50) mounts.length = 50
43
+ // a component may return a node, a fragment or an array (guard() returns its nodes plus an anchor)
44
+ i.n = Array.isArray(out) ? out.flat(Infinity) : out.nodeType === 11 ? [...out.childNodes] : [out]
45
+ for (const x of i.n) if (x.nodeType === 1) x.$i = i
46
+ i.d = d
47
+ i.m = cur.m
48
+ const set = live.get(i.h) || live.set(i.h, new Set()).get(i.h)
49
+ set.add(i)
50
+ // on dispose the nodes stop pointing at the instance: a browser may keep a detached node alive for a while
51
+ // (last hovered or focused element, accessibility tree) and must not keep the whole instance with it
52
+ fx(() => () => {
53
+ set.delete(i)
54
+ for (const x of i.n || []) if (x.nodeType === 1 && x.$i === i) x.$i = null
55
+ quiet || emit('dispose', i)
56
+ })
57
+ if (!quiet) emit('mount', i)
58
+ return out
59
+ }, i.p)
60
+ } finally {
61
+ cur = pc
62
+ }
63
+ }
64
+
65
+ export function dev(C) {
66
+ const W = $p => build({ C, $p, h: C.h, p: owner(), n: null, d: null, m: null, r: null }, null)
67
+ Object.assign(W, C) // h, p, form, seo, head… whatever the compiler (or guard) put on it
68
+ W.C = C
69
+ return W
70
+ }
71
+
72
+ // Remounts in place every live instance of `hash` with the new component from `mod`.
73
+ export function hmr(hash, mod) {
74
+ const W = Object.values(mod).find(c => c && c.h === hash)
75
+ if (!W) return 0
76
+ const C = W.C || W
77
+ let k = 0
78
+ quiet++
79
+ try {
80
+ for (const i of [...(live.get(hash) || [])]) {
81
+ const old = i.n, pa = old[0].parentNode
82
+ // an anchor that survives the dispose (guard() removes its own nodes when its owner is disposed)
83
+ const first = document.createComment('')
84
+ if (pa) pa.insertBefore(first, old[0])
85
+ const vals = new Map([...i.m].map(([name, n]) => [name, n._]))
86
+ i.d()
87
+ emit('dispose', { h: i.h, C: i.C, n: old, r: null, hmr: 1 })
88
+ i.C = C
89
+ build(i, vals)
90
+ if (pa) for (const x of i.n) pa.insertBefore(x, first)
91
+ for (const x of old) x.remove()
92
+ first.remove()
93
+ k++
94
+ }
95
+ } finally {
96
+ quiet--
97
+ }
98
+ emit('hmr', { h: hash, n: k })
99
+ return k
100
+ }