@christianirack/brainllm-runtime 0.3.1 → 0.5.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-dev.js CHANGED
@@ -1,12 +1,13 @@
1
1
  // BRAINLLM dev (SPEC §6.2): HMR that keeps sig state by name. Development only.
2
2
  // Served next to the core under /@brain/ and imports ./brain.js relatively: a single instance of the signal graph.
3
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.
4
+ // Static proof retains node identity; incompatible nested updates rebuild a proven owner boundary before parent ranges can become stale.
5
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'
6
+ import { _dev, fx, tpl } from './brain.js'
7
7
 
8
8
  let cur = null // instance under construction: { m: Map name→sig, r: Map name→value to restore | null }
9
9
  let quiet = 0 // >0 during hmr: the bus emits `hmr` instead of mount/dispose
10
+ const definitions = new Map() // bounded current implementations for future mounts through an older import
10
11
  export const live = new Map()
11
12
  export const inst = new WeakMap()
12
13
  export const bus = new Set() // fn(kind, data): 'mount' | 'dispose' | 'hmr' | 'hydrate'
@@ -30,18 +31,22 @@ export { owner }
30
31
 
31
32
  function build(i, restore) {
32
33
  const pc = cur
33
- cur = { m: new Map(), r: restore, f: i.C.f || i.h || null }
34
+ cur = { m: new Map(), r: restore, f: i.C.f || i.h || null, i }
34
35
  try {
35
36
  return root(d => {
36
37
  i.r = owner() // register before C(): fx from the first run already have an owner (inspector)
37
38
  inst.set(i.r, i)
38
39
  const t = performance.now()
39
- const out = i.C(i.$p)
40
+ i.fragments = new Set()
41
+ i.fragmentRefs = 0
42
+ i.fragmentOverflow = false
43
+ let out = i.C(i.$p)
40
44
  i.$mt = performance.now() - t
41
45
  mounts.unshift({ comp: i.C.f || i.h || null, ms: Math.round(i.$mt * 100) / 100, at: Date.now(), hmr: quiet > 0 })
42
46
  if (mounts.length > 50) mounts.length = 50
43
47
  // 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]
48
+ i.n = out == null ? [] : Array.isArray(out) ? out.flat(Infinity) : out.nodeType === 11 ? [...out.childNodes] : [out]
49
+ if (!i.n.length) { out = document.createComment(''); i.n = [out] }
45
50
  for (const x of i.n) if (x.nodeType === 1) x.$i = i
46
51
  i.d = d
47
52
  i.m = cur.m
@@ -51,7 +56,9 @@ function build(i, restore) {
51
56
  // (last hovered or focused element, accessibility tree) and must not keep the whole instance with it
52
57
  fx(() => () => {
53
58
  set.delete(i)
59
+ if (!set.size) live.delete(i.h)
54
60
  for (const x of i.n || []) if (x.nodeType === 1 && x.$i === i) x.$i = null
61
+ if (!quiet) for (const node of i.n || []) node.remove()
55
62
  quiet || emit('dispose', i)
56
63
  })
57
64
  if (!quiet) emit('mount', i)
@@ -63,38 +70,194 @@ function build(i, restore) {
63
70
  }
64
71
 
65
72
  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)
73
+ const registered = typeof C.h === 'string' && (definitions.has(C.h) || definitions.size < 1024)
74
+ if (registered) definitions.set(C.h, C)
75
+ const W = $p => build({ C: definitions.get(C.h) || C, $p, h: C.h, p: owner(), n: null, d: null, m: null, r: null }, null)
67
76
  Object.assign(W, C) // h, p, form, seo, head… whatever the compiler (or guard) put on it
68
77
  W.C = C
78
+ if (typeof C.h === 'string' && !registered) W.$hmrUnsupportedReason = 'definition-registry-budget'
69
79
  return W
70
80
  }
71
81
 
72
- // Remounts in place every live instance of `hash` with the new component from `mod`.
82
+ // Compiler-only fragment factories record pristine references before dynamic holes insert/move siblings.
83
+ // Per-owner cleanup prevents inactive branches/rows from retaining detached nodes.
84
+ export function fragment(create, hash, index) {
85
+ let cached = null, factory = create
86
+ return () => {
87
+ let i = cur?.i?.h === hash ? cur.i : null
88
+ for (let o = owner(); !i && o; o = o.p) { const found = inst.get(o); if (found?.h === hash) i = found }
89
+ const html = i?.C?.$hmr?.templates?.[index]
90
+ if (typeof html === 'string' && html !== cached) { cached = html; factory = tpl(html) }
91
+ const out = factory()
92
+ if (!i || i.fragmentOverflow) return out
93
+ const nodes = [], roots = out.nodeType === 11 ? [...out.childNodes] : [out]
94
+ const rootSet = new Set(roots)
95
+ const visit = (node, depth) => {
96
+ if (depth > 64 || nodes.length >= 4096) return false
97
+ nodes.push({ node, parent: node.parentNode, root: rootSet.has(node) })
98
+ for (const child of node.childNodes || []) if (!visit(child, depth + 1)) return false
99
+ return true
100
+ }
101
+ if (!roots.every(node => visit(node, 0)) || i.fragmentRefs + nodes.length > 8192 || i.fragments.size >= 2048) { i.fragmentOverflow = true; return out }
102
+ const record = { index, nodes, owner: owner() }
103
+ i.fragments.add(record)
104
+ i.fragmentRefs += nodes.length
105
+ fx(() => () => { if (i.fragments.delete(record)) i.fragmentRefs -= nodes.length })
106
+ return out
107
+ }
108
+ }
109
+
110
+ export let lastUpdate = { mode: 'none', patched: 0, remounted: 0, reason: 'not-run' }
111
+ const safeAttribute = name => ['class', 'title', 'alt', 'placeholder', 'style'].includes(name) || name.startsWith('aria-') || name.startsWith('data-') && !['data-b', 'data-cv-ref'].includes(name)
112
+ const attributes = node => new Map([...node.attributes].map(attribute => [attribute.name, attribute.value]))
113
+ function metadata(value) {
114
+ return value?.version === 1 && typeof value.wiring === 'string' && value.wiring.length <= 65536 && Array.isArray(value.templates) && value.templates.length <= 64 && value.templates.every(html => typeof html === 'string') && value.templates.reduce((n, html) => n + html.length, 0) <= 65536
115
+ }
116
+ function templateChanges(before, after) {
117
+ const changes = [], shapes = []
118
+ let visits = 0
119
+ for (let index = 0; index < before.length; index++) {
120
+ const old = tpl(before[index])(), next = tpl(after[index])(), flat = []
121
+ const walk = (a, b, depth) => {
122
+ if (++visits > 8192 || depth > 64) throw Error('template-budget')
123
+ if (!a || !b || a.nodeType !== b.nodeType || a.nodeName !== b.nodeName || a.namespaceURI !== b.namespaceURI) throw Error('template-topology')
124
+ const position = flat.length
125
+ flat.push(a)
126
+ if (a.nodeType === 3 && a.data !== b.data) changes.push({ index, position, kind: 'text', before: a.data, after: b.data })
127
+ else if (a.nodeType === 8 && a.data !== b.data) throw Error('anchor-topology')
128
+ if (a.nodeType === 1) {
129
+ const previous = attributes(a), current = attributes(b)
130
+ for (const name of new Set([...previous.keys(), ...current.keys()])) if (previous.get(name) !== current.get(name)) {
131
+ if (!safeAttribute(name)) throw Error('stateful-or-unsupported-attribute')
132
+ changes.push({ index, position, kind: 'attribute', name, before: previous.get(name) ?? null, after: current.get(name) ?? null })
133
+ }
134
+ }
135
+ if (a.childNodes.length !== b.childNodes.length) throw Error('template-topology')
136
+ for (let n = 0; n < a.childNodes.length; n++) walk(a.childNodes[n], b.childNodes[n], depth + 1)
137
+ }
138
+ const a = old.nodeType === 11 ? [...old.childNodes] : [old], b = next.nodeType === 11 ? [...next.childNodes] : [next]
139
+ if (a.length !== b.length) throw Error('template-topology')
140
+ for (let n = 0; n < a.length; n++) walk(a[n], b[n], 0)
141
+ shapes.push(flat)
142
+ }
143
+ return { changes, shapes }
144
+ }
145
+ function staticPlan(i, C) {
146
+ const before = i.C.$hmr, after = C.$hmr
147
+ if (!metadata(before) || !metadata(after)) return 'metadata-unavailable-or-budget'
148
+ if (before.wiring !== after.wiring) return 'compiled-wiring-changed'
149
+ if (before.templates.length !== after.templates.length) return 'template-topology'
150
+ if (i.fragmentOverflow) return 'instance-reference-budget'
151
+ let analysis
152
+ try { analysis = templateChanges(before.templates, after.templates) } catch (error) { return ['template-budget', 'template-topology', 'anchor-topology', 'stateful-or-unsupported-attribute'].includes(error.message) ? error.message : 'template-proof-failed' }
153
+ const operations = []
154
+ for (const record of i.fragments || []) {
155
+ const shape = analysis.shapes[record.index]
156
+ if (!shape || shape.length !== record.nodes.length) return 'instance-shape-conflict'
157
+ for (const change of analysis.changes) if (change.index === record.index) {
158
+ const entry = record.nodes[change.position], node = entry?.node
159
+ if (!node || node.nodeType !== shape[change.position].nodeType || !entry.root && node.parentNode !== entry.parent) return 'instance-node-conflict'
160
+ if (change.kind === 'text' ? node.data !== change.before : node.getAttribute(change.name) !== change.before) return 'runtime-owned-content-conflict'
161
+ operations.push({ node, ...change })
162
+ if (operations.length > 8192) return 'instance-operation-budget'
163
+ }
164
+ }
165
+ return { i, C, operations }
166
+ }
167
+ function applyStaticPlan({ i, C, operations }) {
168
+ // Plan first; preserve live handlers/effects/owners and target the original nodes only.
169
+ const selection = document.getSelection?.()
170
+ const saved = selection?.rangeCount ? { anchor: selection.anchorNode, anchorOffset: selection.anchorOffset, focus: selection.focusNode, focusOffset: selection.focusOffset } : null
171
+ try {
172
+ for (const operation of operations) {
173
+ if (operation.kind === 'text') operation.node.data = operation.after
174
+ else if (operation.after === null) operation.node.removeAttribute(operation.name)
175
+ else operation.node.setAttribute(operation.name, operation.after)
176
+ }
177
+ } catch { return 'native-dom-patch-failed' }
178
+ if (saved && selection.setBaseAndExtent) {
179
+ const length = node => node?.nodeType === 3 ? node.data.length : node?.childNodes.length || 0
180
+ try { selection.setBaseAndExtent(saved.anchor, Math.min(saved.anchorOffset, length(saved.anchor)), saved.focus, Math.min(saved.focusOffset, length(saved.focus))) } catch {}
181
+ }
182
+ i.C = C
183
+ return null
184
+ }
185
+
186
+ // A parent list/branch keeps its own node range. Replacing a child range directly would leave those
187
+ // captured roots stale, so incompatible nested updates rebuild the outermost owned component once.
188
+ function boundary(i) {
189
+ let target = i, count = 0
190
+ for (let owner = i.p; owner; owner = owner.p) {
191
+ if (++count > 256) return null
192
+ const ancestor = inst.get(owner)
193
+ if (ancestor) target = ancestor
194
+ }
195
+ return !target.p || !target.p.f && !target.p.p ? target : null
196
+ }
197
+ function under(i, ancestor) {
198
+ if (i === ancestor) return true
199
+ let count = 0
200
+ for (let owner = i.p; owner && ++count <= 256; owner = owner.p) if (owner === ancestor.r) return true
201
+ return false
202
+ }
203
+ function remount(i, C) {
204
+ const old = i.n, parent = old[0]?.parentNode
205
+ const anchor = document.createComment('')
206
+ if (parent) parent.insertBefore(anchor, old[0])
207
+ const values = new Map([...i.m].map(([name, node]) => [name, node._]))
208
+ i.d()
209
+ emit('dispose', { h: i.h, C: i.C, n: old, r: null, hmr: 1 })
210
+ i.C = C
211
+ build(i, values)
212
+ if (parent) for (const node of i.n) parent.insertBefore(node, anchor)
213
+ for (const node of old) node.remove()
214
+ anchor.remove()
215
+ }
216
+ // Safe static fragments retain node identity. Other updates rebuild a proven owner boundary.
73
217
  export function hmr(hash, mod) {
74
- const W = Object.values(mod).find(c => c && c.h === hash)
218
+ const W = Object.values(mod).find(component => component && component.h === hash)
75
219
  if (!W) return 0
76
220
  const C = W.C || W
77
- let k = 0
221
+ if (W.$hmrUnsupportedReason) { lastUpdate = { mode: 'reload-required', patched: 0, remounted: 0, reason: W.$hmrUnsupportedReason }; return 0 }
222
+ const instances = [...(live.get(hash) || [])].filter(i => i.r?.s !== 3)
223
+ const plans = [], rebuild = new Map(), reasons = new Set(), patched = new Set()
224
+ for (const i of instances) {
225
+ let plan
226
+ try { plan = staticPlan(i, C) } catch { plan = 'native-proof-operation-failed' }
227
+ if (typeof plan !== 'string') { plans.push(plan); continue }
228
+ const target = boundary(i)
229
+ if (!target) { lastUpdate = { mode: 'reload-required', patched: 0, remounted: 0, reason: 'parent-range-owner-unavailable' }; return 0 }
230
+ reasons.add(plan)
231
+ if (target !== i) reasons.add('ancestor-remount-for-parent-owned-range')
232
+ rebuild.set(target, definitions.get(target.h) || target.C)
233
+ }
234
+ let affected = 0, remounted = 0, ancestors = 0
78
235
  quiet++
79
236
  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++
237
+ for (const plan of plans) {
238
+ if ([...rebuild.keys()].some(target => under(plan.i, target))) continue
239
+ const failure = applyStaticPlan(plan)
240
+ if (!failure) patched.add(plan.i)
241
+ else {
242
+ const target = boundary(plan.i)
243
+ if (!target) { lastUpdate = { mode: 'reload-required', patched: patched.size, remounted, reason: 'native-patch-failed-without-owned-range' }; return 0 }
244
+ rebuild.set(target, definitions.get(target.h) || target.C)
245
+ reasons.add(failure)
246
+ if (target !== plan.i) reasons.add('ancestor-remount-for-parent-owned-range')
247
+ }
94
248
  }
95
- } finally {
96
- quiet--
97
- }
98
- emit('hmr', { h: hash, n: k })
99
- return k
249
+ for (const [target, implementation] of rebuild) {
250
+ if (target.r?.s === 3) continue
251
+ const covered = instances.filter(i => under(i, target)).length
252
+ for (const i of patched) if (under(i, target)) patched.delete(i)
253
+ if (target.h !== hash) ancestors++
254
+ remount(target, implementation)
255
+ affected += covered
256
+ remounted++
257
+ }
258
+ } finally { quiet-- }
259
+ const count = patched.size + affected
260
+ lastUpdate = { mode: !count ? 'future-mount-updated' : remounted ? patched.size ? 'mixed' : ancestors ? 'ancestor-remount' : 'component-remount' : 'static-fragment', patched: patched.size, remounted, ancestorRemounted: ancestors, reason: [...reasons].join(',') || (!count ? 'no-live-instance; current-definition-retained' : 'exact-compiled-wiring-and-template-shape'), stateRestoration: ancestors ? 'ancestor-named-state; descendant-instances-recreated' : 'named-state-or-static-identity' }
261
+ emit('hmr', { h: hash, n: count, ...lastUpdate })
262
+ return count
100
263
  }
package/brain-err.js CHANGED
@@ -12,7 +12,7 @@ const RT = new URL('.', import.meta.url).href
12
12
  // Core hook: records (capture active) or console.error, and falls back to the nearest boundary's fallback (not for
13
13
  // event handlers).
14
14
  const I = _err((e, x, w) => {
15
- on ? record(e, x, w) : G.console && console.error(e)
15
+ on ? record(e, x, w) : print(e)
16
16
  // §42: a failing event handler never destroys its component (it is reported; brain-guard shows the toast)
17
17
  if (String(w).startsWith('on:')) return
18
18
  for (; x; x = x.p || x.parentNode) {
@@ -20,6 +20,7 @@ const I = _err((e, x, w) => {
20
20
  if (b) return fall(b)
21
21
  }
22
22
  })
23
+ const print = (e, ref) => G.console && console.error(I.development?.() ? e : `BRAIN_ERROR: Operation failed. Reference: ${ref || 'unavailable'}`)
23
24
  // Boundaries: owner N and its root elements → { fb, n: nodes, r: owner, f: already fell }.
24
25
  const BW = new WeakMap()
25
26
  const fbn = fb => {
@@ -50,7 +51,7 @@ export const fallback = (C, fb) =>
50
51
  try {
51
52
  a = I.nodes(C($p))
52
53
  } catch (e) {
53
- on ? record(e, r, 'render') : G.console && console.error(e)
54
+ on ? record(e, r, 'render') : print(e)
54
55
  r.q()
55
56
  ;(b.f = 1), (a = fbn(fb))
56
57
  }
@@ -67,6 +68,7 @@ const crumbs = []
67
68
  let opts = {}, on = false, sent = 0, F0 = null
68
69
 
69
70
  export function crumb(k, d) {
71
+ k = clean(k, 40); d = clean(d, 200)
70
72
  const l = crumbs[crumbs.length - 1]
71
73
  if (l && l.k === k && l.d === d) return void l.n++ // the same repeated action is counted, not stacked
72
74
  crumbs.push({ k, d, n: 1, at: Date.now() })
@@ -82,22 +84,49 @@ const path = u => {
82
84
  try {
83
85
  return new URL(u, G.location && G.location.href).pathname // no query: it may carry sensitive data
84
86
  } catch {
85
- return String(u).split('?')[0]
87
+ return String(u).split(/[?#]/)[0]
86
88
  }
87
89
  }
88
- // "url:line:col" of each frame; the first one outside the runtime is the app line.
90
+ const LIMIT = { frames: 24, causes: 6, stackBytes: 16384, stackLines: 256, maps: 64, sourceBytes: 1048576, mapLines: 65536, loads: 2, pending: 64, deadline: 2000 }
91
+ const get = (o, k) => { try { return o && o[k] } catch { return null } }
92
+ const clean = (s, max = 500) => String(s == null ? '' : s).slice(0, max).replace(/(?:https?|file):\/\/[^\s)]+/g, u => u.split(/[?#]/)[0]).replace(/([/\w.:%-]+)[?#][^\s)'"]*/g, '$1')
93
+ // Only structural stack locations are retained, never argument lists or the raw stack header.
89
94
  function frames(e) {
90
- const out = []
91
- for (const l of String((e && e.stack) || '').split('\n')) {
95
+ const raw = String(get(e, 'stack') || ''), out = []
96
+ let omitted = raw.length > LIMIT.stackBytes
97
+ const lines = raw.slice(0, LIMIT.stackBytes).split('\n')
98
+ if (lines.length > LIMIT.stackLines) omitted = true
99
+ for (const l of lines.slice(0, LIMIT.stackLines)) {
92
100
  const m = l.match(/((?:https?|file):\/\/[^\s)]+):(\d+):(\d+)/)
93
- if (m) out.push({ url: m[1], line: +m[2], col: +m[3], rt: m[1].startsWith(RT) && !m[1].slice(RT.length).includes('/') })
101
+ if (!m) continue
102
+ if (![+m[2], +m[3]].every(n => Number.isSafeInteger(n) && n > 0)) { omitted = true; continue }
103
+ if (out.length >= LIMIT.frames) { omitted = true; break }
104
+ out.push({ url: m[1], line: +m[2], col: +m[3], rt: m[1].startsWith(RT) && !m[1].slice(RT.length).includes('/') })
94
105
  }
95
- return out
106
+ return { frames: out, truncated: omitted }
107
+ }
108
+ const generated = t => t ? { file: path(t.url).slice(-1024), line: t.line, column: t.col } : null
109
+ const location = (t, c) => ({ component: c ? clean(c.name || c.h || '', 120) || null : null, file: t ? path(t.url).slice(-1024) : c && c.file ? c.file : null, line: t ? t.line : null, column: t ? t.col : null, precision: t ? 'generated' : 'component-only' })
110
+ function trace(e, c) {
111
+ const f = frames(e), top = f.frames.find(t => !t.rt) || null
112
+ return { rawFrames: f.frames, location: location(top, c), generatedLocation: generated(top), frames: f.frames.map(t => ({ generated: generated(t), source: null, runtime: t.rt })), truncated: { stack: f.truncated, frames: f.truncated, locations: f.frames.some(t => path(t.url).length > 1024) } }
113
+ }
114
+ function causes(e, c) {
115
+ const seen = new Set([e]), out = []
116
+ let next = get(e, 'cause'), truncated = false
117
+ while (next != null) {
118
+ if (seen.has(next) || out.length >= LIMIT.causes) { truncated = true; break }
119
+ seen.add(next)
120
+ const t = trace(next, c)
121
+ out.push({ depth: out.length + 1, type: clean(get(next, 'name') || typeof next, 80), code: clean(get(next, 'code') || '', 80) || null, msg: clean(get(next, 'message') || (typeof next === 'string' ? next : ''), 300), ...t })
122
+ next = get(next, 'cause')
123
+ }
124
+ return { entries: out, truncated }
96
125
  }
97
126
  // Component via the DOM: roots with data-b="<hash>" (prod) → parent chain via ancestors with data-b.
98
127
  function byDom(el) {
99
128
  const chain = []
100
- for (; el; el = el.parentNode) if (el.getAttribute && el.getAttribute('data-b')) chain.push({ h: el.getAttribute('data-b') })
129
+ for (; el && chain.length < 33; el = el.parentNode) if (el.getAttribute && el.getAttribute('data-b')) chain.push({ h: el.getAttribute('data-b') })
101
130
  return chain.length ? { ...chain[0], chain: chain.map(c => c.h) } : null
102
131
  }
103
132
  // Prod chunk /_b/<hash>.brain → component hash.
@@ -110,18 +139,21 @@ const byStack = f => {
110
139
  // line to its .brain line. With `maps`, a recorded error is enriched asynchronously: `src` ('views/home.brain:12', the
111
140
  // first app frame) and `brainStack`; subscribers (brain_errors, overlay, doctor) are notified again. The file path
112
141
  // comes from the module URL (two `index.brain` in different folders never mix); prod chunks carry no map: no-op.
113
- const MAPS = new Map()
142
+ const MAPS = new Map(), pending = []
143
+ let loading = 0, enriching = 0
114
144
  const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
115
145
  export function decode(m) {
146
+ if (typeof m !== 'string' || m.length > LIMIT.sourceBytes) return []
116
147
  const lines = []
117
148
  let src = 0
118
- for (const seg of m.split(';')) {
149
+ for (const seg of m.split(';').slice(0, LIMIT.mapLines)) {
119
150
  let line = null
120
151
  for (const part of seg.split(',')) {
121
152
  if (!part) continue
122
153
  const v = []
123
154
  for (let i = 0, n = 0, sh = 0; i < part.length; i++) {
124
155
  const d = B64.indexOf(part[i])
156
+ if (d < 0 || sh > 30) return []
125
157
  n += (d & 31) << sh
126
158
  if (d & 32) sh += 5
127
159
  else (v.push(n & 1 ? -(n >> 1) : n >> 1), (n = 0), (sh = 0))
@@ -132,54 +164,147 @@ export function decode(m) {
132
164
  }
133
165
  return lines
134
166
  }
135
- const mapOf = url => {
136
- if (!MAPS.has(url))
137
- MAPS.set(
138
- url,
139
- Promise.resolve(opts.load ? opts.load(url) : F0(url).then(r => r.text()))
140
- .then(t => {
141
- const m = /sourceMappingURL=data:application\/json;base64,([A-Za-z0-9+/=]+)/.exec(t)
142
- return m ? decode(JSON.parse(atob(m[1])).mappings) : null
143
- })
144
- .catch(() => null)
145
- )
146
- return MAPS.get(url)
147
- }
148
- async function toBrain(r, f) {
149
- const out = []
150
- for (const t of f.slice(0, 12)) {
151
- const lines = t.rt ? null : await mapOf(t.url)
152
- const l = lines && lines[t.line - 1]
153
- if (l) out.push(`${path(t.url).replace(/^\//, '')}:${l}`)
167
+ async function source(url, signal) {
168
+ if (opts.load) {
169
+ const text = await opts.load(url, { signal })
170
+ return typeof text === 'string' && text.length <= LIMIT.sourceBytes && new TextEncoder().encode(text).byteLength <= LIMIT.sourceBytes ? text : null
154
171
  }
155
- if (!out.length) return
156
- r.src = out[0]
157
- r.brainStack = out
172
+ if (!F0) return null
173
+ const r = await F0(url, { signal, credentials: 'same-origin' })
174
+ if (r.ok === false || +(r.headers && r.headers.get('content-length')) > LIMIT.sourceBytes) return null
175
+ if (!r.body || !r.body.getReader) { const text = await r.text(); return text.length <= LIMIT.sourceBytes && new TextEncoder().encode(text).byteLength <= LIMIT.sourceBytes ? text : null }
176
+ const reader = r.body.getReader(), decoder = new TextDecoder()
177
+ let text = '', bytes = 0
178
+ try {
179
+ for (;;) {
180
+ const part = await reader.read()
181
+ if (part.done) break
182
+ bytes += part.value.byteLength
183
+ if (bytes > LIMIT.sourceBytes) { await reader.cancel(); return null }
184
+ text += decoder.decode(part.value, { stream: true })
185
+ }
186
+ return text + decoder.decode()
187
+ } finally { reader.releaseLock() }
188
+ }
189
+ function pump() {
190
+ while (loading < LIMIT.loads && pending.length) {
191
+ const task = pending.shift()
192
+ if (task.done) continue
193
+ loading++
194
+ Promise.resolve().then(() => source(task.url, task.controller.signal)).then(text => {
195
+ const m = text && /sourceMappingURL=data:application\/json;base64,([A-Za-z0-9+/=]+)/.exec(text)
196
+ const map = m && JSON.parse(atob(m[1]))
197
+ const lines = map && map.version === 3 && decode(map.mappings)
198
+ task.finish({ lines: lines && lines.length ? lines : null, status: lines && lines.length ? 'mapped' : 'unavailable' })
199
+ }).catch(() => task.finish({ lines: null, status: 'unavailable' })).finally(() => { loading--; pump() })
200
+ }
201
+ }
202
+ function mapOf(input) {
203
+ let url
204
+ try {
205
+ const u = new URL(input), origin = G.location && G.location.origin
206
+ if (!['http:', 'https:', 'file:'].includes(u.protocol) || (u.protocol !== 'file:' && origin && origin !== 'null' && u.origin !== origin)) return Promise.resolve({ lines: null, status: 'unavailable' })
207
+ u.hash = ''; url = u.href
208
+ } catch { return Promise.resolve({ lines: null, status: 'unavailable' }) }
209
+ const hit = MAPS.get(url)
210
+ if (hit) { MAPS.delete(url); MAPS.set(url, hit); return hit.promise }
211
+ if (pending.length >= LIMIT.pending) return Promise.resolve({ lines: null, status: 'limited' })
212
+ if (MAPS.size >= LIMIT.maps) {
213
+ const old = [...MAPS].find(([, e]) => e.done)
214
+ if (!old) return Promise.resolve({ lines: null, status: 'limited' })
215
+ MAPS.delete(old[0])
216
+ }
217
+ const entry = { done: false }
218
+ entry.promise = new Promise(resolve => {
219
+ const task = { url, done: false, controller: new AbortController(), finish(result) {
220
+ if (task.done) return
221
+ task.done = entry.done = true
222
+ clearTimeout(timer)
223
+ const at = pending.indexOf(task)
224
+ if (at >= 0) pending.splice(at, 1)
225
+ if (result.status !== 'mapped' && MAPS.get(url) === entry) MAPS.delete(url)
226
+ resolve(result)
227
+ } }
228
+ const timer = setTimeout(() => { task.controller.abort(); task.finish({ lines: null, status: 'timeout' }) }, LIMIT.deadline)
229
+ pending.push(task)
230
+ })
231
+ MAPS.set(url, entry)
232
+ pump()
233
+ return entry.promise
234
+ }
235
+ async function toBrain(r, traces) {
236
+ const jobs = []
237
+ for (const item of traces) for (const [index, t] of item.rawFrames.entries()) {
238
+ if (t.rt) continue
239
+ jobs.push(mapOf(t.url).then(result => {
240
+ const line = result.lines && result.lines[t.line - 1]
241
+ if (Number.isSafeInteger(line) && line > 0 && item.frames[index]) item.frames[index].source = { file: path(t.url).replace(/^\//, '').slice(-1024), line, column: null }
242
+ return result.status
243
+ }))
244
+ }
245
+ const status = await Promise.all(jobs)
246
+ for (const item of traces) {
247
+ const first = item.frames.find(t => t.source)
248
+ if (first) item.location = { component: item.location.component, ...first.source, precision: 'source-line' }
249
+ delete item.rawFrames
250
+ }
251
+ for (let i = 1; i < traces.length; i++) r.causes[i - 1].location = traces[i].location
252
+ r.location = traces[0].location
253
+ r.frames = traces[0].frames
254
+ const out = r.frames.filter(t => t.source).map(t => `${t.source.file}:${t.source.line}`)
255
+ if (out.length) { r.src = out[0]; r.brainStack = out }
256
+ r.mapping = { status: status.includes('timeout') ? 'timeout' : status.includes('limited') ? 'limited' : out.length ? 'mapped' : 'unavailable' }
257
+ r.truncated.mapping = status.some(s => s === 'timeout' || s === 'limited')
258
+ boundRecord(r)
158
259
  for (const s of sub) s(r)
159
260
  }
160
261
 
262
+ // Bound the serialized notification too, including mapped locations and nested causes.
263
+ function boundRecord(r) {
264
+ const size = () => new TextEncoder().encode(JSON.stringify(r)).byteLength
265
+ r.truncated.output ||= false
266
+ while (size() > 65536) {
267
+ r.truncated.output = true
268
+ const nested = [...r.causes].reverse().find(c => c.frames.length)
269
+ if (nested) { nested.frames.pop(); nested.truncated.frames = true; continue }
270
+ if (r.frames.length > 1) { r.frames.pop(); r.stack.pop(); if (r.brainStack) r.brainStack.pop(); r.truncated.frames = true; continue }
271
+ if (r.causes.length) { r.causes.pop(); r.truncated.causes = true; continue }
272
+ break
273
+ }
274
+ }
161
275
  function record(e, x, w) {
162
276
  if (e && e.kind === 'aborted') return // a cancelled request (brain-data ApiError): not an error
163
- const f = frames(e), top = f.find(t => !t.rt) || null
164
- const c = (opts.who && opts.who(x)) || (x && x.nodeType ? byDom(x) : null) || byStack(top)
165
- const type = (e && e.code) || (e && e.name) || typeof e
166
- const line = top ? `${path(top.url)}:${top.line}` : null
277
+ const extracted = frames(e), f = extracted.frames, top = f.find(t => !t.rt) || null
278
+ const owner = (opts.who && opts.who(x)) || (x && x.nodeType ? byDom(x) : null) || byStack(top)
279
+ const c = owner ? { id: typeof owner.id === 'number' ? owner.id : null, h: clean(owner.h || '', 120) || null, name: clean(owner.name || '', 120) || null, file: owner.file ? clean(owner.file, 500).split(/[?#]/)[0] : null, chain: Array.isArray(owner.chain) ? owner.chain.slice(0, 32).map(n => clean(n, 120)) : [] } : null
280
+ const type = clean(get(e, 'code') || get(e, 'name') || typeof e, 80)
281
+ const line = top ? `${path(top.url).slice(-1024)}:${top.line}` : null
167
282
  const fp = hash([type, c && c.h, line, w].join('|'))
168
283
  let r = errors.find(r => r.fp === fp)
169
284
  if (r) (r.n++, (r.last = Date.now()))
170
285
  else {
286
+ const t = trace(e, c), chain = causes(e, c)
171
287
  r = {
172
- fp, code: (e && e.code) || null, type, msg: MSG[e && e.code] || String((e && e.message) || e).slice(0, 500), where: w || null, comp: c, line,
173
- stack: f.slice(0, 12).map(t => `${path(t.url)}:${t.line}:${t.col}`), crumbs: crumbs.map(c => ({ ...c })),
174
- build: opts.build || null, ua: G.navigator ? navigator.userAgent : null, at: Date.now(), last: Date.now(), n: 1,
288
+ fp, code: clean(get(e, 'code') || '', 80) || null, type, msg: MSG[get(e, 'code')] || clean(get(e, 'message') || e), where: clean(w || '', 120) || null, comp: c, line,
289
+ stack: f.slice(0, LIMIT.frames).map(t => `${path(t.url).slice(-1024)}:${t.line}:${t.col}`), location: t.location, generatedLocation: t.generatedLocation, frames: t.frames, causes: chain.entries.map(({ rawFrames, ...item }) => item), truncated: { ...t.truncated, causes: chain.truncated, componentChain: !!(owner && owner.chain && owner.chain.length > 32), breadcrumbs: crumbs.length >= 30, locations: f.some(t => path(t.url).length > 1024), mapping: false }, mapping: { status: opts.maps ? 'pending' : 'disabled' }, crumbs: crumbs.map(c => ({ ...c })),
290
+ build: clean(opts.build || '', 200) || null, ua: G.navigator ? clean(navigator.userAgent, 300) : null, at: Date.now(), last: Date.now(), n: 1,
175
291
  }
292
+ boundRecord(r)
176
293
  errors.push(r)
177
294
  if (errors.length > 100) errors.shift()
178
- if (opts.maps) toBrain(r, f)
295
+ if (opts.maps && enriching < 100) {
296
+ enriching++
297
+ const traces = [t, ...chain.entries.slice(0, r.causes.length)]
298
+ for (let i = 0; i < r.causes.length; i++) chain.entries[i].frames = r.causes[i].frames
299
+ toBrain(r, traces).finally(() => { enriching-- })
300
+ } else {
301
+ if (opts.maps) { r.mapping.status = 'limited'; r.truncated.mapping = true }
302
+ for (const item of chain.entries) delete item.rawFrames
303
+ }
179
304
  if (opts.report && G.navigator && navigator.sendBeacon && sent < (opts.max || 20)) sent++, navigator.sendBeacon(opts.report, JSON.stringify(r))
180
305
  }
181
306
  for (const s of sub) s(r)
182
- if (opts.log !== false && G.console) console.error(e)
307
+ if (opts.log !== false) print(e, r.fp)
183
308
  return r
184
309
  }
185
310
 
@@ -200,7 +325,7 @@ export function capture(o = {}) {
200
325
  G.addEventListener('error', ev => record(ev.error || ev.message, null, 'onerror'))
201
326
  G.addEventListener('unhandledrejection', ev => record(ev.reason, null, 'promise'))
202
327
  G.addEventListener('popstate', () => crumb('nav', path(location.href)))
203
- G.addEventListener('hashchange', () => crumb('nav', path(location.href) + location.hash))
328
+ G.addEventListener('hashchange', () => crumb('nav', path(location.href)))
204
329
  }
205
330
  const d = G.document
206
331
  if (d && d.addEventListener) {