@christianirack/brainllm-runtime 0.8.12 → 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 = {}) {
package/brain-links.js CHANGED
@@ -14,8 +14,10 @@ export default function links(lang, b, p, re, s) {
14
14
  const t = tables[document.documentElement.getAttribute('lang')]
15
15
  if (!t || !h || h[0] !== '/' || h[1] === '/') return h
16
16
  const a = t.b && (h + '/').startsWith(t.b + '/') ? h.slice(t.b.length) || '/' : h
17
- const m = /^[^?#]*/.exec(a)[0]
18
- const n = t.s[m] || (t.re.test(m) && (m === '/' ? t.p : t.p + m))
17
+ const m = /^[^?#]*/.exec(a)[0], key = m.replace(/\/+$/, '') || '/'
18
+ if (key === t.p || key.startsWith(t.p + '/')) return h
19
+ let n = t.s[key] || (t.re.test(key) && (key === '/' ? t.p : t.p + key))
20
+ if (n && key !== '/' && m.endsWith('/') && !n.endsWith('/')) n += '/'
19
21
  return n ? t.b + n + a.slice(m.length) : h
20
22
  }
21
23
  G.__brainHref = to
package/brain-list.js ADDED
@@ -0,0 +1,9 @@
1
+ // Pure bounded integer iteration, outside the runtime core.
2
+ export function range(from, to) {
3
+ if (!Number.isSafeInteger(from) || !Number.isSafeInteger(to)) throw new RangeError('range requires two safe integer endpoints')
4
+ const length = Math.max(0, to - from)
5
+ if (length > 10000) throw new RangeError('range cannot emit more than 10000 integers')
6
+ const values = new Array(length)
7
+ for (let i = 0; i < length; i++) values[i] = from + i
8
+ return values
9
+ }
@@ -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
+ }
@@ -29,7 +29,7 @@ export async function prefetchPage(url, signal) {
29
29
  let hit
30
30
  while ((hit = pattern.exec(text)) && assets.length < 16) {
31
31
  const asset = new URL(hit[1] || hit[2] || hit[3], base)
32
- if (asset.origin === base.origin && asset.pathname.includes('/_b/')) assets.push(asset.href)
32
+ if (asset.origin === base.origin && (asset.pathname.includes('/_b/') || /\/[a-z0-9]{8}\.(?:js|mjs|jsbrain|css|cssbrain)$/.test(asset.pathname))) assets.push(asset.href)
33
33
  }
34
34
  return { ok: true, status: r.status, url: r.url, headers: r.headers, text, bytes, assets }
35
35
  } catch { return null }
package/brain-router.js CHANGED
@@ -96,6 +96,10 @@ export function router(o = {}) {
96
96
  const region = o.region || 'main'
97
97
  const parse = o.parse || (h => new DOMParser().parseFromString(h, 'text/html'))
98
98
  const fullLoad = new Set((o.fullLoad || []).map(p => new URL(p, location.href).pathname.replace(/\/+$/, '') || '/'))
99
+ // COOP belongs to the document, not the swapped view. Only real head markers opt into provider popups.
100
+ const popup = d => [...((d.head || d.querySelector('head'))?.querySelectorAll('meta') || [])].some(m =>
101
+ /^(brain-google-signin|brain-apple-signin)$/i.test(m.getAttribute('name') || '') && m.getAttribute('content') === '1')
102
+ const popupDocument = popup(document)
99
103
  let seq = 0, through = null, stale = false
100
104
  const ahead = predict(o, (u, p = true) => same(u, p))
101
105
  // Build signature (§35.7): the page's <meta name="brain-build">. A navigation response from another build, or the
@@ -205,6 +209,7 @@ export function router(o = {}) {
205
209
  if (!r.ok || !/html/.test(hd('content-type') || 'html')) throw Error(r.status)
206
210
  const doc = parse(await r.text())
207
211
  if (my !== seq) return // a newer navigation won
212
+ if (popup(doc) !== popupDocument) return hard(url) // entering or leaving a popup page needs fresh HTTP headers
208
213
  // another language (§37): a full load brings its own dictionaries, `lang` and `dir`; locales never mix in memory
209
214
  const lang = d => d?.documentElement?.getAttribute('lang')
210
215
  if (lang(doc) && lang(document) && lang(doc) !== lang(document)) return hard(url)
@@ -308,7 +313,7 @@ export function router(o = {}) {
308
313
  const h = e => {
309
314
  if (through && e.destination.url === through) return void (through = null)
310
315
  if (e.navigationType === 'reload') return // a reload is always a real full load (e.g. after a deploy deleted a chunk)
311
- if (!e.canIntercept || e.hashChange || e.downloadRequest || e.formData || !same(e.destination.url)) return
316
+ if (!e.canIntercept || e.hashChange || e.downloadRequest || e.formData || externalLink(e.sourceElement) || !same(e.destination.url)) return
312
317
  if (e.cancelable && !leave()) return e.preventDefault()
313
318
  const to = e.destination.url, from = nav.currentEntry?.url
314
319
  if (G.__brainURLOnly?.(from || location.href, to)) return e.intercept({ focusReset: 'manual', handler: () => G.__brainURLRestore?.() })
@@ -362,7 +367,7 @@ export function router(o = {}) {
362
367
  if (e.defaultPrevented || e.button || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
363
368
  let a = e.target
364
369
  while (a && a.tagName !== 'A') a = a.parentNode
365
- if (!a || !a.getAttribute('href') || a.getAttribute('target') || a.hasAttribute('download') || !same(a.href || a.getAttribute('href'))) return
370
+ if (!a || !a.getAttribute('href') || a.getAttribute('target') || a.hasAttribute('download') || externalLink(a) || !same(a.href || a.getAttribute('href'))) return
366
371
  const u = new URL(a.href || a.getAttribute('href'), location.href)
367
372
  if (u.pathname === location.pathname && u.hash) return // in-page anchor: native
368
373
  e.preventDefault()
@@ -416,7 +421,7 @@ export function predict(o = {}, same = () => false) {
416
421
  return !NOPE.test(u.pathname) && !FILE.test(u.pathname) && !SECRET.test(u.search)
417
422
  }
418
423
  const ok = a => {
419
- if (!a?.getAttribute?.('href') || a.getAttribute('target') || a.hasAttribute('download')) return null
424
+ if (!a?.getAttribute?.('href') || a.getAttribute('target') || a.hasAttribute('download') || externalLink(a)) return null
420
425
  for (let n = a; n?.getAttribute; n = n.parentNode) if (n.getAttribute('data-prefetch') === 'false') return null
421
426
  const k = key(a.href || a.getAttribute('href'))
422
427
  return safe(k) ? k : null
@@ -426,7 +431,7 @@ export function predict(o = {}, same = () => false) {
426
431
  let n = 0
427
432
  for (const href of list) {
428
433
  const u = key(href)
429
- if (!u || pre.has(u) || pre.size > 47 || !same(u) || !new URL(u).pathname.includes('/_b/') || ++n > 8) continue
434
+ if (!u || pre.has(u) || pre.size > 47 || !same(u) || !(new URL(u).pathname.includes('/_b/') || /\/[a-z0-9]{8}\.(?:js|mjs|jsbrain|css|cssbrain)$/.test(new URL(u).pathname)) || ++n > 8) continue
430
435
  const l = document.createElement('link')
431
436
  l.setAttribute('rel', /\.(js|jsbrain)(\?|$)/.test(u) ? 'modulepreload' : 'prefetch')
432
437
  l.setAttribute('href', u)
@@ -619,3 +624,5 @@ function hold(el) {
619
624
  },
620
625
  })
621
626
  }
627
+ // An absolute navigation can intentionally belong to another site on the same origin.
628
+ const externalLink = a => (a?.getAttribute?.('rel') || '').split(/\s+/).some(token => token.toLowerCase() === 'external') && /^https?:\/\//i.test((a?.getAttribute?.('href') || '').trim())
@@ -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-text.js ADDED
@@ -0,0 +1,56 @@
1
+ // Pure bounded Unicode matching; no locale, network, DOM or persistent state.
2
+ const letters = value => {
3
+ if (typeof value !== 'string' || value.length > 8192) throw new RangeError('text helpers require strings of at most 4096 Unicode characters')
4
+ const result = Array.from(value)
5
+ if (result.length > 4096) throw new RangeError('text helpers require strings of at most 4096 Unicode characters')
6
+ return result
7
+ }
8
+ const fold = value => {
9
+ letters(value)
10
+ const result = value.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase()
11
+ letters(result)
12
+ return result
13
+ }
14
+ // Optimal string alignment: adjacent transpositions count as one edit; the band and row minimum bound work.
15
+ const distance = (left, right, max = 2) => {
16
+ if (!Number.isInteger(max) || max < 0 || max > 64) throw new RangeError('text.distance max must be an integer from 0 to 64')
17
+ const a = letters(left), b = letters(right), n = a.length, m = b.length, cap = max + 1
18
+ if (Math.abs(n - m) > max) return cap
19
+ if (!n) return Math.min(m, cap)
20
+ if (!m) return Math.min(n, cap)
21
+ let before = new Uint16Array(m + 1).fill(cap)
22
+ let previous = new Uint16Array(m + 1).fill(cap)
23
+ let current = new Uint16Array(m + 1).fill(cap)
24
+ for (let j = 0; j <= Math.min(m, max); j++) previous[j] = j
25
+ for (let i = 1; i <= n; i++) {
26
+ const low = Math.max(1, i - max), high = Math.min(m, i + max)
27
+ current[0] = Math.min(i, cap)
28
+ if (low > 1) current[low - 1] = cap
29
+ if (high < m) current[high + 1] = cap
30
+ let minimum = current[0]
31
+ for (let j = low; j <= high; j++) {
32
+ let value = Math.min(previous[j] + 1, current[j - 1] + 1, previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1), cap)
33
+ if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) value = Math.min(value, before[j - 2] + 1)
34
+ current[j] = value
35
+ minimum = Math.min(minimum, value)
36
+ }
37
+ if (minimum > max) return cap
38
+ const spare = before; before = previous; previous = current; current = spare
39
+ }
40
+ return Math.min(previous[m], cap)
41
+ }
42
+ const fuzzy = (word, list) => {
43
+ const needle = fold(word), size = letters(needle).length
44
+ if (!Array.isArray(list) || list.length > 10000) throw new RangeError('text.fuzzy requires at most 10000 candidate strings')
45
+ const count = list.length, max = size < 4 ? 0 : size < 8 ? 1 : 2
46
+ const found = []
47
+ let units = 0
48
+ for (let i = 0; i < count; i++) {
49
+ const candidate = list[i]
50
+ if (typeof candidate !== 'string' || candidate.length > 1048576 - units) throw new RangeError('text.fuzzy candidate text exceeds 1048576 UTF-16 code units or contains a non-string')
51
+ units += candidate.length
52
+ if (distance(needle, fold(candidate), max) <= max) found.push(candidate)
53
+ }
54
+ return found
55
+ }
56
+ export const text = Object.freeze({ fold, distance, fuzzy })