@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.
@@ -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
- const store = env.localStorage
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
- return !!(JSON.parse(store.getItem('brain:consent')) || {}).analytics
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
- target.addEventListener(t, f, o)
271
- off.push(() => target.removeEventListener(t, f, o))
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
- hover = { el, t: setTimeout(() => (hovered.add(el), (hover = null), record('hover', el, HOVER_MS)), HOVER_MS) }
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 read = () => {
13
+ const MEMORY = Symbol.for('brain:consent-state')
14
+ const read = (env = G) => {
15
+ const state = {}
14
16
  try {
15
- return JSON.parse(G.localStorage.getItem(KEY)) || {}
16
- } catch {
17
- return {}
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
- if (typeof G.gtag === 'function') G.gtag('consent', 'update', u)
25
- else if (G.dataLayer) G.dataLayer.push(['consent', 'update', u]) // gtag not loaded yet: queued the way gtag reads it
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
- export function consent(kind, granted = true) {
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
- const s = { ...read(), [kind]: !!granted }
31
- try {
32
- G.localStorage.setItem(KEY, JSON.stringify(s))
33
- } catch {}
34
- apply({ [kind]: !!granted })
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
+ }
@@ -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
+ }
@@ -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?.(url, new Blob([body], { type: 'application/json' }))) fresh.clear()
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.9.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
  ],