@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/THIRD_PARTY_NOTICES.txt +211 -0
- package/brain-budget.js +142 -0
- package/brain-ch.js +18 -4
- package/brain-data.js +13 -3
- package/brain-dev.js +191 -28
- package/brain-err.js +168 -43
- package/brain-form.js +58 -23
- package/brain-hash.js +17 -0
- package/brain-inspect.js +6 -4
- package/brain-interop.js +50 -0
- package/brain-live.js +364 -0
- package/brain-overlay.js +12 -0
- package/brain-preferences.js +64 -0
- package/brain-prefetch-shared.js +36 -0
- package/brain-prefetch.js +19 -0
- package/brain-router.js +144 -78
- package/brain-schema.js +69 -18
- package/brain-sqlite.js +122 -0
- package/brain-storage.js +143 -0
- package/brain-threads.js +49 -0
- package/brain-time.js +77 -0
- package/brain-url.js +125 -0
- package/brain-worker.js +105 -13
- package/brain.js +5 -4
- package/package.json +45 -1
- package/server/native-sockets.mjs +156 -0
- package/server/server.mjs +449 -41
- package/server/templates/gcs-adapter.mjs +99 -0
- package/templates/google-signin.js +102 -0
- package/ui/button.brain +1 -1
- package/ui/checkbox.brain +2 -1
- package/ui/dialog.brain +5 -3
- package/ui/error-state.brain +1 -1
- package/ui/input.brain +4 -3
- package/ui/menu.brain +4 -1
- package/ui/select.brain +2 -2
- package/ui/toast.brain +4 -1
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
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
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(
|
|
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
|
-
|
|
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
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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) :
|
|
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') :
|
|
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(
|
|
87
|
+
return String(u).split(/[?#]/)[0]
|
|
86
88
|
}
|
|
87
89
|
}
|
|
88
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
136
|
-
if (
|
|
137
|
-
|
|
138
|
-
|
|
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 (!
|
|
156
|
-
r
|
|
157
|
-
r.
|
|
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
|
|
164
|
-
const
|
|
165
|
-
const
|
|
166
|
-
const
|
|
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
|
|
173
|
-
stack: f.slice(0,
|
|
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
|
|
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
|
|
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)
|
|
328
|
+
G.addEventListener('hashchange', () => crumb('nav', path(location.href)))
|
|
204
329
|
}
|
|
205
330
|
const d = G.document
|
|
206
331
|
if (d && d.addEventListener) {
|