@christianirack/brainllm-runtime 0.9.0 → 0.10.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-analytics.js +29 -5
- package/brain-consent.js +78 -17
- package/brain-google.js +113 -0
- package/brain-lazy.js +4 -1
- package/brain-native-vitals.js +78 -0
- package/brain-sentry.js +71 -0
- package/brain-vitals.js +8 -2
- package/brain.js +3 -2
- package/package.json +9 -2
- package/server/redis-cache.mjs +173 -0
- package/server/sentry.mjs +156 -0
- package/server/server.mjs +224 -60
- package/server/warmup-watchdog.mjs +9 -0
- package/server/warmup.mjs +134 -0
package/brain-analytics.js
CHANGED
|
@@ -37,7 +37,8 @@ export function start(config = {}, env = globalThis) {
|
|
|
37
37
|
const win = env.window || env
|
|
38
38
|
const nav = env.navigator || {}
|
|
39
39
|
const loc = () => env.location || { pathname: '/', search: '', href: '/', origin: '' }
|
|
40
|
-
|
|
40
|
+
let store
|
|
41
|
+
try { store = env.localStorage } catch {} // some browsers block access to the storage object itself
|
|
41
42
|
const now = () => (env.performance ? env.performance.now() : Date.now())
|
|
42
43
|
if (!doc || config.enabled === false) return null
|
|
43
44
|
if (nav.globalPrivacyControl === true || nav.doNotTrack === '1' || win.doNotTrack === '1' || nav.webdriver) return null
|
|
@@ -45,7 +46,9 @@ export function start(config = {}, env = globalThis) {
|
|
|
45
46
|
const consented = () => {
|
|
46
47
|
if (config.consent !== 'required') return true
|
|
47
48
|
try {
|
|
48
|
-
|
|
49
|
+
const memory = env[Symbol.for('brain:consent-state')]
|
|
50
|
+
if (memory && Object.hasOwn(memory, 'analytics')) return memory.analytics === true
|
|
51
|
+
return (JSON.parse(store.getItem('brain:consent')) || {}).analytics === true
|
|
49
52
|
} catch {
|
|
50
53
|
return false
|
|
51
54
|
}
|
|
@@ -193,9 +196,11 @@ export function start(config = {}, env = globalThis) {
|
|
|
193
196
|
let maxScroll = 0
|
|
194
197
|
let path = loc().pathname
|
|
195
198
|
let stopped = false
|
|
199
|
+
let consentEpoch = 0
|
|
196
200
|
const send = () => {
|
|
197
201
|
clearTimeout(timer)
|
|
198
202
|
timer = null
|
|
203
|
+
if (!consented()) { discardDenied(); return }
|
|
199
204
|
if (!queue.length) return
|
|
200
205
|
const vid = ids()
|
|
201
206
|
while (queue.length) {
|
|
@@ -267,9 +272,25 @@ export function start(config = {}, env = globalThis) {
|
|
|
267
272
|
const listen = (t, f, target = doc) => {
|
|
268
273
|
if (!target.addEventListener) return
|
|
269
274
|
const o = { capture: true, passive: true }
|
|
270
|
-
|
|
271
|
-
|
|
275
|
+
const listener = /^(brain:consent|storage|popstate|visibilitychange|pagehide)$/.test(t) ? f : event => { if (consented()) f(event) }
|
|
276
|
+
target.addEventListener(t, listener, o)
|
|
277
|
+
off.push(() => target.removeEventListener(t, listener, o))
|
|
272
278
|
}
|
|
279
|
+
const discardDenied = () => {
|
|
280
|
+
if (!consented()) {
|
|
281
|
+
queue.length = 0; clearTimeout(timer); timer = null; pv = 0; consentEpoch++
|
|
282
|
+
clicks.length = 0; focused.clear(); typed.clear()
|
|
283
|
+
if (hover) clearTimeout(hover.t)
|
|
284
|
+
hover = null
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
listen('brain:consent', discardDenied, win)
|
|
288
|
+
listen('storage', event => {
|
|
289
|
+
if (event.key === 'brain:consent' || event.key === null) {
|
|
290
|
+
delete env[Symbol.for('brain:consent-state')]
|
|
291
|
+
discardDenied()
|
|
292
|
+
}
|
|
293
|
+
}, win)
|
|
273
294
|
const clicks = []
|
|
274
295
|
let mutations = 0
|
|
275
296
|
const Observer = env.MutationObserver
|
|
@@ -291,7 +312,9 @@ export function start(config = {}, env = globalThis) {
|
|
|
291
312
|
const seen = changes()
|
|
292
313
|
const href = loc().href
|
|
293
314
|
const active = doc.activeElement
|
|
315
|
+
const epoch = consentEpoch
|
|
294
316
|
setTimeout(() => {
|
|
317
|
+
if (epoch !== consentEpoch) return
|
|
295
318
|
if (changes() === seen && loc().href === href && doc.activeElement === active) record('dead', el)
|
|
296
319
|
}, DEAD_MS)
|
|
297
320
|
}
|
|
@@ -357,7 +380,8 @@ export function start(config = {}, env = globalThis) {
|
|
|
357
380
|
const el = actionable(e.target)
|
|
358
381
|
if (!el || hovered.has(el) || (hover && hover.el === el)) return
|
|
359
382
|
if (hover) clearTimeout(hover.t)
|
|
360
|
-
|
|
383
|
+
const epoch = consentEpoch
|
|
384
|
+
hover = { el, t: setTimeout(() => { if (epoch === consentEpoch) { hovered.add(el); hover = null; record('hover', el, HOVER_MS) } }, HOVER_MS) }
|
|
361
385
|
})
|
|
362
386
|
listen('pointerout', e => {
|
|
363
387
|
if (hover && actionable(e.target) === hover.el) clearTimeout(hover.t), (hover = null)
|
package/brain-consent.js
CHANGED
|
@@ -10,29 +10,90 @@ const TYPES = {
|
|
|
10
10
|
functional: ['functionality_storage'],
|
|
11
11
|
personal: ['personalization_storage'],
|
|
12
12
|
}
|
|
13
|
-
const
|
|
13
|
+
const MEMORY = Symbol.for('brain:consent-state')
|
|
14
|
+
const read = (env = G) => {
|
|
15
|
+
const state = {}
|
|
14
16
|
try {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
const stored = JSON.parse(env.localStorage.getItem(KEY))
|
|
18
|
+
for (const kind of Object.keys(TYPES)) if (typeof stored?.[kind] === 'boolean') state[kind] = stored[kind]
|
|
19
|
+
} catch {}
|
|
20
|
+
const memory = env[MEMORY]
|
|
21
|
+
for (const kind of Object.keys(TYPES)) if (typeof memory?.[kind] === 'boolean') state[kind] = memory[kind]
|
|
22
|
+
return state
|
|
19
23
|
}
|
|
20
|
-
const apply = state => {
|
|
24
|
+
const apply = (state, env = G) => {
|
|
21
25
|
const u = {}
|
|
22
|
-
for (const k in state) for (const t of TYPES[k] || []) u[t] = state[k] ? 'granted' : 'denied'
|
|
26
|
+
for (const k in state) for (const t of TYPES[k] || []) if (typeof state[k] === 'boolean') u[t] = state[k] ? 'granted' : 'denied'
|
|
23
27
|
if (!Object.keys(u).length) return
|
|
24
|
-
|
|
25
|
-
|
|
28
|
+
try {
|
|
29
|
+
if (typeof env.gtag === 'function') env.gtag('consent', 'update', u)
|
|
30
|
+
else if (Array.isArray(env.dataLayer)) env.dataLayer.push((function () { return arguments })('consent', 'update', u))
|
|
31
|
+
} catch {} // a blocked or broken external tag must not prevent a local privacy decision
|
|
26
32
|
}
|
|
27
|
-
|
|
28
|
-
|
|
33
|
+
const notify = env => {
|
|
34
|
+
const target = env.window || env
|
|
35
|
+
const EventType = env.Event || G.Event
|
|
36
|
+
if (target.dispatchEvent && EventType) target.dispatchEvent(new EventType('brain:consent'))
|
|
37
|
+
}
|
|
38
|
+
export function consent(kind, granted = true, env = G) {
|
|
29
39
|
if (!TYPES[kind]) throw Error(`consent: unknown kind "${kind}" (${Object.keys(TYPES).join(', ')})`)
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
40
|
+
if (typeof granted !== 'boolean') throw Error('consent: granted must be true or false')
|
|
41
|
+
if (env.__brainSSR || env.__brainRoute) return
|
|
42
|
+
const s = { ...read(env), [kind]: granted }
|
|
43
|
+
env[MEMORY] = s // a current-page decision still works when browser storage is blocked
|
|
44
|
+
try { env.localStorage.setItem(KEY, JSON.stringify(s)) } catch {}
|
|
45
|
+
// Disable the bridge/Google tag before a denied update can be processed by an already-loaded tag.
|
|
46
|
+
if (!granted) notify(env)
|
|
47
|
+
apply({ [kind]: granted }, env)
|
|
48
|
+
if (granted) notify(env)
|
|
49
|
+
}
|
|
50
|
+
export const consented = (kind, env = G) => !env.__brainSSR && !env.__brainRoute && read(env)[kind] === true
|
|
51
|
+
|
|
52
|
+
// Opt-in, non-modal controls: native buttons work with keyboard and screen readers; no custom key trap.
|
|
53
|
+
// App policy wording and any additional jurisdiction-specific requirements remain the site's responsibility.
|
|
54
|
+
export function consentBanner(options = {}, env = G) {
|
|
55
|
+
const doc = env.document
|
|
56
|
+
if (env.__brainSSR || env.__brainRoute || !doc?.body || options.enabled === false) return null
|
|
57
|
+
const region = doc.createElement('section'), choices = doc.createElement('div')
|
|
58
|
+
region.setAttribute('role', 'region'); region.setAttribute('aria-label', 'Privacy preferences')
|
|
59
|
+
region.setAttribute('data-brain-consent', '')
|
|
60
|
+
const message = doc.createElement('p')
|
|
61
|
+
message.textContent = 'Optional Google analytics stays off until you accept. You can change your choice here.'
|
|
62
|
+
choices.appendChild(message)
|
|
63
|
+
const button = (label, fn, parent) => {
|
|
64
|
+
const el = doc.createElement('button'); el.setAttribute('type', 'button'); el.textContent = label
|
|
65
|
+
el.addEventListener('click', fn); parent.appendChild(el); return el
|
|
66
|
+
}
|
|
67
|
+
const refresh = () => { choices.hidden = Object.hasOwn(read(env), 'analytics'); revoke.hidden = !consented('analytics', env) }
|
|
68
|
+
const choose = value => { consent('analytics', value, env); refresh(); manage.focus?.() }
|
|
69
|
+
button('Deny analytics', () => choose(false), choices)
|
|
70
|
+
button('Accept analytics', () => choose(true), choices)
|
|
71
|
+
region.appendChild(choices)
|
|
72
|
+
const manage = button('Manage privacy preferences', () => { choices.hidden = false; choices.querySelector('button')?.focus?.() }, region)
|
|
73
|
+
const revoke = button('Revoke analytics consent', () => choose(false), region)
|
|
74
|
+
const target = env.window || env
|
|
75
|
+
const storageChanged = event => { if (event.key === KEY || event.key === null) { delete env[MEMORY]; notify(env); refresh() } }
|
|
76
|
+
target.addEventListener?.('brain:consent', refresh)
|
|
77
|
+
target.addEventListener?.('storage', storageChanged)
|
|
78
|
+
refresh(); doc.body.appendChild(region)
|
|
79
|
+
return { element: region, stop() { target.removeEventListener?.('brain:consent', refresh); target.removeEventListener?.('storage', storageChanged); region.parentNode?.removeChild(region) } }
|
|
35
80
|
}
|
|
36
|
-
export const consented = kind => !!read()[kind]
|
|
37
81
|
|
|
38
82
|
apply(read()) // a returning visitor: their earlier answer, before any tag reads the defaults
|
|
83
|
+
|
|
84
|
+
// The public consent API also invalidates page-local decisions across tabs when no collector or banner is mounted.
|
|
85
|
+
// One page-lifetime listener is shared by the generated bridge and separately imported consent module.
|
|
86
|
+
const STORAGE_SYNC = Symbol.for('brain:consent-storage-sync')
|
|
87
|
+
const consentWindow = G.window || G
|
|
88
|
+
if (!G.__brainSSR && !G.__brainRoute && consentWindow.addEventListener && !G[STORAGE_SYNC]) {
|
|
89
|
+
G[STORAGE_SYNC] = true
|
|
90
|
+
consentWindow.addEventListener('storage', event => {
|
|
91
|
+
if (event.key !== KEY && event.key !== null) return
|
|
92
|
+
delete G[MEMORY]
|
|
93
|
+
notify(G)
|
|
94
|
+
const state = read()
|
|
95
|
+
const deniedMissing = {}
|
|
96
|
+
for (const kind of Object.keys(TYPES)) deniedMissing[kind] = state[kind] === true
|
|
97
|
+
apply(deniedMissing)
|
|
98
|
+
})
|
|
99
|
+
}
|
package/brain-google.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
const TOKEN = /^[\w.:-]{1,64}$/
|
|
2
|
+
|
|
3
|
+
// Explicit basic-mode Google bridge. It runs without the first-party collector or a native backend.
|
|
4
|
+
// Only declared event names cross the boundary: props, DOM text, field values, URLs and visitor ids never do.
|
|
5
|
+
export function startGoogle(config = {}, env = globalThis) {
|
|
6
|
+
const doc = env.document, win = env.window || env, nav = env.navigator || {}
|
|
7
|
+
const id = config.analytics || config.tagManager
|
|
8
|
+
if (env.__brainSSR || env.__brainRoute || !doc || config.mode !== 'basic' || !id || (config.analytics && config.tagManager)) return null
|
|
9
|
+
if (config.analytics ? !/^G-[A-Z0-9]{1,32}$/.test(id) : !/^GTM-[A-Z0-9]{1,32}$/.test(id)) return null
|
|
10
|
+
if (nav.globalPrivacyControl === true || nav.doNotTrack === '1' || win.doNotTrack === '1' || nav.webdriver) return null
|
|
11
|
+
const eventName = name => typeof name === 'string' && /^[A-Za-z][A-Za-z0-9_]{0,39}$/.test(name) && !/^(google_|ga_|firebase_)/i.test(name)
|
|
12
|
+
const events = Object.create(null)
|
|
13
|
+
for (const [source, name] of Object.entries(config.events || {}).slice(0, 100)) if (TOKEN.test(source) && eventName(name)) events[source] = name
|
|
14
|
+
const funnels = Object.entries(config.funnels || {}).slice(0, 20).filter(([name, steps]) => eventName(name) && Array.isArray(steps) && steps.length >= 2 && steps.length <= 20)
|
|
15
|
+
let stopped = false, initialized = false, loaded = false, lastGranted = false, loader = null
|
|
16
|
+
const pending = new Set(), off = []
|
|
17
|
+
const allowed = () => {
|
|
18
|
+
if (stopped || nav.globalPrivacyControl === true || nav.doNotTrack === '1' || win.doNotTrack === '1') return false
|
|
19
|
+
try {
|
|
20
|
+
const memory = env[Symbol.for('brain:consent-state')]
|
|
21
|
+
if (memory && Object.hasOwn(memory, 'analytics')) return memory.analytics === true
|
|
22
|
+
return JSON.parse(env.localStorage.getItem('brain:consent'))?.analytics === true
|
|
23
|
+
} catch { return false }
|
|
24
|
+
}
|
|
25
|
+
const listen = (target, type, fn) => {
|
|
26
|
+
if (!target?.addEventListener) return
|
|
27
|
+
target.addEventListener(type, fn, true)
|
|
28
|
+
off.push(() => target.removeEventListener(type, fn, true))
|
|
29
|
+
}
|
|
30
|
+
const clearPending = () => {
|
|
31
|
+
if (Array.isArray(env.dataLayer)) for (let i = env.dataLayer.length - 1; i >= 0; i--) if (pending.has(env.dataLayer[i])) env.dataLayer.splice(i, 1)
|
|
32
|
+
pending.clear()
|
|
33
|
+
}
|
|
34
|
+
const privacy = () => {
|
|
35
|
+
let origin = 'https://example.invalid/'
|
|
36
|
+
try { const url = new URL(env.location?.origin); if (/^https?:$/.test(url.protocol)) origin = url.origin + '/' } catch {}
|
|
37
|
+
return { page_location: origin, page_referrer: '', page_title: '[redacted]', send_page_view: false, allow_google_signals: false, allow_ad_personalization_signals: false }
|
|
38
|
+
}
|
|
39
|
+
const sync = () => {
|
|
40
|
+
try {
|
|
41
|
+
const granted = allowed()
|
|
42
|
+
if (config.analytics) env['ga-disable-' + id] = !granted
|
|
43
|
+
if (!granted) {
|
|
44
|
+
clearPending()
|
|
45
|
+
if (initialized && lastGranted) env.gtag('consent', 'update', { analytics_storage: 'denied' })
|
|
46
|
+
lastGranted = false
|
|
47
|
+
return false
|
|
48
|
+
}
|
|
49
|
+
if (initialized) {
|
|
50
|
+
if (!lastGranted) env.gtag('consent', 'update', { analytics_storage: 'granted' })
|
|
51
|
+
lastGranted = true
|
|
52
|
+
return true
|
|
53
|
+
}
|
|
54
|
+
// Do not adopt a malformed/preconfigured data layer. Never overwrite another integration's queue.
|
|
55
|
+
if (env.dataLayer !== undefined && !Array.isArray(env.dataLayer)) return false
|
|
56
|
+
env.dataLayer ||= []
|
|
57
|
+
if (env.gtag !== undefined && typeof env.gtag !== 'function') return false
|
|
58
|
+
env.gtag ||= function () { env.dataLayer.push(arguments) }
|
|
59
|
+
env.gtag('consent', 'default', { analytics_storage: 'denied', ad_storage: 'denied', ad_user_data: 'denied', ad_personalization: 'denied' })
|
|
60
|
+
env.gtag('consent', 'update', { analytics_storage: 'granted', ad_storage: 'denied', ad_user_data: 'denied', ad_personalization: 'denied' })
|
|
61
|
+
if (config.analytics) { env.gtag('js', new Date()); env.gtag('config', id, privacy()) }
|
|
62
|
+
else env.dataLayer.push({ 'gtm.start': Date.now(), event: 'gtm.js' })
|
|
63
|
+
loader = doc.createElement('script')
|
|
64
|
+
loader.async = true
|
|
65
|
+
loader.onload = () => { loaded = true; pending.clear() }
|
|
66
|
+
loader.src = 'https://www.googletagmanager.com/' + (config.analytics ? 'gtag/js' : 'gtm.js') + '?id=' + id
|
|
67
|
+
const parent = doc.head || doc.documentElement
|
|
68
|
+
if (!parent) return false
|
|
69
|
+
parent.appendChild(loader)
|
|
70
|
+
initialized = lastGranted = true
|
|
71
|
+
return true
|
|
72
|
+
} catch { return false }
|
|
73
|
+
}
|
|
74
|
+
const track = source => {
|
|
75
|
+
if (typeof source !== 'string' || !Object.hasOwn(events, source) || !sync() || (!loaded && pending.size >= 128)) return false
|
|
76
|
+
const name = events[source], params = privacy()
|
|
77
|
+
delete params.send_page_view
|
|
78
|
+
// A declared funnel is a list of named steps, not an inferred conversion or a separate extra event.
|
|
79
|
+
for (const [funnel, steps] of funnels) {
|
|
80
|
+
const step = Array.isArray(steps) ? steps.indexOf(name) : -1
|
|
81
|
+
if (eventName(funnel) && step >= 0 && step < 20) { params.brain_funnel = funnel; params.brain_step = step + 1; break }
|
|
82
|
+
}
|
|
83
|
+
let message
|
|
84
|
+
if (config.analytics) {
|
|
85
|
+
params.send_to = id
|
|
86
|
+
// Arguments objects are the official gtag queue shape. Keep references so revocation drops our pending events.
|
|
87
|
+
message = (function () { return arguments })('event', name, params)
|
|
88
|
+
} else message = { event: name, ...params }
|
|
89
|
+
if (!loaded) pending.add(message)
|
|
90
|
+
try { env.dataLayer.push(message); return true } catch { pending.delete(message); return false }
|
|
91
|
+
}
|
|
92
|
+
const tracked = target => {
|
|
93
|
+
let found = null
|
|
94
|
+
for (let el = target; el?.nodeType === 1; el = el.parentNode) {
|
|
95
|
+
if (el.hasAttribute('data-private')) return null
|
|
96
|
+
if (!found && el.hasAttribute('data-track')) found = el
|
|
97
|
+
}
|
|
98
|
+
return found
|
|
99
|
+
}
|
|
100
|
+
listen(doc, 'click', e => {
|
|
101
|
+
const el = tracked(e.target)
|
|
102
|
+
if (el && el.tagName !== 'FORM') track(el.getAttribute('data-track'))
|
|
103
|
+
})
|
|
104
|
+
listen(doc, 'submit', e => {
|
|
105
|
+
const el = e.target
|
|
106
|
+
if (el?.tagName === 'FORM' && tracked(el) === el) track(el.getAttribute('data-track'))
|
|
107
|
+
})
|
|
108
|
+
listen(win, 'brain:track', e => { if (e.detail && typeof e.detail.name === 'string') track(e.detail.name) })
|
|
109
|
+
listen(win, 'brain:consent', sync)
|
|
110
|
+
listen(win, 'storage', e => { if (e.key === 'brain:consent' || e.key === null) { delete env[Symbol.for('brain:consent-state')]; sync() } })
|
|
111
|
+
sync()
|
|
112
|
+
return { track, stop() { stopped = true; clearPending(); if (config.analytics) env['ga-disable-' + id] = true; for (const fn of off) fn(); if (loader?.parentNode) loader.parentNode.removeChild(loader) } }
|
|
113
|
+
}
|
package/brain-lazy.js
CHANGED
|
@@ -29,8 +29,11 @@ function reloadOnce() {
|
|
|
29
29
|
function visible(el, go) {
|
|
30
30
|
const box = { go }
|
|
31
31
|
const io = new IntersectionObserver(es => es.some(e => e.isIntersecting) && box.go?.(), { rootMargin: '200px' })
|
|
32
|
+
const doc = el.ownerDocument || G.document
|
|
33
|
+
const hidden = () => { if (doc?.visibilityState === 'hidden') box.go?.() }
|
|
34
|
+
doc?.addEventListener('visibilitychange', hidden)
|
|
32
35
|
io.observe(el)
|
|
33
|
-
return () => { box.go = null; io.disconnect() }
|
|
36
|
+
return () => { box.go = null; io.disconnect(); doc?.removeEventListener('visibilitychange', hidden) }
|
|
34
37
|
}
|
|
35
38
|
|
|
36
39
|
export function lazy(el, imp, mode, props = {}) {
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Optional initial-document performance estimates. Only declared patterns leave the browser.
|
|
2
|
+
import { consented } from './brain-consent.js'
|
|
3
|
+
import { vitals } from './brain-vitals.js'
|
|
4
|
+
const STATE = Symbol.for('brain:native-vitals')
|
|
5
|
+
const ROUTE = /^(?:\/|(?:\/(?:[A-Za-z0-9_-]+|:[a-z][a-z0-9_]*))+)$/
|
|
6
|
+
const MAX = { LCP: 3600000, CLS: 1000, INP: 3600000, FCP: 3600000, TTFB: 3600000 }
|
|
7
|
+
export function startNativeVitals(config, env = globalThis) {
|
|
8
|
+
if (env.__brainSSR || env.__brainRoute || !env.window || !env.document || !env.location || env[STATE]) return env[STATE]
|
|
9
|
+
if (!config || typeof config.base !== 'string' || !/^(?:\/[A-Za-z0-9_.-]+)*$/.test(config.base) || config.base.split('/').some(p => p === '.' || p === '..') || !Array.isArray(config.routes) || !config.routes.length || config.routes.length > 512 || config.routes.some(p => typeof p !== 'string' || p.length > 160 || !ROUTE.test(p)) || new Set(config.routes).size !== config.routes.length) return
|
|
10
|
+
let origin
|
|
11
|
+
try { const url = new URL(env.location.href); if (!/^https?:$/.test(url.protocol)) return; origin = url.origin } catch { return }
|
|
12
|
+
const initialPath = env.location.pathname
|
|
13
|
+
if (config.base && !initialPath.startsWith(config.base + '/')) return
|
|
14
|
+
const local = config.base ? initialPath.slice(config.base.length) : initialPath
|
|
15
|
+
const pathname = local.length > 1 && local.endsWith('/') ? local.slice(0, -1) : local
|
|
16
|
+
const parts = pathname.split('/')
|
|
17
|
+
let route, score = -1
|
|
18
|
+
for (const pattern of config.routes) {
|
|
19
|
+
const wanted = pattern.split('/')
|
|
20
|
+
if (wanted.length !== parts.length) continue
|
|
21
|
+
let matches = true, literal = 0
|
|
22
|
+
for (let i = 0; i < wanted.length; i++) {
|
|
23
|
+
if (wanted[i].startsWith(':')) { if (!parts[i]) { matches = false; break } }
|
|
24
|
+
else if (wanted[i] !== parts[i]) { matches = false; break }
|
|
25
|
+
else literal++
|
|
26
|
+
}
|
|
27
|
+
if (matches && literal > score) { route = pattern; score = literal }
|
|
28
|
+
}
|
|
29
|
+
if (!route) return
|
|
30
|
+
const target = env.window, doc = env.document, fresh = new Map()
|
|
31
|
+
let active = false, disposed = false, navigated = false, off, pending, timer, generation = 0
|
|
32
|
+
const dnt = value => value === '1' || value === 'yes' || value === true
|
|
33
|
+
const permitted = () => consented('analytics', env) && !dnt(env.navigator?.doNotTrack) && !dnt(env.navigator?.msDoNotTrack) && !dnt(env.doNotTrack) && env.navigator?.globalPrivacyControl !== true
|
|
34
|
+
const deactivate = () => {
|
|
35
|
+
active = false; generation++; off?.(); off = undefined; fresh.clear()
|
|
36
|
+
doc.removeEventListener('visibilitychange', flush); target.removeEventListener('pagehide', flush)
|
|
37
|
+
pending?.abort(); pending = undefined; env.clearTimeout?.(timer); timer = undefined
|
|
38
|
+
}
|
|
39
|
+
const navigation = () => { navigated = true; deactivate() }
|
|
40
|
+
const flush = event => {
|
|
41
|
+
if (!active || !permitted()) return deactivate()
|
|
42
|
+
if (env.location.pathname !== initialPath) return navigation()
|
|
43
|
+
if (event?.type !== 'pagehide' && doc.visibilityState !== 'hidden' || !fresh.size || pending) return
|
|
44
|
+
const body = JSON.stringify({ route, consent: true, metrics: [...fresh.values()] })
|
|
45
|
+
fresh.clear()
|
|
46
|
+
if (body.length > 4096) return
|
|
47
|
+
const controller = new env.AbortController(), epoch = generation
|
|
48
|
+
pending = controller
|
|
49
|
+
timer = env.setTimeout(() => controller.abort(), 3000)
|
|
50
|
+
Promise.resolve().then(() => active && epoch === generation && permitted() ? env.fetch(origin + config.base + '/_brain/vitals', {
|
|
51
|
+
method: 'POST', headers: { 'content-type': 'application/json' }, body,
|
|
52
|
+
mode: 'same-origin', credentials: 'omit', referrerPolicy: 'no-referrer', redirect: 'error', cache: 'no-store', keepalive: true, signal: controller.signal,
|
|
53
|
+
}) : undefined).then(response => { if (response?.status === 404) state.stop() }).catch(() => {}).finally(() => {
|
|
54
|
+
if (pending === controller) { pending = undefined; env.clearTimeout(timer); timer = undefined }
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
const update = () => {
|
|
58
|
+
if (disposed) return
|
|
59
|
+
if (!permitted()) return deactivate()
|
|
60
|
+
if (active || navigated || env.location.pathname !== initialPath || typeof env.fetch !== 'function' || !env.AbortController) return
|
|
61
|
+
active = true
|
|
62
|
+
const epoch = generation
|
|
63
|
+
off = vitals(metric => {
|
|
64
|
+
if (!active || epoch !== generation || !permitted()) return
|
|
65
|
+
if (Object.hasOwn(MAX, metric.name) && Number.isFinite(metric.value) && metric.value >= 0 && metric.value <= MAX[metric.name]) fresh.set(metric.name, { name: metric.name, value: metric.value })
|
|
66
|
+
}, env)
|
|
67
|
+
doc.addEventListener('visibilitychange', flush); target.addEventListener('pagehide', flush)
|
|
68
|
+
}
|
|
69
|
+
const storage = event => { if (event.key === 'brain:consent' || event.key === null) { delete env[Symbol.for('brain:consent-state')]; update() } }
|
|
70
|
+
const state = { stop() { disposed = true; deactivate(); target.removeEventListener('brain:navigate', navigation); target.removeEventListener('popstate', navigation); target.removeEventListener('brain:consent', update); target.removeEventListener('storage', storage); delete env[STATE] } }
|
|
71
|
+
env[STATE] = state
|
|
72
|
+
target.addEventListener('brain:consent', update); target.addEventListener('storage', storage)
|
|
73
|
+
// Lifetime events only: no performance access or collection occurs before consent. Revocation must not
|
|
74
|
+
// erase knowledge that the original document has navigated away and returned to the same pathname.
|
|
75
|
+
target.addEventListener('brain:navigate', navigation); target.addEventListener('popstate', navigation)
|
|
76
|
+
update()
|
|
77
|
+
return state
|
|
78
|
+
}
|
package/brain-sentry.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Optional error reporting. Consent owns the entire capture lifetime; raw values never leave this function.
|
|
2
|
+
import { _rt } from './brain.js'
|
|
3
|
+
import { consented } from './brain-consent.js'
|
|
4
|
+
|
|
5
|
+
const TYPES = new Set(['Error', 'TypeError', 'RangeError', 'ReferenceError', 'SyntaxError', 'URIError', 'EvalError', 'AggregateError'])
|
|
6
|
+
const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/
|
|
7
|
+
const STATE = Symbol.for('brain:sentry')
|
|
8
|
+
export function startSentry(config, env = globalThis, subscribe) {
|
|
9
|
+
if (env.__brainSSR || env.__brainRoute || !env.window || !env.location || env[STATE]) return env[STATE]
|
|
10
|
+
if (!config || !config.assets || typeof config.base !== 'string' || !/^(?:\/[a-zA-Z0-9_.-]+)*$/.test(config.base) || config.base.split('/').some(p => p === '.' || p === '..')) return
|
|
11
|
+
const assets = new Map()
|
|
12
|
+
for (const [path, id] of Object.entries(config.assets)) if (path.length <= 511 && /^(?:[a-zA-Z0-9_.-]+\/)*[a-z0-9]{8}\.(?:js|jsbrain)$/.test(path) && UUID.test(id)) assets.set('/' + path, id)
|
|
13
|
+
const target = env.window, pending = new Set(), seen = new Set()
|
|
14
|
+
let active = false, disposed = false, off, windowStart = 0, count = 0, generation = 0
|
|
15
|
+
const dnt = value => value === '1' || value === 'yes' || value === true
|
|
16
|
+
const permitted = () => consented('analytics', env) && !dnt(env.navigator?.doNotTrack) && !dnt(env.navigator?.msDoNotTrack) && !dnt(env.doNotTrack) && env.navigator?.globalPrivacyControl !== true
|
|
17
|
+
const capture = error => {
|
|
18
|
+
try {
|
|
19
|
+
if (!active || !permitted() || pending.size >= 4 || !error) return
|
|
20
|
+
const stack = error.stack
|
|
21
|
+
if (typeof stack !== 'string') return
|
|
22
|
+
const frames = []
|
|
23
|
+
for (const line of stack.slice(0, 8192).split('\n').slice(0, 41)) {
|
|
24
|
+
const m = /^\s+at (?:[^\r\n()]{1,160} \()?(https?:\/\/[^\s()]+):(\d{1,7}):(\d{1,7})\)?$/.exec(line) || /^[^\s@()]{0,160}@(https?:\/\/[^\s()]+):(\d{1,7}):(\d{1,7})$/.exec(line)
|
|
25
|
+
if (!m) continue
|
|
26
|
+
const url = new URL(m[1]), id = assets.get(url.pathname)
|
|
27
|
+
if (url.origin !== env.location.origin || url.search || url.hash || !id || +m[2] < 1 || +m[3] < 1) continue
|
|
28
|
+
frames.push({ asset: url.pathname, line: +m[2], column: +m[3], debugId: id })
|
|
29
|
+
if (frames.length >= 20) break
|
|
30
|
+
}
|
|
31
|
+
if (!frames.length) return
|
|
32
|
+
const name = error.name
|
|
33
|
+
const type = TYPES.has(name) ? name : 'Error'
|
|
34
|
+
const payload = JSON.stringify({ schemaVersion: 1, type, frames })
|
|
35
|
+
const now = Date.now()
|
|
36
|
+
if (now - windowStart >= 60000) { windowStart = now; count = 0; seen.clear() }
|
|
37
|
+
if (count >= 8 || seen.has(payload) || seen.size >= 32) return
|
|
38
|
+
count++; seen.add(payload)
|
|
39
|
+
const controller = new env.AbortController()
|
|
40
|
+
const epoch = generation
|
|
41
|
+
pending.add(controller)
|
|
42
|
+
const timer = env.setTimeout(() => controller.abort(), 3000)
|
|
43
|
+
Promise.resolve().then(() => active && epoch === generation && permitted() ? env.fetch(env.location.origin + config.base + '/_brain/sentry', {
|
|
44
|
+
method: 'POST', headers: { 'content-type': 'application/json' }, body: payload,
|
|
45
|
+
mode: 'same-origin', credentials: 'omit', referrerPolicy: 'no-referrer', redirect: 'error', cache: 'no-store', signal: controller.signal,
|
|
46
|
+
}) : undefined).catch(() => {}).finally(() => { env.clearTimeout(timer); pending.delete(controller) })
|
|
47
|
+
} catch {} // reporting, including hostile accessors, never enters the app's error routing
|
|
48
|
+
}
|
|
49
|
+
const onError = event => capture(event.error)
|
|
50
|
+
const onRejection = event => capture(event.reason)
|
|
51
|
+
const deactivate = () => {
|
|
52
|
+
active = false; generation++; off?.(); off = undefined
|
|
53
|
+
target.removeEventListener('error', onError); target.removeEventListener('unhandledrejection', onRejection)
|
|
54
|
+
for (const request of pending) request.abort()
|
|
55
|
+
pending.clear(); seen.clear(); count = 0; windowStart = 0
|
|
56
|
+
}
|
|
57
|
+
const update = () => {
|
|
58
|
+
if (disposed) return
|
|
59
|
+
if (!permitted()) return deactivate()
|
|
60
|
+
if (active || typeof env.fetch !== 'function' || !env.AbortController) return
|
|
61
|
+
active = true
|
|
62
|
+
off = (subscribe || _rt().listenErrors)(capture)
|
|
63
|
+
target.addEventListener('error', onError); target.addEventListener('unhandledrejection', onRejection)
|
|
64
|
+
}
|
|
65
|
+
const storage = event => { if (event.key === 'brain:consent' || event.key === null) update() }
|
|
66
|
+
target.addEventListener('brain:consent', update); target.addEventListener('storage', storage)
|
|
67
|
+
const state = { stop() { disposed = true; deactivate(); target.removeEventListener('brain:consent', update); target.removeEventListener('storage', storage); delete env[STATE] } }
|
|
68
|
+
env[STATE] = state
|
|
69
|
+
update()
|
|
70
|
+
return state
|
|
71
|
+
}
|
package/brain-vitals.js
CHANGED
|
@@ -45,14 +45,20 @@ export function vitals(report, env = globalThis) {
|
|
|
45
45
|
|
|
46
46
|
// One beacon each time the page is hidden, with what changed since the last one; the query string is never sent.
|
|
47
47
|
export function reportVitals(url, env = globalThis) {
|
|
48
|
-
if (typeof url !== 'string' || !url.startsWith('/')) throw new TypeError('reportVitals(url): pass a same-origin path, such as "/api/vitals"')
|
|
48
|
+
if (typeof url !== 'string' || url.length > 2048 || !url.startsWith('/') || url.startsWith('//') || /[\\\u0000-\u0020\u007f]/.test(url)) throw new TypeError('reportVitals(url): pass a same-origin path, such as "/api/vitals"')
|
|
49
|
+
let target
|
|
50
|
+
try {
|
|
51
|
+
const base = new URL(env.location.href)
|
|
52
|
+
target = new URL(url, base)
|
|
53
|
+
if (!/^https?:$/.test(base.protocol) || target.origin !== base.origin) throw new TypeError()
|
|
54
|
+
} catch { throw new TypeError('reportVitals(url): a same-origin HTTP(S) page is required') }
|
|
49
55
|
const doc = env.document
|
|
50
56
|
const fresh = new Map()
|
|
51
57
|
const stop = vitals(metric => fresh.set(metric.name, metric), env)
|
|
52
58
|
const flush = () => {
|
|
53
59
|
if (doc.visibilityState !== 'hidden' || !fresh.size) return
|
|
54
60
|
const body = JSON.stringify({ path: env.location.pathname, metrics: [...fresh.values()] })
|
|
55
|
-
if (env.navigator.sendBeacon?.(
|
|
61
|
+
if (env.navigator.sendBeacon?.(target.href, new Blob([body], { type: 'application/json' }))) fresh.clear()
|
|
56
62
|
}
|
|
57
63
|
doc.addEventListener('visibilitychange', flush)
|
|
58
64
|
env.addEventListener('pagehide', flush)
|
package/brain.js
CHANGED
|
@@ -5,13 +5,14 @@
|
|
|
5
5
|
// · r run · q clean · n unlink · d dispose. Short names: brain-min does not rename properties.
|
|
6
6
|
|
|
7
7
|
// L tracker · O owner · Q pending effects · B batch depth · P flush scheduled · H dev hook · E error hook · V provider (brain-ch)
|
|
8
|
-
let L = null, O = null, Q = [], B = 0, P = 0, H = null, E = null, V, A = null, U
|
|
8
|
+
let L = null, O = null, Q = [], B = 0, P = 0, H = null, E = null, V, A = null, U, Z, Y = 0
|
|
9
9
|
// Errors (INTERNALS §18): fx, drv and handlers are caught and the app keeps running. E (brain-err.js, _err) records
|
|
10
10
|
// them and applies per-component fallbacks; without it, console.error.
|
|
11
11
|
// Errors go to the rich catcher (brain-err) or the console, then to the nearest component boundary (§42, brain-guard.js):
|
|
12
12
|
// up the owner chain for fx/drv, up the DOM for handlers.
|
|
13
13
|
const oops = (e, x, w) => {
|
|
14
14
|
if (e && e.kind == 'aborted') return // a cancelled request (brain-data): never an error to report or show
|
|
15
|
+
if (Z && !Y) { Y = 1; for (const f of Z.slice()) { try { f(e) } catch {} } Y = 0 }
|
|
15
16
|
E ? E(e, x, w) : H ? globalThis.reportError ? reportError(e) : console.error(e) : console.error('BRAIN_ERROR: Operation failed. Try again.')
|
|
16
17
|
for (let o = x; o; o = o.nodeType ? o.parentNode : o.p) {
|
|
17
18
|
const g = o.$g
|
|
@@ -204,7 +205,7 @@ export const rpc = (id, a) => import('./brain-rpc.js').then(m => m.rpc(id, a, oo
|
|
|
204
205
|
// Internals for sibling modules (brain-err, brain-ch): root, current owner and node helpers; _err installs the hook;
|
|
205
206
|
// brain-ch passes its provider for `mount(C, t, props, { provide })`.
|
|
206
207
|
// Optimistic writes (brain-data) and the circuit breaker (brain-guard) wrap N's prototype instead of a core hook (#288).
|
|
207
|
-
export const _rt = p => ((V = V || p), { root, owner: () => O, nodes, rm, oops, guard: (o, g) => (o.$g = g), adopt: a => (A = a), dev: (a, b) => H && H(a, b), development: () => !!H, budget: f => (U = f) })
|
|
208
|
+
export const _rt = p => ((V = V || p), { root, owner: () => O, nodes, rm, oops, guard: (o, g) => (o.$g = g), adopt: a => (A = a), dev: (a, b) => H && H(a, b), development: () => !!H, budget: f => (U = f), listenErrors: f => { Z ||= []; if (typeof f != 'function' || Z.length >= 8) return () => {}; Z.push(f); return () => { Z = Z.filter(x => x != f) } } })
|
|
208
209
|
export const _err = h => ((E = h), _rt())
|
|
209
210
|
|
|
210
211
|
// ---------- DOM ----------
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@christianirack/brainllm-runtime",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "The BRAINLLM runtime for the Rust-built web compiler for LLMs: dependency-free ES modules (signals, DOM, router, data, forms, channels). The brain binary embeds this same version.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -28,11 +28,14 @@
|
|
|
28
28
|
"back-end",
|
|
29
29
|
"fullstack",
|
|
30
30
|
"full-stack",
|
|
31
|
+
"javascript",
|
|
32
|
+
"nodejs",
|
|
33
|
+
"html",
|
|
34
|
+
"css",
|
|
31
35
|
"cli",
|
|
32
36
|
"command-line",
|
|
33
37
|
"developer-tools",
|
|
34
38
|
"design-system",
|
|
35
|
-
"css",
|
|
36
39
|
"testing",
|
|
37
40
|
"e2e",
|
|
38
41
|
"postgresql",
|
|
@@ -49,6 +52,10 @@
|
|
|
49
52
|
"reactive",
|
|
50
53
|
"components",
|
|
51
54
|
"ssr",
|
|
55
|
+
"ssg",
|
|
56
|
+
"static-site-generator",
|
|
57
|
+
"static-site",
|
|
58
|
+
"seo",
|
|
52
59
|
"spa",
|
|
53
60
|
"web"
|
|
54
61
|
],
|