@christianirack/brainllm-runtime 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/brain-ai.js +15 -7
- package/brain-analytics.js +401 -0
- package/brain-budget.js +46 -25
- package/brain-ch.js +35 -8
- package/brain-data.js +4 -1
- package/brain-env.js +5 -4
- package/brain-guard.js +6 -6
- package/brain-live.js +367 -0
- 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/native-sockets.mjs +156 -0
- package/server/server.mjs +319 -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
|
}
|