@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 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
- const bases = new Map()
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
- if (!bases.has(k)) {
63
- const p = create('prompt', { ...(o.system ? { initialPrompts: [{ role: 'system', content: o.system }] } : {}), ...(o.onProgress ? { onProgress: o.onProgress } : {}) })
64
- p.catch(() => bases.delete(k))
65
- bases.set(k, p)
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
- return (await bases.get(k)).clone()
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 = Object.freeze({ steps: 1000000, depth: 128, ms: 200, rows: 100000, signals: 100000, resources: 1024, pending: 32, workerMs: 30000 })
5
- const now = () => globalThis.performance ? performance.now() : Date.now()
6
- const metaOf = m => ({ file: typeof m?.file === 'string' ? m.file.slice(0, 256) : null, component: typeof m?.component === 'string' ? m.component.slice(0, 80) : null, line: Number.isSafeInteger(m?.line) && m.line > 0 ? m.line : null, column: Number.isSafeInteger(m?.column) && m.column > 0 ? m.column : null })
7
- const limitsOf = options => Object.fromEntries(Object.entries(LIMITS).map(([k, max]) => [k, Number.isFinite(options?.[k]) && options[k] >= 1 ? Math.min(max, options[k]) : max]))
8
- const failure = (code, kind, limit, meta) => Object.assign(new Error(`${code}: ${kind} budget exceeded`), { code, kind: 'budget', budget: kind, limit, ...metaOf(meta), fix: kind === 'ms' || kind === 'steps' || kind === 'depth' ? 'Bound the work or move it to an owned worker.' : 'Bound the collection and dispose resources with their owner.' })
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 += Math.max(0, t - root.started); root.started = t }
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]) throw (r.error = failure('B0910', kind, r.limits[kind], frame.meta))
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[active.length - 1]
20
- const root = parent?.root || { limits: limitsOf(options), steps: 0, work: 0, depth: 0, running: false, started: 0, error: null }
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) { root.running = true; root.started = now() }
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 !== 'active') throw failure('B0910', 'inactive-frame', 0, site)
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 !== 'active') return
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
- if (!active.some(x => x.root === r)) r.running = false
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 === 'closed') return
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, promise) {
53
- detach(frame)
54
- frame.state = 'suspended'
55
- return Promise.resolve(promise).then(value => ({ ok: true, value }), error => ({ ok: false, error }))
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 !== 'suspended') throw failure('B0910', 'inactive-frame', 0, frame.meta)
79
+ if (frame.state != 'suspended') throw failure('B0910', 'inactive-frame', 0, frame.meta)
59
80
  const r = frame.root
60
- if (!r.running) { r.running = true; r.started = now() }
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 !== 'object') throw failure('B0911', 'missing-owner', 0, meta)
71
- if (owner.s === 3) throw failure('B0911', 'disposed-owner', 0, meta)
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: Object.create(null), releases: new Set(), disposed: false }
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[active.length - 1]
110
+ const frame = active.at(-1)
90
111
  const meta = frame?.meta
91
112
  const l = ledger(owner, meta)
92
- const next = kind === 'signals' ? (l.counts.signals || 0) + units : units
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
  }