@christianirack/brainllm-runtime 0.5.0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/brain-ai.js +15 -7
- package/brain-analytics.js +401 -0
- package/brain-budget.js +46 -25
- package/brain-ch.js +17 -4
- package/brain-data.js +4 -1
- package/brain-env.js +5 -4
- package/brain-guard.js +6 -6
- package/brain-live.js +6 -3
- package/brain-mcp.js +87 -18
- package/brain-outbound.js +244 -0
- package/brain-router.js +8 -12
- package/brain-update.js +37 -0
- package/brain-url.js +7 -5
- package/brain.js +6 -3
- package/package.json +1 -1
- package/server/server.mjs +310 -85
- package/sw/brain-sw.js +51 -2
- package/ui/error-state.brain +26 -13
- package/ui/tabs.brain +4 -3
package/brain-ai.js
CHANGED
|
@@ -55,16 +55,24 @@ async function create(kind, o = {}) {
|
|
|
55
55
|
return N.create(rest)
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
// one base session per system prompt; each stateless call works on a clone, so calls never share history
|
|
59
|
-
|
|
58
|
+
// one base session per system prompt; each stateless call works on a clone, so calls never share history. A system
|
|
59
|
+
// prompt built from data (a name, a date) would open a native session per value: keep the 8 most recent, destroy the rest.
|
|
60
|
+
const bases = new Map(), BASES = 8
|
|
60
61
|
async function base(o) {
|
|
61
62
|
const k = o.system || ''
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
63
|
+
let p = bases.get(k)
|
|
64
|
+
if (p) bases.delete(k)
|
|
65
|
+
else {
|
|
66
|
+
p = create('prompt', { ...(o.system ? { initialPrompts: [{ role: 'system', content: o.system }] } : {}), ...(o.onProgress ? { onProgress: o.onProgress } : {}) })
|
|
67
|
+
p.catch(() => bases.get(k) === p && bases.delete(k))
|
|
68
|
+
if (bases.size >= BASES) {
|
|
69
|
+
const [old, q] = bases.entries().next().value
|
|
70
|
+
bases.delete(old)
|
|
71
|
+
q.then(s => s.destroy && s.destroy(), () => {})
|
|
72
|
+
}
|
|
66
73
|
}
|
|
67
|
-
|
|
74
|
+
bases.set(k, p)
|
|
75
|
+
return (await p).clone()
|
|
68
76
|
}
|
|
69
77
|
const opts = o => ({ ...(o.signal ? { signal: o.signal } : {}), ...(o.schema ? { responseConstraint: o.schema } : {}) })
|
|
70
78
|
|
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
// BRAINLLM behavior analytics collector (docs/backend/analytics.md). First-party and off the critical path: the native
|
|
2
|
+
// backend serves this module at `/_brain/analytics.js` and the server adds it to HTML pages as an async module, so it
|
|
3
|
+
// never counts toward a route's budget. With no backend nothing loads and nothing is recorded.
|
|
4
|
+
//
|
|
5
|
+
// One delegated, capture-phase listener per action type on the document records what every element receives
|
|
6
|
+
// (click, keyboard activation, focus, input, change, toggles, submit, copy/cut/paste, media, drag/drop, context menu,
|
|
7
|
+
// hover intent), plus page views, scroll depth, dwell and SPA navigations. Each event carries a stable descriptor of the
|
|
8
|
+
// element: `c` the component hash (`data-b`, the same id the compiler gives every component root), `t` tag, `i` ordinal
|
|
9
|
+
// among same-tag elements of that component, `n` accessible name, `x` data-track, `o` role, `y` type, `f` field or form
|
|
10
|
+
// name, `h` link route. `brain analytics report` maps `c` + `t` + `i`/`n` back to `file:line` of the .brain source.
|
|
11
|
+
//
|
|
12
|
+
// Never recorded: field values, typed keys, copied text, IP addresses. Password, card and one-time-code fields are only
|
|
13
|
+
// counted. Global Privacy Control, Do Not Track and automated browsers turn the collector off; `consent: "required"`
|
|
14
|
+
// waits for consent('analytics') from brain-consent.js.
|
|
15
|
+
//
|
|
16
|
+
// Custom events from app code, with no import:
|
|
17
|
+
// dispatchEvent(new CustomEvent('brain:track', { detail: { name: 'add_to_cart', props: { sku: 'A1' } } }))
|
|
18
|
+
// or declaratively: <button data-track="add_to_cart">.
|
|
19
|
+
const VISITOR = 'brain:vid'
|
|
20
|
+
const SESSION = 'brain:sid'
|
|
21
|
+
const IDLE_MS = 30 * 60 * 1000
|
|
22
|
+
const FLUSH_MS = 5000
|
|
23
|
+
const BATCH = 64
|
|
24
|
+
const HOVER_MS = 500
|
|
25
|
+
const RAGE = 3
|
|
26
|
+
const RAGE_MS = 1000
|
|
27
|
+
const DEAD_MS = 1000
|
|
28
|
+
const TAGS = /^(A|BUTTON|INPUT|SELECT|TEXTAREA|SUMMARY|DETAILS|LABEL|OPTION|VIDEO|AUDIO|FORM|IFRAME)$/
|
|
29
|
+
const ROLES =
|
|
30
|
+
/^(button|link|tab|menuitem|menuitemcheckbox|menuitemradio|checkbox|radio|switch|option|combobox|slider|textbox|searchbox|treeitem|gridcell)$/
|
|
31
|
+
const FIELDS = /^(INPUT|SELECT|TEXTAREA|LABEL|OPTION|VIDEO|AUDIO|IFRAME|FORM|DETAILS)$/
|
|
32
|
+
const SECRET = /pass|card|cvc|cvv|ccv|iban|ssn|secret|token|otp|pin$/i
|
|
33
|
+
const TOKEN = /^[\w.:-]{1,64}$/
|
|
34
|
+
|
|
35
|
+
export function start(config = {}, env = globalThis) {
|
|
36
|
+
const doc = env.document
|
|
37
|
+
const win = env.window || env
|
|
38
|
+
const nav = env.navigator || {}
|
|
39
|
+
const loc = () => env.location || { pathname: '/', search: '', href: '/', origin: '' }
|
|
40
|
+
const store = env.localStorage
|
|
41
|
+
const now = () => (env.performance ? env.performance.now() : Date.now())
|
|
42
|
+
if (!doc || config.enabled === false) return null
|
|
43
|
+
if (nav.globalPrivacyControl === true || nav.doNotTrack === '1' || win.doNotTrack === '1' || nav.webdriver) return null
|
|
44
|
+
const endpoint = config.endpoint || (config.base || '/') + '_brain/analytics'
|
|
45
|
+
const consented = () => {
|
|
46
|
+
if (config.consent !== 'required') return true
|
|
47
|
+
try {
|
|
48
|
+
return !!(JSON.parse(store.getItem('brain:consent')) || {}).analytics
|
|
49
|
+
} catch {
|
|
50
|
+
return false
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const get = k => {
|
|
54
|
+
try {
|
|
55
|
+
return JSON.parse(store.getItem(k))
|
|
56
|
+
} catch {
|
|
57
|
+
return null
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const put = (k, v) => {
|
|
61
|
+
try {
|
|
62
|
+
store.setItem(k, JSON.stringify(v))
|
|
63
|
+
} catch {}
|
|
64
|
+
}
|
|
65
|
+
const uuid = () =>
|
|
66
|
+
env.crypto && env.crypto.randomUUID
|
|
67
|
+
? env.crypto.randomUUID()
|
|
68
|
+
: '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, c =>
|
|
69
|
+
(c ^ ((Math.random() * 16) >> (c / 4))).toString(16),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
// ── identity: anonymous first-party visitor (localStorage) and a session that ends after 30 idle minutes
|
|
73
|
+
let session = null
|
|
74
|
+
const ids = () => {
|
|
75
|
+
const t = Date.now()
|
|
76
|
+
let vid = get(VISITOR)
|
|
77
|
+
if (typeof vid !== 'string') put(VISITOR, (vid = uuid()))
|
|
78
|
+
if (!session) session = get(SESSION)
|
|
79
|
+
if (!session || typeof session.id !== 'string' || t - session.last > IDLE_MS) session = { id: uuid(), last: t, pv: 0, ctx: 0 }
|
|
80
|
+
session.last = t
|
|
81
|
+
put(SESSION, session)
|
|
82
|
+
return vid
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── element descriptor
|
|
86
|
+
const attr = (n, k) => (n && n.getAttribute && n.getAttribute(k)) || ''
|
|
87
|
+
const actionable = el => {
|
|
88
|
+
for (let n = el; n && n.nodeType === 1; n = n.parentNode)
|
|
89
|
+
if (TAGS.test(n.tagName) || ROLES.test(attr(n, 'role')) || n.hasAttribute('data-track') || n.hasAttribute('tabindex')) return n
|
|
90
|
+
return null
|
|
91
|
+
}
|
|
92
|
+
const secret = el =>
|
|
93
|
+
el.tagName !== 'A' &&
|
|
94
|
+
(attr(el, 'type') === 'password' ||
|
|
95
|
+
/^(cc-|one-time-code)/.test(attr(el, 'autocomplete')) ||
|
|
96
|
+
SECRET.test(attr(el, 'name') + ' ' + attr(el, 'id')) ||
|
|
97
|
+
el.hasAttribute('data-private'))
|
|
98
|
+
const text = s => String(s || '').replace(/\s+/g, ' ').trim().slice(0, 64)
|
|
99
|
+
const accessibleName = el => {
|
|
100
|
+
const label = attr(el, 'aria-label')
|
|
101
|
+
if (label) return text(label)
|
|
102
|
+
const by = attr(el, 'aria-labelledby')
|
|
103
|
+
if (by) {
|
|
104
|
+
const n = doc.getElementById(by.split(/\s+/)[0])
|
|
105
|
+
if (n) return text(n.textContent)
|
|
106
|
+
}
|
|
107
|
+
if (/^(INPUT|SELECT|TEXTAREA)$/.test(el.tagName)) {
|
|
108
|
+
const id = attr(el, 'id')
|
|
109
|
+
const l = id && doc.querySelector && doc.querySelector(`label[for=${id}]`)
|
|
110
|
+
return text((l && l.textContent) || attr(el, 'placeholder') || attr(el, 'title'))
|
|
111
|
+
}
|
|
112
|
+
return text(attr(el, 'title') || attr(el, 'alt') || el.textContent)
|
|
113
|
+
}
|
|
114
|
+
const component = el => {
|
|
115
|
+
for (let n = el; n && n.nodeType === 1; n = n.parentNode) if (n.hasAttribute('data-b')) return n
|
|
116
|
+
return null
|
|
117
|
+
}
|
|
118
|
+
// Ordinal among same-tag elements of the component, in document order, skipping nested components.
|
|
119
|
+
const ordinal = (root, el) => {
|
|
120
|
+
const hash = attr(root, 'data-b')
|
|
121
|
+
let i = 0
|
|
122
|
+
let found = -1
|
|
123
|
+
const walk = n => {
|
|
124
|
+
for (const c of n.childNodes || []) {
|
|
125
|
+
if (found >= 0 || c.nodeType !== 1) continue
|
|
126
|
+
if (c.hasAttribute('data-b') && attr(c, 'data-b') !== hash) continue
|
|
127
|
+
if (c === el) return void (found = i)
|
|
128
|
+
if (c.tagName === el.tagName) i++
|
|
129
|
+
walk(c)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const roots = root.parentNode ? [...root.parentNode.childNodes].filter(c => c.nodeType === 1 && attr(c, 'data-b') === hash) : [root]
|
|
133
|
+
for (const r of roots) {
|
|
134
|
+
if (found >= 0) break
|
|
135
|
+
if (r === el) found = i
|
|
136
|
+
else {
|
|
137
|
+
if (r.tagName === el.tagName) i++
|
|
138
|
+
walk(r)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return found
|
|
142
|
+
}
|
|
143
|
+
const route = href => {
|
|
144
|
+
try {
|
|
145
|
+
const u = new URL(href, loc().href)
|
|
146
|
+
return u.origin === loc().origin ? u.pathname : null
|
|
147
|
+
} catch {
|
|
148
|
+
return null
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const describe = el => {
|
|
152
|
+
if (!el || el.nodeType !== 1) return null
|
|
153
|
+
const d = { t: el.tagName.toLowerCase() }
|
|
154
|
+
const root = component(el)
|
|
155
|
+
if (root) {
|
|
156
|
+
d.c = attr(root, 'data-b')
|
|
157
|
+
const i = ordinal(root, el)
|
|
158
|
+
if (i >= 0 && i < 10000) d.i = i
|
|
159
|
+
}
|
|
160
|
+
const track = attr(el, 'data-track')
|
|
161
|
+
if (TOKEN.test(track)) d.x = track
|
|
162
|
+
const role = attr(el, 'role')
|
|
163
|
+
if (TOKEN.test(role)) d.o = role
|
|
164
|
+
if (/^(INPUT|BUTTON)$/.test(el.tagName) && TOKEN.test(attr(el, 'type'))) d.y = attr(el, 'type')
|
|
165
|
+
if (secret(el)) return (d.y = 'secret'), d // counted, never named
|
|
166
|
+
const field = attr(el, 'name') || (/^(INPUT|SELECT|TEXTAREA|FORM)$/.test(el.tagName) ? attr(el, 'id') : '')
|
|
167
|
+
if (TOKEN.test(field)) d.f = field
|
|
168
|
+
if (el.tagName === 'A') {
|
|
169
|
+
const h = route(attr(el, 'href'))
|
|
170
|
+
if (h) d.h = h
|
|
171
|
+
}
|
|
172
|
+
if (el.tagName === 'FORM') {
|
|
173
|
+
const h = route(attr(el, 'action') || loc().href)
|
|
174
|
+
if (h) d.h = h
|
|
175
|
+
} else if (actionable(el) === el) {
|
|
176
|
+
const n = accessibleName(el) // only controls are named: the text of arbitrary content is never read
|
|
177
|
+
if (n) d.n = n
|
|
178
|
+
}
|
|
179
|
+
return d
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ── queue and transport
|
|
183
|
+
let queue = []
|
|
184
|
+
let timer = null
|
|
185
|
+
let pv = 0
|
|
186
|
+
let pageAt = now()
|
|
187
|
+
let visibleMs = 0
|
|
188
|
+
let visibleAt = now()
|
|
189
|
+
let maxScroll = 0
|
|
190
|
+
let path = loc().pathname
|
|
191
|
+
let stopped = false
|
|
192
|
+
const send = () => {
|
|
193
|
+
clearTimeout(timer)
|
|
194
|
+
timer = null
|
|
195
|
+
if (!queue.length) return
|
|
196
|
+
const vid = ids()
|
|
197
|
+
while (queue.length) {
|
|
198
|
+
const body = { v: 1, vid, sid: session.id, ev: queue.splice(0, BATCH) }
|
|
199
|
+
if (!session.ctx) {
|
|
200
|
+
const q = new URLSearchParams(loc().search || '')
|
|
201
|
+
const utm = {}
|
|
202
|
+
for (const k of ['source', 'medium', 'campaign', 'term', 'content']) if (q.get('utm_' + k)) utm[k] = q.get('utm_' + k).slice(0, 128)
|
|
203
|
+
body.ctx = { ref: doc.referrer || undefined, utm, lang: nav.language, w: win.innerWidth | 0 }
|
|
204
|
+
session.ctx = 1
|
|
205
|
+
put(SESSION, session)
|
|
206
|
+
}
|
|
207
|
+
const json = JSON.stringify(body)
|
|
208
|
+
let ok = false
|
|
209
|
+
try {
|
|
210
|
+
ok = !!(nav.sendBeacon && nav.sendBeacon(endpoint, json))
|
|
211
|
+
} catch {}
|
|
212
|
+
if (!ok && env.fetch)
|
|
213
|
+
env
|
|
214
|
+
.fetch(endpoint, { method: 'POST', body: json, keepalive: true, credentials: 'same-origin', headers: { 'content-type': 'text/plain' } })
|
|
215
|
+
.catch(() => {})
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
const record = (k, el, v, p) => {
|
|
219
|
+
if (stopped || !consented()) return
|
|
220
|
+
if (!pv && k !== 'page') page() // consent arrived after load: open the page view first
|
|
221
|
+
const e = { k, r: path, pv, t: Math.max(0, Math.round(now() - pageAt)), at: Date.now() }
|
|
222
|
+
const d = el ? describe(el) : null
|
|
223
|
+
if (d) e.el = d
|
|
224
|
+
if (v != null && isFinite(v)) e.v = Math.round(v)
|
|
225
|
+
if (p) e.p = p
|
|
226
|
+
queue.push(e)
|
|
227
|
+
if (queue.length >= BATCH / 2) send()
|
|
228
|
+
else if (!timer) timer = setTimeout(send, FLUSH_MS)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ── page views, dwell, scroll
|
|
232
|
+
const visible = () => doc.visibilityState !== 'hidden'
|
|
233
|
+
const dwell = () => visibleMs + (visible() ? now() - visibleAt : 0)
|
|
234
|
+
const page = () => {
|
|
235
|
+
if (!consented()) return
|
|
236
|
+
ids()
|
|
237
|
+
pv = ++session.pv
|
|
238
|
+
put(SESSION, session)
|
|
239
|
+
pageAt = visibleAt = now()
|
|
240
|
+
visibleMs = maxScroll = 0
|
|
241
|
+
record('page', null)
|
|
242
|
+
}
|
|
243
|
+
const leave = () => {
|
|
244
|
+
flushInputs()
|
|
245
|
+
if (pv) record('leave', null, dwell(), { scroll: Math.round(maxScroll) })
|
|
246
|
+
}
|
|
247
|
+
const navigated = () => {
|
|
248
|
+
const p = loc().pathname
|
|
249
|
+
if (p === path) return
|
|
250
|
+
leave()
|
|
251
|
+
path = p
|
|
252
|
+
page()
|
|
253
|
+
}
|
|
254
|
+
const scrolled = () => {
|
|
255
|
+
const el = doc.documentElement || {}
|
|
256
|
+
const h = (el.scrollHeight || 0) - (win.innerHeight || 0)
|
|
257
|
+
const pct = h > 0 ? (100 * (win.scrollY || el.scrollTop || 0)) / h : 100
|
|
258
|
+
if (pct > maxScroll) maxScroll = Math.min(100, pct)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ── interactions
|
|
262
|
+
const off = []
|
|
263
|
+
const listen = (t, f, target = doc) => {
|
|
264
|
+
if (!target.addEventListener) return
|
|
265
|
+
const o = { capture: true, passive: true }
|
|
266
|
+
target.addEventListener(t, f, o)
|
|
267
|
+
off.push(() => target.removeEventListener(t, f, o))
|
|
268
|
+
}
|
|
269
|
+
const clicks = []
|
|
270
|
+
let mutations = 0
|
|
271
|
+
const Observer = env.MutationObserver
|
|
272
|
+
const mo = Observer && new Observer(r => (mutations += r.length))
|
|
273
|
+
if (mo) mo.observe(doc, { subtree: true, childList: true, attributes: true, characterData: true })
|
|
274
|
+
const changes = () => (mutations += mo && mo.takeRecords ? mo.takeRecords().length : 0)
|
|
275
|
+
listen('click', e => {
|
|
276
|
+
const el = actionable(e.target) || e.target
|
|
277
|
+
const touch = e.pointerType === 'touch' || e.pointerType === 'pen'
|
|
278
|
+
record('click', el, null, touch ? { tap: 1 } : undefined)
|
|
279
|
+
const t = now()
|
|
280
|
+
while (clicks.length && t - clicks[0].t > RAGE_MS) clicks.shift()
|
|
281
|
+
clicks.push({ el, t })
|
|
282
|
+
const same = clicks.filter(c => c.el === el).length
|
|
283
|
+
if (same === RAGE) record('rage', el, same)
|
|
284
|
+
const plainLink = el.tagName === 'A' && !el.hasAttribute('target') && !el.hasAttribute('download') && route(attr(el, 'href'))
|
|
285
|
+
// dead click: a control that changed nothing (no DOM mutation, no navigation, no focus move) within a second
|
|
286
|
+
if (actionable(e.target) && !FIELDS.test(el.tagName) && (el.tagName !== 'A' || plainLink)) {
|
|
287
|
+
const seen = changes()
|
|
288
|
+
const href = loc().href
|
|
289
|
+
const active = doc.activeElement
|
|
290
|
+
setTimeout(() => {
|
|
291
|
+
if (changes() === seen && loc().href === href && doc.activeElement === active) record('dead', el)
|
|
292
|
+
}, DEAD_MS)
|
|
293
|
+
}
|
|
294
|
+
})
|
|
295
|
+
listen('keydown', e => {
|
|
296
|
+
const el = actionable(e.target)
|
|
297
|
+
if (!el || (e.key !== 'Enter' && e.key !== ' ')) return
|
|
298
|
+
const textual = el.tagName === 'TEXTAREA' || (el.tagName === 'INPUT' && !/^(checkbox|radio|button|submit|reset)$/.test(attr(el, 'type')))
|
|
299
|
+
if (!textual) record('key', el, null, { key: e.key === ' ' ? 'space' : 'enter' })
|
|
300
|
+
})
|
|
301
|
+
const focused = new Map()
|
|
302
|
+
const typed = new Map()
|
|
303
|
+
const flushInputs = () => {
|
|
304
|
+
for (const [el, n] of typed) record('input', el, n)
|
|
305
|
+
typed.clear()
|
|
306
|
+
}
|
|
307
|
+
listen('focusin', e => {
|
|
308
|
+
const el = actionable(e.target)
|
|
309
|
+
if (el) focused.set(el, now())
|
|
310
|
+
})
|
|
311
|
+
listen('focusout', e => {
|
|
312
|
+
const el = actionable(e.target)
|
|
313
|
+
if (!el || !focused.has(el)) return
|
|
314
|
+
if (typed.has(el)) record('input', el, typed.get(el)), typed.delete(el)
|
|
315
|
+
record('focus', el, now() - focused.get(el))
|
|
316
|
+
focused.delete(el)
|
|
317
|
+
})
|
|
318
|
+
listen('input', e => {
|
|
319
|
+
const el = actionable(e.target)
|
|
320
|
+
if (el) typed.set(el, (typed.get(el) || 0) + 1) // one coalesced `input` event per field visit, never the value
|
|
321
|
+
})
|
|
322
|
+
listen('change', e => {
|
|
323
|
+
const el = e.target
|
|
324
|
+
if (!el || el.nodeType !== 1) return
|
|
325
|
+
const kind = attr(el, 'type')
|
|
326
|
+
if (el.tagName === 'INPUT' && (kind === 'checkbox' || kind === 'radio')) record('toggle', el, secret(el) ? null : el.checked ? 1 : 0)
|
|
327
|
+
else if (el.tagName === 'SELECT') record('toggle', el, secret(el) ? null : el.selectedIndex)
|
|
328
|
+
else record('change', el)
|
|
329
|
+
})
|
|
330
|
+
listen('toggle', e => {
|
|
331
|
+
if (e.target && e.target.tagName === 'DETAILS') record('toggle', e.target, e.target.hasAttribute('open') ? 1 : 0)
|
|
332
|
+
})
|
|
333
|
+
const invalid = new WeakSet()
|
|
334
|
+
listen('submit', e => record('submit', e.target, null, { o: 'sent' }))
|
|
335
|
+
listen('invalid', e => {
|
|
336
|
+
const form = e.target && e.target.form
|
|
337
|
+
if (!form || invalid.has(form)) return
|
|
338
|
+
invalid.add(form)
|
|
339
|
+
setTimeout(() => invalid.delete(form), RAGE_MS)
|
|
340
|
+
record('submit', form, null, { o: 'invalid' })
|
|
341
|
+
})
|
|
342
|
+
for (const k of ['copy', 'cut', 'paste', 'drop']) listen(k, e => record(k, actionable(e.target) || e.target))
|
|
343
|
+
listen('dragstart', e => record('drag', actionable(e.target) || e.target))
|
|
344
|
+
listen('contextmenu', e => record('context', actionable(e.target) || e.target))
|
|
345
|
+
for (const a of ['play', 'pause', 'seeked', 'ended'])
|
|
346
|
+
listen(a, e => {
|
|
347
|
+
const el = e.target
|
|
348
|
+
if (el && /^(VIDEO|AUDIO)$/.test(el.tagName)) record('media', el, el.currentTime, { a })
|
|
349
|
+
})
|
|
350
|
+
const hovered = new WeakSet()
|
|
351
|
+
let hover = null
|
|
352
|
+
listen('pointerover', e => {
|
|
353
|
+
const el = actionable(e.target)
|
|
354
|
+
if (!el || hovered.has(el) || (hover && hover.el === el)) return
|
|
355
|
+
if (hover) clearTimeout(hover.t)
|
|
356
|
+
hover = { el, t: setTimeout(() => (hovered.add(el), (hover = null), record('hover', el, HOVER_MS)), HOVER_MS) }
|
|
357
|
+
})
|
|
358
|
+
listen('pointerout', e => {
|
|
359
|
+
if (hover && actionable(e.target) === hover.el) clearTimeout(hover.t), (hover = null)
|
|
360
|
+
})
|
|
361
|
+
listen('scroll', scrolled, win)
|
|
362
|
+
|
|
363
|
+
// ── SPA navigation, visibility, page exit
|
|
364
|
+
const history = env.history
|
|
365
|
+
if (history)
|
|
366
|
+
for (const m of ['pushState', 'replaceState']) {
|
|
367
|
+
const orig = history[m]
|
|
368
|
+
if (typeof orig === 'function')
|
|
369
|
+
history[m] = function (...a) {
|
|
370
|
+
const r = orig.apply(this, a)
|
|
371
|
+
navigated()
|
|
372
|
+
return r
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
listen('popstate', navigated, win)
|
|
376
|
+
listen('visibilitychange', () => {
|
|
377
|
+
if (visible()) visibleAt = now()
|
|
378
|
+
else (visibleMs += now() - visibleAt), flushInputs(), send()
|
|
379
|
+
})
|
|
380
|
+
listen('pagehide', () => (leave(), send()), win)
|
|
381
|
+
const track = (name, props) => {
|
|
382
|
+
if (!TOKEN.test(String(name))) return
|
|
383
|
+
const p = { name: String(name) }
|
|
384
|
+
for (const [k, v] of Object.entries(props || {}).slice(0, 15)) if (/^\w{1,32}$/.test(k) && v !== Object(v)) p[k] = v
|
|
385
|
+
record('track', null, null, p)
|
|
386
|
+
}
|
|
387
|
+
listen('brain:track', e => e.detail && track(e.detail.name, e.detail.props), win)
|
|
388
|
+
listen('click', e => {
|
|
389
|
+
const el = actionable(e.target)
|
|
390
|
+
const name = el && attr(el, 'data-track')
|
|
391
|
+
if (TOKEN.test(name)) track(name)
|
|
392
|
+
})
|
|
393
|
+
const stop = () => {
|
|
394
|
+
stopped = true
|
|
395
|
+
clearTimeout(timer)
|
|
396
|
+
for (const f of off) f()
|
|
397
|
+
if (mo) mo.disconnect()
|
|
398
|
+
}
|
|
399
|
+
page()
|
|
400
|
+
return { track, send, stop, queue: () => queue, describe }
|
|
401
|
+
}
|
package/brain-budget.js
CHANGED
|
@@ -1,63 +1,84 @@
|
|
|
1
1
|
// Preventive checkpoints for compiler-owned code. Native/foreign calls cannot be preempted here.
|
|
2
2
|
import { _rt, fx } from './brain.js'
|
|
3
3
|
const I = _rt(), owners = new WeakMap(), active = []
|
|
4
|
-
const LIMITS =
|
|
5
|
-
const now = () =>
|
|
6
|
-
const
|
|
7
|
-
const
|
|
8
|
-
const
|
|
4
|
+
const LIMITS = { steps: 1000000, depth: 128, ms: 200, rows: 100000, signals: 100000, resources: 1024, pending: 32, workerMs: 30000 } // module-private, never mutated
|
|
5
|
+
const now = () => performance.now()
|
|
6
|
+
const str = (v, n) => typeof v == 'string' ? v.slice(0, n) : null, pos = v => v > 0 ? v | 0 : null
|
|
7
|
+
const metaOf = m => ({ file: str(m?.file, 256), component: str(m?.component, 80), line: pos(m?.line), column: pos(m?.column) })
|
|
8
|
+
const limitsOf = options => Object.fromEntries(Object.entries(LIMITS).map(([k, max]) => [k, Math.min(max, options?.[k] >= 1 ? options[k] : max)]))
|
|
9
|
+
const failure = (code, kind, limit, meta) => Object.assign(new Error(`${code}: ${kind} budget exceeded`), { code, kind: 'budget', budget: kind, limit, ...metaOf(meta), fix: code == 'B0910' ? 'Bound the work or use an owned worker.' : 'Bound it; dispose with its owner.' })
|
|
10
|
+
const start = r => { r.running = true; r.started = now() }
|
|
9
11
|
function charge(root) {
|
|
10
|
-
if (root.running) { const t = now(); root.work +=
|
|
12
|
+
if (root.running) { const t = now(); root.work += t - root.started; root.started = t }
|
|
11
13
|
}
|
|
14
|
+
// Elapsed time is wall-clock (browsers expose no CPU clock): a loaded machine inflates it, so an overrun is a
|
|
15
|
+
// diagnostic, not a failure. It is reported once per execution with its source and measured time, without an owner
|
|
16
|
+
// (no error boundary replaces the view), and the page keeps running. Steps and depth are deterministic and always
|
|
17
|
+
// fatal: they stop runaway loops and recursion.
|
|
12
18
|
function enforce(frame, kind, value) {
|
|
13
19
|
const r = frame.root
|
|
14
20
|
if (r.error) throw r.error
|
|
15
|
-
if (value > r.limits[kind])
|
|
21
|
+
if (value > r.limits[kind]) {
|
|
22
|
+
const e = failure('B0910', kind, r.limits[kind], frame.meta)
|
|
23
|
+
if (kind != 'ms') throw (r.error = e)
|
|
24
|
+
r.limits.ms = 1 / 0
|
|
25
|
+
console.warn(e.message += ` at ${e.file}:${e.line}: ${e.measured = value | 0} ms`), I.oops(e, null, 'budget')
|
|
26
|
+
}
|
|
16
27
|
}
|
|
17
28
|
export function budgetOwner() { return I.owner() }
|
|
18
29
|
export function budgetEnter(meta, owner = I.owner(), options) {
|
|
19
|
-
const parent = active
|
|
20
|
-
const root = parent?.root || { limits: limitsOf(options), steps: 0, work: 0, depth: 0
|
|
30
|
+
const parent = active.at(-1)
|
|
31
|
+
const root = parent?.root || { limits: limitsOf(options), steps: 0, work: 0, depth: 0 }
|
|
21
32
|
const f = { root, meta: metaOf(meta), owner, state: 'active' }
|
|
22
33
|
charge(root)
|
|
23
34
|
enforce(f, 'depth', root.depth + 1)
|
|
24
35
|
root.depth++
|
|
25
|
-
if (!root.running)
|
|
36
|
+
if (!root.running) start(root)
|
|
26
37
|
active.push(f)
|
|
27
38
|
return f
|
|
28
39
|
}
|
|
29
40
|
export function budgetStep(frame, site) {
|
|
30
|
-
if (!frame || frame.state
|
|
41
|
+
if (!frame || frame.state != 'active') throw failure('B0910', 'inactive-frame', 0, site)
|
|
31
42
|
if (site) frame.meta = metaOf(site)
|
|
32
43
|
const r = frame.root
|
|
44
|
+
// A synchronous throw inside a paused await expression must not leave the clock stopped.
|
|
45
|
+
if (frame.paused) frame.paused = 0, start(r)
|
|
33
46
|
charge(r)
|
|
34
47
|
enforce(frame, 'steps', ++r.steps)
|
|
35
48
|
enforce(frame, 'ms', r.work)
|
|
36
49
|
}
|
|
37
50
|
function detach(f) {
|
|
38
|
-
if (f.state
|
|
51
|
+
if (f.state != 'active') return
|
|
39
52
|
const index = active.lastIndexOf(f)
|
|
40
53
|
if (index >= 0) active.splice(index, 1)
|
|
41
54
|
const r = f.root
|
|
42
55
|
charge(r)
|
|
43
56
|
r.depth--
|
|
44
|
-
|
|
57
|
+
// Native code continuing a paused await expression is not charged.
|
|
58
|
+
const top = active.at(-1)
|
|
59
|
+
if (top?.root != r || top.paused) r.running = false
|
|
45
60
|
}
|
|
46
61
|
export function budgetLeave(frame) {
|
|
47
|
-
if (!frame || frame.state
|
|
62
|
+
if (!frame || frame.state == 'closed') return
|
|
48
63
|
detach(frame)
|
|
49
64
|
frame.state = 'closed'
|
|
50
65
|
}
|
|
66
|
+
// Generated as `budgetSuspend(frame)(expr)`: the clock stops before `expr` runs, so native work
|
|
67
|
+
// (a fetch's connection setup) is not charged; author functions called inside it restart the clock.
|
|
51
68
|
// The outcome always resolves: the generated resume runs even on a rejected await, preserving catch/finally.
|
|
52
|
-
export function budgetSuspend(frame
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
69
|
+
export function budgetSuspend(frame) {
|
|
70
|
+
if (frame.state == 'active') charge(frame.root), frame.root.running = false, frame.paused = 1
|
|
71
|
+
return promise => {
|
|
72
|
+
frame.paused = 0
|
|
73
|
+
detach(frame)
|
|
74
|
+
frame.state = 'suspended'
|
|
75
|
+
return Promise.resolve(promise).then(value => ({ ok: true, value }), error => ({ ok: false, error }))
|
|
76
|
+
}
|
|
56
77
|
}
|
|
57
78
|
export function budgetResume(frame, outcome) {
|
|
58
|
-
if (frame.state
|
|
79
|
+
if (frame.state != 'suspended') throw failure('B0910', 'inactive-frame', 0, frame.meta)
|
|
59
80
|
const r = frame.root
|
|
60
|
-
if (!r.running)
|
|
81
|
+
if (!r.running) start(r)
|
|
61
82
|
r.depth++
|
|
62
83
|
frame.state = 'active'
|
|
63
84
|
active.push(frame)
|
|
@@ -67,11 +88,11 @@ export function budgetResume(frame, outcome) {
|
|
|
67
88
|
return outcome.value
|
|
68
89
|
}
|
|
69
90
|
function ledger(owner, meta, options) {
|
|
70
|
-
if (!owner || typeof owner
|
|
71
|
-
if (owner.s
|
|
91
|
+
if (!owner || typeof owner != 'object') throw failure('B0911', 'missing-owner', 0, meta)
|
|
92
|
+
if (owner.s == 3) throw failure('B0911', 'disposed-owner', 0, meta)
|
|
72
93
|
let l = owners.get(owner)
|
|
73
94
|
if (!l) {
|
|
74
|
-
l = { limits: limitsOf(options), counts:
|
|
95
|
+
l = { limits: limitsOf(options), counts: {}, releases: new Set(), disposed: false }
|
|
75
96
|
owners.set(owner, l)
|
|
76
97
|
I.root(() => fx(() => () => {
|
|
77
98
|
l.disposed = true
|
|
@@ -86,10 +107,10 @@ function ledger(owner, meta, options) {
|
|
|
86
107
|
export function budgetAllocation(kind, owner, units) {
|
|
87
108
|
if (!owner) return // Module-level shared state has no disposable component owner.
|
|
88
109
|
if (!['rows', 'signals'].includes(kind) || !Number.isSafeInteger(units) || units < 0) throw failure('B0911', 'invalid-allocation', 0, null)
|
|
89
|
-
const frame = active
|
|
110
|
+
const frame = active.at(-1)
|
|
90
111
|
const meta = frame?.meta
|
|
91
112
|
const l = ledger(owner, meta)
|
|
92
|
-
const next = kind
|
|
113
|
+
const next = kind == 'signals' ? (l.counts.signals || 0) + units : units
|
|
93
114
|
if (next > l.limits[kind]) throw failure('B0911', kind, l.limits[kind], meta)
|
|
94
115
|
l.counts[kind] = next
|
|
95
116
|
}
|
package/brain-ch.js
CHANGED
|
@@ -119,12 +119,14 @@ export function store(init, o = {}, id) {
|
|
|
119
119
|
const base = JSON.stringify(snap()) // the state as created: changes made before the module loads are still saved
|
|
120
120
|
READY.push(import('./brain-persist.js').then(m => m.persist({ key: P, v, big, apply, snap, S, base })))
|
|
121
121
|
}
|
|
122
|
+
let onStorage = null
|
|
122
123
|
if (o.sync === 'storage' && G.localStorage) {
|
|
123
124
|
try {
|
|
124
125
|
const v = localStorage.getItem(K)
|
|
125
126
|
if (v) apply(JSON.parse(v, safe))
|
|
126
127
|
} catch {}
|
|
127
|
-
|
|
128
|
+
onStorage = e => e.key === K && e.newValue && apply(JSON.parse(e.newValue, safe))
|
|
129
|
+
G.addEventListener && G.addEventListener('storage', onStorage)
|
|
128
130
|
}
|
|
129
131
|
const bc = o.sync === 'tab' && G.BroadcastChannel ? new BroadcastChannel(K) : null
|
|
130
132
|
const w = o.sync === 'ws' && G.WebSocket ? ws(o.url || '/_ws', K, apply) : null
|
|
@@ -155,6 +157,14 @@ export function store(init, o = {}, id) {
|
|
|
155
157
|
}
|
|
156
158
|
}),
|
|
157
159
|
I.owner())
|
|
160
|
+
// a store owned by a subtree (provide inside a component) releases its channel, listener and socket with it;
|
|
161
|
+
// a global store (channels/, no owner) lives as long as the page
|
|
162
|
+
if (o.sync && I.owner())
|
|
163
|
+
I.root(() => fx(() => () => {
|
|
164
|
+
if (bc && bc.close) bc.close()
|
|
165
|
+
if (onStorage && G.removeEventListener) G.removeEventListener('storage', onStorage)
|
|
166
|
+
if (w) w.close()
|
|
167
|
+
}), I.owner())
|
|
158
168
|
return s
|
|
159
169
|
}
|
|
160
170
|
|
|
@@ -199,8 +209,9 @@ function ws(url, s, apply) {
|
|
|
199
209
|
}
|
|
200
210
|
}
|
|
201
211
|
const now = () => ((n = 0), open())
|
|
212
|
+
const shown = () => document.visibilityState === 'visible' && open()
|
|
202
213
|
G.addEventListener && G.addEventListener('online', now)
|
|
203
|
-
G.document && document.addEventListener && document.addEventListener('visibilitychange',
|
|
214
|
+
G.document && document.addEventListener && document.addEventListener('visibilitychange', shown)
|
|
204
215
|
c = {
|
|
205
216
|
subs,
|
|
206
217
|
sub(k, f) {
|
|
@@ -212,14 +223,16 @@ function ws(url, s, apply) {
|
|
|
212
223
|
off = true
|
|
213
224
|
clearTimeout(t)
|
|
214
225
|
sock && sock.close()
|
|
215
|
-
WS.delete(url)
|
|
226
|
+
if (WS.get(url) === c) WS.delete(url)
|
|
216
227
|
G.removeEventListener && G.removeEventListener('online', now)
|
|
228
|
+
G.document && document.removeEventListener && document.removeEventListener('visibilitychange', shown)
|
|
217
229
|
},
|
|
218
230
|
}
|
|
219
231
|
WS.set(url, c)
|
|
220
232
|
}
|
|
221
233
|
c.sub(s, apply)
|
|
222
|
-
|
|
234
|
+
let gone = false // $close() and the owner's cleanup may both run: release once
|
|
235
|
+
return { send: d => c.send(s, d), close: () => gone || ((gone = true), c.subs.delete(s), c.subs.size || c.close()) }
|
|
223
236
|
}
|
|
224
237
|
|
|
225
238
|
// Shape of a store: field names + kind of each initial value. A persisted state with another shape is discarded.
|
package/brain-data.js
CHANGED
|
@@ -186,8 +186,9 @@ function request(path, M, def, arg, o) {
|
|
|
186
186
|
// optimistic: () => (list = [...list, item]) — applied now, undone if the request fails
|
|
187
187
|
const undo = o.optimistic ? rec(o.optimistic) : null
|
|
188
188
|
// a call made from an event handler is cancelled when its component unmounts (brain-guard keeps `$c` on its nodes)
|
|
189
|
+
let reg = null
|
|
189
190
|
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
|
+
const c = (reg = el.$c)
|
|
191
192
|
c.add(ctl)
|
|
192
193
|
ctl.signal.addEventListener('abort', () => c.delete(ctl))
|
|
193
194
|
break
|
|
@@ -238,6 +239,8 @@ function request(path, M, def, arg, o) {
|
|
|
238
239
|
batch(() => { for (let i = undo.length - 2; i >= 0; i -= 2) undo[i].v = undo[i + 1] })
|
|
239
240
|
throw e
|
|
240
241
|
})
|
|
242
|
+
// a settled request leaves its component's registry: a long-lived view must not keep every finished call
|
|
243
|
+
if (reg) p.then(() => reg.delete(ctl), () => reg.delete(ctl))
|
|
241
244
|
p.cancel = () => ctl.abort()
|
|
242
245
|
if (file) Object.defineProperty(p, 'progress', { get: () => up.v }) // 0–1, a signal read
|
|
243
246
|
return p
|
package/brain-env.js
CHANGED
|
@@ -24,10 +24,11 @@ export function mounted(f) {
|
|
|
24
24
|
onDispose(() => ((alive = false), typeof c === 'function' && c()))
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
// watch(() => src, (v, old) => …): runs the callback when the source changes, not on the first read
|
|
28
|
-
//
|
|
29
|
-
|
|
30
|
-
|
|
27
|
+
// watch(() => src, (v, old) => …): runs the callback when the source changes, not on the first read (with
|
|
28
|
+
// `{ immediate: true }` also once with the initial value, `old` undefined); the callback is not tracked. A returned
|
|
29
|
+
// function is its cleanup (before the next run and on unmount).
|
|
30
|
+
export function watch(src, cb, options) {
|
|
31
|
+
let first = !options?.immediate, old
|
|
31
32
|
fx(() => {
|
|
32
33
|
const v = src()
|
|
33
34
|
if (first) return void ((first = false), (old = v))
|