@christianirack/brainllm-runtime 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +4 -2
- package/brain-anim.js +21 -0
- package/brain-ch.js +254 -0
- package/brain-consent.js +38 -0
- package/brain-data.js +547 -0
- package/brain-dev.js +100 -0
- package/brain-env.js +160 -0
- package/brain-err.js +242 -0
- package/brain-form.js +260 -0
- package/brain-guard.js +242 -0
- package/brain-hydrate.js +72 -0
- package/brain-i18n.js +111 -0
- package/brain-inspect.js +745 -0
- package/brain-island.js +106 -0
- package/brain-lazy.js +88 -0
- package/brain-mcp.js +58 -0
- package/brain-mock.js +80 -0
- package/brain-overlay.js +123 -0
- package/brain-persist.js +65 -0
- package/brain-router.js +353 -0
- package/brain-rpc.js +17 -0
- package/brain-schema.js +69 -0
- package/brain-wc.js +69 -0
- package/brain-why.js +198 -0
- package/brain-worker.js +66 -0
- package/brain.js +421 -0
- package/package.json +20 -4
- package/server/server.mjs +882 -0
- package/shim/dom.js +448 -0
- package/shim/probe.js +111 -0
- package/sw/brain-sw.js +103 -0
- package/themes/contrast.cssbrain +39 -0
- package/themes/dark.cssbrain +39 -0
- package/themes/glass.cssbrain +39 -0
- package/themes/paper.cssbrain +39 -0
- package/ui/button.brain +31 -0
- package/ui/center.brain +11 -0
- package/ui/checkbox.brain +13 -0
- package/ui/container.brain +11 -0
- package/ui/dialog.brain +29 -0
- package/ui/error-state.brain +53 -0
- package/ui/field.brain +34 -0
- package/ui/grid.brain +11 -0
- package/ui/input.brain +23 -0
- package/ui/lang-switch.brain +38 -0
- package/ui/menu.brain +64 -0
- package/ui/row.brain +12 -0
- package/ui/select.brain +29 -0
- package/ui/sidebar.brain +17 -0
- package/ui/spacer.brain +12 -0
- package/ui/stack.brain +10 -0
- package/ui/table.brain +73 -0
- package/ui/tabs.brain +33 -0
- package/ui/toast.brain +45 -0
package/brain-data.js
ADDED
|
@@ -0,0 +1,547 @@
|
|
|
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
|
+
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 } }))
|
|
359
|
+
}
|
|
360
|
+
let j
|
|
361
|
+
try {
|
|
362
|
+
j = text ? JSON.parse(text, reviver) : null
|
|
363
|
+
} catch {
|
|
364
|
+
j = null
|
|
365
|
+
}
|
|
366
|
+
if (st >= 200 && st < 300) return { value: j, cacheable: !/no-store|private/i.test(h('cache-control') || '') }
|
|
367
|
+
const ra = h('retry-after'), retryAfter = ra == null ? undefined : isNaN(ra) ? Math.max(0, (Date.parse(ra) - Date.now()) / 1e3) : +ra
|
|
368
|
+
if (st === 401 && P.login && G.location && !location.pathname.endsWith(P.login)) location.assign(appBase() + P.login + '?next=' + encodeURIComponent(location.pathname + location.search))
|
|
369
|
+
throw new ApiError('http', (j && j.error) || `${M} ${u}: ${st}`, { status: st, errors: j && j.errors, retryAfter, id: h('x-request-id') || undefined })
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// multipart upload with progress (fetch has no upload progress)
|
|
373
|
+
function xhr(url, M, body, headers, sg, up) {
|
|
374
|
+
return new Promise((ok, no) => {
|
|
375
|
+
const x = new XMLHttpRequest()
|
|
376
|
+
x.open(M, url)
|
|
377
|
+
x.withCredentials = false
|
|
378
|
+
for (const k in headers) if (k !== 'content-type') x.setRequestHeader(k, headers[k])
|
|
379
|
+
if (x.upload) x.upload.onprogress = e => e.lengthComputable && (up.v = e.loaded / e.total)
|
|
380
|
+
x.onload = () => ((up.v = 1), ok({ st: x.status, text: x.responseText || '', h: k => x.getResponseHeader(k) }))
|
|
381
|
+
x.onerror = x.ontimeout = () => no(new TypeError('upload failed'))
|
|
382
|
+
x.onabort = () => no(new DOMException('aborted', 'AbortError'))
|
|
383
|
+
sg.addEventListener('abort', () => x.abort(), { once: true })
|
|
384
|
+
x.send(body)
|
|
385
|
+
})
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Shape check with form()'s mini-language: 'num!' · '[str]' · { field: rule | schema } · [Schema] (list of objects).
|
|
389
|
+
// Returns the first difference as text, or ''. Names the compiler could not resolve ('User') are not checked.
|
|
390
|
+
function diff(s, v, at = '') {
|
|
391
|
+
if (Array.isArray(s)) {
|
|
392
|
+
if (!Array.isArray(v)) return `${at || 'value'}: expected a list`
|
|
393
|
+
for (let i = 0; i < v.length; i++) {
|
|
394
|
+
const d = diff(s[0], v[i], `${at}[${i}]`)
|
|
395
|
+
if (d) return d
|
|
396
|
+
}
|
|
397
|
+
return ''
|
|
398
|
+
}
|
|
399
|
+
if (s && typeof s === 'object') {
|
|
400
|
+
if (!v || typeof v !== 'object' || Array.isArray(v)) return `${at || 'value'}: expected an object`
|
|
401
|
+
for (const k of Object.keys(s)) {
|
|
402
|
+
const d = diff(s[k], v[k], at ? `${at}.${k}` : k)
|
|
403
|
+
if (d) return d
|
|
404
|
+
}
|
|
405
|
+
return ''
|
|
406
|
+
}
|
|
407
|
+
let r
|
|
408
|
+
try {
|
|
409
|
+
r = rule(s)
|
|
410
|
+
const m = r.t === 'obj' ? '' : check(r, v)
|
|
411
|
+
return m ? `${at || 'value'}: ${m}` : ''
|
|
412
|
+
} catch {
|
|
413
|
+
return ''
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const CACHE = 'brain:data' // not `brain-…`: those are per-build caches the service worker rotates
|
|
418
|
+
let storageTail = Promise.resolve(), storagePending = 0, storageBlocked = false
|
|
419
|
+
const storageTask = work => {
|
|
420
|
+
if (storageBlocked) return Promise.resolve()
|
|
421
|
+
if (storagePending >= 128) {
|
|
422
|
+
// Coalesce overload into one full invalidation after queued writes. Never drop an invalidation and retain
|
|
423
|
+
// data that a successful mutation made stale. Readers stay disabled until cleanup succeeds.
|
|
424
|
+
storageBlocked = true
|
|
425
|
+
storageTail = storageTail.then(async () => { try { if (G.caches) await G.caches.delete(CACHE); storageBlocked = false } catch {} })
|
|
426
|
+
return Promise.resolve()
|
|
427
|
+
}
|
|
428
|
+
storagePending++
|
|
429
|
+
const task = storageTail.then(work, work)
|
|
430
|
+
storageTail = task.catch(() => {}).finally(() => storagePending--)
|
|
431
|
+
return task
|
|
432
|
+
}
|
|
433
|
+
function save(u, j) { return storageTask(() => saveNow(u, j)) }
|
|
434
|
+
async function saveNow(u, j) {
|
|
435
|
+
try {
|
|
436
|
+
if (!G.caches) return
|
|
437
|
+
const c = await G.caches.open(CACHE)
|
|
438
|
+
const text = JSON.stringify(j)
|
|
439
|
+
if (new TextEncoder().encode(text).length > MEM_ENTRY) return
|
|
440
|
+
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()) } }))
|
|
441
|
+
const keys = await c.keys()
|
|
442
|
+
for (let i = 0; i < keys.length - 128; i++) await c.delete(keys[i])
|
|
443
|
+
} catch {}
|
|
444
|
+
}
|
|
445
|
+
function dropStored(u, prefix = false) { return storageTask(() => dropStoredNow(u, prefix)) }
|
|
446
|
+
async function dropStoredNow(u, prefix = false) {
|
|
447
|
+
try {
|
|
448
|
+
if (!G.caches) return
|
|
449
|
+
const c = await G.caches.open(CACHE), url = new URL(u, G.location && location.href).href
|
|
450
|
+
if (!prefix) await c.delete(url)
|
|
451
|
+
else for (const key of await c.keys()) if (key.url.startsWith(url)) await c.delete(key)
|
|
452
|
+
} catch {}
|
|
453
|
+
}
|
|
454
|
+
async function load(u, keep) {
|
|
455
|
+
try {
|
|
456
|
+
if (!G.caches || storageBlocked) return undefined
|
|
457
|
+
await storageTail
|
|
458
|
+
const r = await (await G.caches.open(CACHE)).match(new URL(u, G.location && location.href).href)
|
|
459
|
+
if (!r) return undefined
|
|
460
|
+
const stamp = Number(r.headers.get('x-brain-t')), now = Date.now()
|
|
461
|
+
if (!Number.isFinite(stamp) || stamp <= 0 || stamp > now + 60000 || now - stamp > keep) { await dropStored(u); return undefined }
|
|
462
|
+
return JSON.parse(await r.text(), reviver)
|
|
463
|
+
} catch {
|
|
464
|
+
return undefined
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// `resource(fn, { page, every })`: a read-only node whose value is a Promise, for `{#await x}`. Signals read inside fn
|
|
469
|
+
// make it refetch (so does a write to the same path, or `r.refetch()`); the previous request is aborted and its answer
|
|
470
|
+
// ignored. A cached answer is shown at once and replaced only if the network brings something different; a network
|
|
471
|
+
// error keeps the cached answer on screen.
|
|
472
|
+
// `page: true` → fn is called with `{ page: 1 }`, `r.more()` appends the next page, `r.done` turns true on a short or
|
|
473
|
+
// empty page. `every: '30s'` refetches on an interval, paused while the tab is hidden or offline.
|
|
474
|
+
// a cancelled run is not an error to show: {#await} keeps waiting for the run that replaced it
|
|
475
|
+
const quiet = p => p.catch(e => (e && e.kind === 'aborted' ? new Promise(() => {}) : Promise.reject(e)))
|
|
476
|
+
export function resource(f, o = {}) {
|
|
477
|
+
const n = sig(new Promise(() => {})), force = sig(0), done = sig(false)
|
|
478
|
+
let tok = 0, acc = [], pg = 1, size = 0, loading = false
|
|
479
|
+
const call = (a, c) => {
|
|
480
|
+
CUR = c
|
|
481
|
+
try {
|
|
482
|
+
return f(a)
|
|
483
|
+
} catch (e) {
|
|
484
|
+
return Promise.reject(e)
|
|
485
|
+
} finally {
|
|
486
|
+
CUR = null
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
fx(() => {
|
|
490
|
+
force.v // refetch()
|
|
491
|
+
const t = ++tok, c = new AbortController()
|
|
492
|
+
let p = call(o.page ? { page: (pg = 1) } : undefined, c.signal)
|
|
493
|
+
if (o.page) {
|
|
494
|
+
untrack(() => (done.v = false))
|
|
495
|
+
p = Promise.resolve(p).then(l => ((acc = l || []), (size = acc.length), t === tok && untrack(() => (done.v = !size)), acc))
|
|
496
|
+
}
|
|
497
|
+
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b)
|
|
498
|
+
const later = (old, q) => q.then(v => t === tok && !same(v, old) && (n.v = Promise.resolve(v)), () => {})
|
|
499
|
+
if (p && p.$stale !== undefined) (n.v = Promise.resolve(p.$stale)), later(p.$stale, p)
|
|
500
|
+
else if (p && p.$stored) n.v = p.$stored.then(s => (s === undefined ? quiet(p) : (later(s, p), s)))
|
|
501
|
+
else n.v = quiet(Promise.resolve(p))
|
|
502
|
+
return () => c.abort() // re-run or unmount: the request is cancelled
|
|
503
|
+
})
|
|
504
|
+
n.refetch = () => force.v++
|
|
505
|
+
n.more = () => {
|
|
506
|
+
if (!o.page || loading || untrack(() => done.v)) return Promise.resolve()
|
|
507
|
+
const t = tok
|
|
508
|
+
loading = true
|
|
509
|
+
return Promise.resolve(untrack(() => call({ page: pg + 1 }, null)))
|
|
510
|
+
.then(l => {
|
|
511
|
+
if (t !== tok) return
|
|
512
|
+
l = l || []
|
|
513
|
+
pg++
|
|
514
|
+
if (!l.length || l.length < size) done.v = true
|
|
515
|
+
n.v = Promise.resolve((acc = [...acc, ...l]))
|
|
516
|
+
})
|
|
517
|
+
.finally(() => (loading = false))
|
|
518
|
+
}
|
|
519
|
+
Object.defineProperty(n, 'done', { get: () => done.v })
|
|
520
|
+
if (o.every && !(G.__brainSSR || G.__brainRoute)) {
|
|
521
|
+
const id = setInterval(() => ON.v && !(G.document && document.hidden) && force.v++, ttl(o.every) || +o.every)
|
|
522
|
+
fx(() => () => clearInterval(id))
|
|
523
|
+
}
|
|
524
|
+
return n
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// `let feed = stream('/events')` (§41.4): Server-Sent Events as a read-only node. `.v` is the last message (JSON when
|
|
528
|
+
// it parses, else the text; `{ reduce: (acc, msg) => … , initial }` accumulates instead), `.connected` follows the
|
|
529
|
+
// connection. EventSource reconnects by itself; the stream closes with the component. Nothing while the server renders.
|
|
530
|
+
export function stream(path, o = {}) {
|
|
531
|
+
const n = sig(o.initial ?? null), up = sig(false), out = { get v() { return n.v }, get connected() { return up.v } }
|
|
532
|
+
if (!G.EventSource || G.__brainSSR || G.__brainRoute) return out
|
|
533
|
+
const es = new EventSource(full(path))
|
|
534
|
+
es.onopen = () => (up.v = true)
|
|
535
|
+
es.onerror = () => (up.v = false)
|
|
536
|
+
es.addEventListener(o.event || 'message', e => {
|
|
537
|
+
let m
|
|
538
|
+
try {
|
|
539
|
+
m = JSON.parse(e.data, reviver)
|
|
540
|
+
} catch {
|
|
541
|
+
m = e.data
|
|
542
|
+
}
|
|
543
|
+
n.v = o.reduce ? o.reduce(n.v, m) : m
|
|
544
|
+
})
|
|
545
|
+
fx(() => () => es.close())
|
|
546
|
+
return out
|
|
547
|
+
}
|
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
|
+
}
|