@christianirack/brainllm-runtime 0.6.1 → 0.8.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-data.js +5 -2
- package/brain-env.js +8 -6
- package/brain-form.js +46 -36
- package/brain-inspect.js +19 -6
- package/brain-island.js +2 -1
- package/brain-lazy.js +2 -0
- package/brain-links.js +37 -0
- package/brain-live.js +21 -5
- package/brain-observe.js +120 -0
- package/brain-router.js +224 -178
- package/brain-schema.js +41 -9
- package/brain-trace.js +269 -0
- package/brain-vitals.js +60 -0
- package/package.json +1 -1
- package/server/llm.mjs +199 -0
- package/server/native-sockets.mjs +13 -10
- package/server/server.mjs +663 -105
- package/shim/dom.js +42 -7
- package/sw/brain-sw.js +17 -9
- package/ui/error-state.brain +1 -1
package/brain-router.js
CHANGED
|
@@ -17,16 +17,14 @@ import { prefetchPage } from './brain-prefetch-shared.js'
|
|
|
17
17
|
const G = globalThis
|
|
18
18
|
// Base path (<meta name="brain-base" content="/app/">, §23 #148): links in the HTML already carry it; guards and
|
|
19
19
|
// routes work with app paths (strip), and app paths given to go()/navigate()/redirect() get it (app). '/' when absent.
|
|
20
|
-
const base = () =>
|
|
21
|
-
const m = G.document && document.querySelector('meta[name=brain-base]')
|
|
22
|
-
return (m && m.getAttribute('content')) || '/'
|
|
23
|
-
}
|
|
20
|
+
const base = () => G.document?.querySelector('meta[name=brain-base]')?.getAttribute('content') || '/'
|
|
24
21
|
const strip = p => {
|
|
25
22
|
const b = base()
|
|
26
23
|
return b !== '/' && (p + '/').startsWith(b) ? '/' + p.slice(b.length) : p
|
|
27
24
|
}
|
|
28
25
|
const app = u => {
|
|
29
26
|
const b = base()
|
|
27
|
+
u = G.__brainHref?.(u) ?? u // the page's language (brain-links.js)
|
|
30
28
|
return typeof u === 'string' && b !== '/' && u.startsWith('/') && !u.startsWith('//') && !(u + '/').startsWith(b) ? b + u.slice(1) : u
|
|
31
29
|
}
|
|
32
30
|
|
|
@@ -38,13 +36,14 @@ export function navigate(to, o = {}) {
|
|
|
38
36
|
if (/[\\\x00-\x20\x7f]/.test(raw)) throw Error('Invalid navigation target')
|
|
39
37
|
const u = app(raw)
|
|
40
38
|
if (active) return active.go(u, o.replace ? 'replace' : true)
|
|
39
|
+
if (G.__brainDevGo) return G.__brainDevGo(u, o.replace)
|
|
41
40
|
o.replace ? location.replace(u) : location.assign(u)
|
|
42
41
|
}
|
|
43
42
|
G.__brainNavigate = navigate // brain-form follows a server function's { redirect } without importing the router
|
|
44
43
|
|
|
45
44
|
// route() lives in brain-env.js (it needs the core's signals; a page without islands downloads only this router):
|
|
46
45
|
// after every client navigation the router calls the hook it installs.
|
|
47
|
-
const routed = () => G.__brainRouted
|
|
46
|
+
const routed = () => G.__brainRouted?.()
|
|
48
47
|
|
|
49
48
|
// Active links (§41.5): every internal <a href> to the current page gets aria-current="page" (the CSS hook is
|
|
50
49
|
// [aria-current=page]); the others lose it. On start and after every client navigation.
|
|
@@ -70,7 +69,6 @@ const leave = () => {
|
|
|
70
69
|
}
|
|
71
70
|
|
|
72
71
|
// Same shapes as server.mjs, so one _guard.jsbrain works on both sides: { redirect, status } and { deny }
|
|
73
|
-
const R = Symbol('redirect') // older compiled guards
|
|
74
72
|
export function redirect(url, status = 303) {
|
|
75
73
|
throw { redirect: String(url), status }
|
|
76
74
|
}
|
|
@@ -78,13 +76,11 @@ export const deny = (reason = 'Forbidden') => ({ deny: String(reason) })
|
|
|
78
76
|
// guard result → true (go on) · false (cancel) · string (redirect) · null (full load: the server decides, e.g. 403)
|
|
79
77
|
const outcome = v =>
|
|
80
78
|
v && typeof v === 'object'
|
|
81
|
-
?
|
|
82
|
-
? v
|
|
83
|
-
:
|
|
84
|
-
?
|
|
85
|
-
:
|
|
86
|
-
? null
|
|
87
|
-
: true
|
|
79
|
+
? typeof v.redirect === 'string'
|
|
80
|
+
? v.redirect
|
|
81
|
+
: v.deny !== undefined
|
|
82
|
+
? null
|
|
83
|
+
: true
|
|
88
84
|
: v === false || typeof v === 'string'
|
|
89
85
|
? v
|
|
90
86
|
: true
|
|
@@ -92,18 +88,21 @@ const outcome = v =>
|
|
|
92
88
|
export function router(o = {}) {
|
|
93
89
|
const region = o.region || 'main'
|
|
94
90
|
const parse = o.parse || (h => new DOMParser().parseFromString(h, 'text/html'))
|
|
95
|
-
const fullLoad = new Set(
|
|
91
|
+
const fullLoad = new Set((o.fullLoad || []).map(p => new URL(p, location.href).pathname.replace(/\/+$/, '') || '/'))
|
|
96
92
|
let seq = 0, through = null, stale = false
|
|
97
|
-
const ahead = predict(o, u => same(u,
|
|
93
|
+
const ahead = predict(o, (u, p = true) => same(u, p))
|
|
98
94
|
// Build signature (§35.7): the page's <meta name="brain-build">. A navigation response from another build, or the
|
|
99
95
|
// service worker announcing one, turns client navigation into full loads so old and new code never mix.
|
|
100
|
-
const build = () =>
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
const
|
|
105
|
-
const
|
|
106
|
-
|
|
96
|
+
const build = () => document.querySelector('meta[name=brain-build]')?.getAttribute('content')
|
|
97
|
+
// The page's CSP nonce, read once while the document is still the server's: later, data scripts adopted from fetched
|
|
98
|
+
// pages (JSON-LD, b/state) carry their own response's nonce and could come first, and a mount script recreated with
|
|
99
|
+
// that nonce is blocked by the CSP.
|
|
100
|
+
const own = document.querySelector('script[nonce]'), nonce = own?.nonce || own?.getAttribute('nonce')
|
|
101
|
+
const sw = G.navigator?.serviceWorker
|
|
102
|
+
const onMsg = e => /^brain:build /.test(e.data) && build() && e.data.slice(12) !== build() && (stale = true, ahead.stop())
|
|
103
|
+
sw?.addEventListener?.('message', onMsg)
|
|
104
|
+
// both routers stop through here, so neither keeps the service worker listener alive (B05)
|
|
105
|
+
const quit = () => ((active = null), ahead.stop(), sw?.removeEventListener?.('message', onMsg))
|
|
107
106
|
// a guard is a function, or the URL of its `_guard.jsbrain` chunk (BUILD #220): loaded the first time a navigation
|
|
108
107
|
// reaches that folder, then cached by the module map; its default export runs with the same ctx
|
|
109
108
|
const gs = Object.entries(o.guards || {})
|
|
@@ -125,7 +124,7 @@ export function router(o = {}) {
|
|
|
125
124
|
v = await g(ctx)
|
|
126
125
|
} catch (e) {
|
|
127
126
|
if (asked) return null
|
|
128
|
-
return
|
|
127
|
+
return typeof e?.redirect === 'string' ? e.redirect : null
|
|
129
128
|
}
|
|
130
129
|
v = outcome(v)
|
|
131
130
|
if (v !== true) return asked ? null : v
|
|
@@ -133,106 +132,155 @@ export function router(o = {}) {
|
|
|
133
132
|
return true
|
|
134
133
|
}
|
|
135
134
|
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
|
|
135
|
+
// Feedback (§19): a navigation marks the region aria-busy and route().navigating at once; past 150 ms it shows
|
|
136
|
+
// #brain-nav: the app's own element with that id (a spinner in app.brain, `<div id="brain-nav" hidden>`) or a native
|
|
137
|
+
// indeterminate <progress> bar at the top (label "Loading"; colored by accent-color on #brain-nav, no extra
|
|
138
|
+
// DOM), so fast ones show nothing. _loading.brain (#235): past 400 ms the nearest loading view of the target route
|
|
139
|
+
// (`loading: [{ rx, view }]`, most specific first, emitted by the build with the `core` URL) replaces the region until
|
|
140
|
+
// the page arrives. Until then the current page stays as it is.
|
|
141
|
+
const loads = o.loading || [], adopt = n => document.adoptNode?.(n) || n
|
|
142
|
+
// the previous page's click hold ends with it, mounted or not
|
|
143
|
+
const dispose = r => { r.$hold?.(); for (const e of [r, ...r.querySelectorAll('*')]) if (e.$d) e.$d(), (e.$d = null) }
|
|
144
|
+
const fixed = (t, id, css) => {
|
|
145
|
+
const b = document.body.appendChild(document.createElement(t))
|
|
146
|
+
b.id = id
|
|
147
|
+
b.style.cssText = 'position:fixed;' + css
|
|
148
|
+
return b
|
|
149
|
+
}
|
|
150
|
+
const ui = on => {
|
|
151
|
+
let b = document.getElementById('brain-nav')
|
|
152
|
+
if (!b && on) (b = fixed('progress', 'brain-nav', 'top:0;left:0;width:100%;height:3px')).ariaLabel = 'Loading'
|
|
153
|
+
if (b) b.hidden = !on
|
|
154
|
+
}
|
|
155
|
+
const busy = (my, on) => {
|
|
156
|
+
const el = my === seq && document.querySelector(region)
|
|
157
|
+
if (!el) return
|
|
158
|
+
on ? el.setAttribute('aria-busy', 'true') : (el.removeAttribute('aria-busy'), ui())
|
|
159
|
+
G.__brainRouted?.(on)
|
|
160
|
+
}
|
|
140
161
|
function waiting(url, my) {
|
|
141
|
-
const path = strip(new URL(url, location.href).pathname), l = loads.find(x =>
|
|
142
|
-
const w = {
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
162
|
+
const path = strip(new URL(url, location.href).pathname), l = loads.find(x => [x.rx].flat().some(rx => new RegExp(rx).test(path)))
|
|
163
|
+
const w = {}, at = (ms, f) => setTimeout(() => my === seq && !w.done && f(), ms)
|
|
164
|
+
at(150, () => ui(1))
|
|
165
|
+
at(8e3, () => (seq++, hard(url))) // a request that never answers (a hung prefetch included) ends in a full load
|
|
166
|
+
if (l && o.core)
|
|
167
|
+
at(400, () =>
|
|
168
|
+
Promise.all([import(o.core), import(l.view)])
|
|
169
|
+
.then(([c, v]) => {
|
|
170
|
+
const el = document.querySelector(region), mount = c.m || c.mount
|
|
171
|
+
if (w.done || my !== seq || !el || !mount) return // the page arrived first
|
|
172
|
+
dispose(el)
|
|
173
|
+
for (const n of [...el.childNodes]) if (!/^(STYLE|LINK)$/.test(n.nodeName)) n.remove() // the view's CSS stays (BUILD #35)
|
|
174
|
+
el.$d = mount(v.default, el, {})
|
|
175
|
+
})
|
|
176
|
+
.catch(() => {}),
|
|
177
|
+
)
|
|
155
178
|
return w
|
|
156
179
|
}
|
|
157
180
|
|
|
181
|
+
let navFetch // the navigation in flight: a newer one aborts its fetch
|
|
182
|
+
// → true when this navigation swapped the region (a newer one, a full load or a failure → falsy)
|
|
158
183
|
async function load(url) {
|
|
159
184
|
const my = ++seq
|
|
160
|
-
let doc, r
|
|
161
185
|
if (stale) return hard(url)
|
|
186
|
+
navFetch?.abort()
|
|
187
|
+
const ctl = (navFetch = new AbortController())
|
|
162
188
|
const slow = waiting(url, my)
|
|
189
|
+
busy(my, 1)
|
|
163
190
|
// the previous route's background requests (prefetch) are dropped by brain-data's queue (§41.4)
|
|
191
|
+
G.dispatchEvent?.(new Event('brain:navigate'))
|
|
164
192
|
try {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
const tags = (r, skip) =>
|
|
213
|
-
[...((r && r.querySelectorAll && r.querySelectorAll('meta,script,link')) || [])].filter(e => {
|
|
214
|
-
for (let p = e.parentNode; p; p = p.parentNode) if (p === skip) return false
|
|
215
|
-
const a = n => e.getAttribute(n) || '', t = e.tagName
|
|
216
|
-
return t == 'SCRIPT' ? a('type') == 'application/ld+json'
|
|
217
|
-
: t == 'LINK' ? /^(canonical|prev|next)$/.test(a('rel')) || (a('rel') == 'alternate' && (a('hreflang') || a('type') == 'text/markdown'))
|
|
218
|
-
: /^(og:|twitter:|robots$|description$|brain-params$)/.test(a('property') || a('name'))
|
|
219
|
-
})
|
|
220
|
-
if (document.head) {
|
|
193
|
+
const r = (await ahead.take(url)) || (await fetch(url, { headers: { 'x-brain-nav': '1' }, signal: ctl.signal }))
|
|
194
|
+
if (my !== seq) return
|
|
195
|
+
const hd = k => r.headers?.get?.(k), b = hd('x-brain-build')
|
|
196
|
+
if (b && build() && b !== build()) return hard(url) // a new deploy: load it whole
|
|
197
|
+
if (!r.ok || !/html/.test(hd('content-type') || 'html')) throw Error(r.status)
|
|
198
|
+
const doc = parse(await r.text())
|
|
199
|
+
if (my !== seq) return // a newer navigation won
|
|
200
|
+
// another language (§37): a full load brings its own dictionaries, `lang` and `dir`; locales never mix in memory
|
|
201
|
+
const lang = d => d?.documentElement?.getAttribute('lang')
|
|
202
|
+
if (lang(doc) && lang(document) && lang(doc) !== lang(document)) return hard(url)
|
|
203
|
+
if (r.redirected && r.url && r.url !== new URL(url, location.href).href) history.replaceState(history.state, '', r.url)
|
|
204
|
+
const cur = document.querySelector(region), next = doc.querySelector(region)
|
|
205
|
+
if (!cur || !next) return hard(url)
|
|
206
|
+
// DOM, head and active links change in one callback (one frame); a newer navigation that started meanwhile skips it
|
|
207
|
+
const swap = () => {
|
|
208
|
+
if (my !== seq) return
|
|
209
|
+
dispose(cur)
|
|
210
|
+
cur.replaceChildren(...[...next.childNodes].map(adopt))
|
|
211
|
+
// Scripts parsed by DOMParser never run: the region's own scripts (the view's mount, `e.$d = mount(...)`) are
|
|
212
|
+
// recreated so they execute. Already-imported modules come from the module map: their state survives.
|
|
213
|
+
// The page's CSP only trusts its own nonce: a recreated script carries the current page's nonce, never the one
|
|
214
|
+
// that came with the fetched page (each server response has a fresh one).
|
|
215
|
+
for (const sc of cur.querySelectorAll('script')) {
|
|
216
|
+
if (sc.type && !/^(module|text\/javascript)$/.test(sc.type)) continue
|
|
217
|
+
const n = document.createElement('script')
|
|
218
|
+
for (const a of sc.attributes) if (a.name !== 'nonce') n.setAttribute(a.name, a.value)
|
|
219
|
+
if (nonce) n.setAttribute('nonce', nonce)
|
|
220
|
+
n.textContent = sc.textContent
|
|
221
|
+
sc.replaceWith(n)
|
|
222
|
+
}
|
|
223
|
+
// QA #291: the view's mount script stores its dispose (`e.$d = mount(...)`) after the swap, since module scripts
|
|
224
|
+
// run later; until then the region shows the server's HTML with no handlers. Clicks and submits there are held and
|
|
225
|
+
// replayed on the mounted view, by position, so the first click after a navigation is never lost.
|
|
226
|
+
if (!cur.$d && [...cur.querySelectorAll('script[type=module]')].some(sc => /\$d\s*=/.test(sc.textContent))) hold(cur)
|
|
227
|
+
for (const a of next.attributes) cur.setAttribute(a.name, a.value)
|
|
228
|
+
if (doc.title) document.title = doc.title
|
|
229
|
+
// SEO (§32, S04): the route owns description, og:*, twitter:*, robots, JSON-LD, canonical, hreflang and Markdown
|
|
230
|
+
// alternates, prev/next and its route params (brain-params, read by route()): the current page's go, the next
|
|
231
|
+
// page's come (none when it has none). Site links (icons, stylesheets, the RSS feed) and the region's own tags stay.
|
|
232
|
+
const tags = (r, skip) =>
|
|
233
|
+
[...(r.querySelectorAll?.('meta,script,link') || [])].filter(e => {
|
|
234
|
+
for (let p = e.parentNode; p; p = p.parentNode) if (p === skip) return false
|
|
235
|
+
const a = n => e.getAttribute(n) || '', t = e.tagName
|
|
236
|
+
return t == 'SCRIPT' ? a('type') == 'application/ld+json'
|
|
237
|
+
: t == 'LINK' ? /^(canonical|prev|next)$/.test(a('rel')) || (a('rel') == 'alternate' && (a('hreflang') || a('type') == 'text/markdown'))
|
|
238
|
+
: /^(og:|twitter:|robots$|description$|brain-params$)/.test(a('property') || a('name'))
|
|
239
|
+
})
|
|
221
240
|
for (const e of tags(document.head)) e.remove()
|
|
222
|
-
for (const e of tags(doc.head || doc, next)) document.head.appendChild(
|
|
241
|
+
for (const e of tags(doc.head || doc, next)) document.head.appendChild(adopt(e))
|
|
242
|
+
routed()
|
|
243
|
+
current()
|
|
244
|
+
document.getElementById('brain-next')?.remove()
|
|
245
|
+
const plan = doc.querySelector('#brain-next')
|
|
246
|
+
if (plan) document.body.appendChild(adopt(plan))
|
|
247
|
+
ahead.scan()
|
|
223
248
|
}
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
249
|
+
// View Transition when the browser has it (an app turns it off under prefers-reduced-motion with CSS, §19)
|
|
250
|
+
if (document.startViewTransition) {
|
|
251
|
+
const vt = document.startViewTransition(swap)
|
|
252
|
+
vt.ready.catch(() => {}) // rejects when a faster navigation skips this transition: expected, not an error
|
|
253
|
+
await vt.updateCallbackDone
|
|
254
|
+
} else swap()
|
|
255
|
+
return my === seq
|
|
256
|
+
} catch {
|
|
257
|
+
// a failed fetch, a parse or swap that threw: never a half-swapped page, the browser loads it whole
|
|
258
|
+
my === seq && hard(url)
|
|
259
|
+
} finally {
|
|
260
|
+
slow.done = 1
|
|
261
|
+
busy(my, 0)
|
|
230
262
|
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
263
|
+
}
|
|
264
|
+
// After a swap, for assistive technology (§19): focus moves to the URL's target, else the page's h1, else the region,
|
|
265
|
+
// unless the new page already holds focus; #brain-announce (polite, created at start) reads the new title.
|
|
266
|
+
const place = (u, lost) => {
|
|
267
|
+
const m = document.querySelector(region), a = document.activeElement
|
|
268
|
+
if (!m || (a && a !== document.body && a !== m && (lost || m.contains(a)))) return
|
|
269
|
+
const id = new URL(u, location.href).hash.slice(1), h = (id && document.getElementById(id)) || m.querySelector('h1') || m
|
|
270
|
+
h.hasAttribute('tabindex') || h.setAttribute('tabindex', '-1')
|
|
271
|
+
h.focus({ preventScroll: true })
|
|
272
|
+
}
|
|
273
|
+
const say = t => {
|
|
274
|
+
let a = document.getElementById('brain-announce')
|
|
275
|
+
if (!a) (a = fixed('div', 'brain-announce', 'clip-path:inset(50%)')).setAttribute('aria-live', 'polite')
|
|
276
|
+
a.textContent = t
|
|
277
|
+
}
|
|
278
|
+
say('')
|
|
279
|
+
const landed = u => {
|
|
280
|
+
place(u)
|
|
281
|
+
say(document.title)
|
|
282
|
+
const m = document.querySelector(region)
|
|
283
|
+
if (m?.$hold) m.$mounted = () => place(u, 1) // mounting replaces the server's nodes: a focus lost there comes back
|
|
236
284
|
}
|
|
237
285
|
// a full load must not be intercepted again (Navigation API): `through` lets that one navigation pass
|
|
238
286
|
const hard = url => ((through = new URL(url, location.href).href), (o.hard || (u => location.assign(u)))(url))
|
|
@@ -240,7 +288,7 @@ export function router(o = {}) {
|
|
|
240
288
|
try {
|
|
241
289
|
const x = new URL(u, location.href), b = base()
|
|
242
290
|
// same origin and inside the app's base path (§23 #148); anything else is a full load
|
|
243
|
-
return x.origin === location.origin && (x.pathname + '/').startsWith(b) && !
|
|
291
|
+
return x.origin === location.origin && (x.pathname + '/').startsWith(b) && !o.skip?.(x) && (prefetch || !fullLoad.has(x.pathname.replace(/\/+$/, '') || '/'))
|
|
244
292
|
} catch {
|
|
245
293
|
return false
|
|
246
294
|
}
|
|
@@ -253,11 +301,13 @@ export function router(o = {}) {
|
|
|
253
301
|
if (e.navigationType === 'reload') return // a reload is always a real full load (e.g. after a deploy deleted a chunk)
|
|
254
302
|
if (!e.canIntercept || e.hashChange || e.downloadRequest || e.formData || !same(e.destination.url)) return
|
|
255
303
|
if (e.cancelable && !leave()) return e.preventDefault()
|
|
256
|
-
const to = e.destination.url, from = nav.currentEntry
|
|
257
|
-
if (G.__brainURLOnly?.(from || location.href, to)) return e.intercept({ handler: () => G.__brainURLRestore?.() })
|
|
258
|
-
|
|
304
|
+
const to = e.destination.url, from = nav.currentEntry?.url
|
|
305
|
+
if (G.__brainURLOnly?.(from || location.href, to)) return e.intercept({ focusReset: 'manual', handler: () => G.__brainURLRestore?.() })
|
|
306
|
+
const shown = u => load(u).then(ok => ok && landed(u)) // the browser restores scroll; focus is ours (it would reset to body)
|
|
307
|
+
if (!gs.length) return e.intercept({ focusReset: 'manual', handler: () => shown(to) })
|
|
259
308
|
let g
|
|
260
309
|
e.intercept({
|
|
310
|
+
focusReset: 'manual',
|
|
261
311
|
// Chrome 141+: decided before the URL commits (cancel = the address bar never changes; native redirect).
|
|
262
312
|
async precommitHandler(c) {
|
|
263
313
|
g = await check(to)
|
|
@@ -266,7 +316,7 @@ export function router(o = {}) {
|
|
|
266
316
|
},
|
|
267
317
|
async handler() {
|
|
268
318
|
if (g === undefined) g = await check(to) // no precommit support: the URL already committed
|
|
269
|
-
if (g === true) return
|
|
319
|
+
if (g === true) return shown(nav.currentEntry ? nav.currentEntry.url : to)
|
|
270
320
|
if (g === null) return hard(to)
|
|
271
321
|
nav.navigate(g === false ? from : new URL(app(g), location.href).href, { history: 'replace' })
|
|
272
322
|
},
|
|
@@ -277,7 +327,7 @@ export function router(o = {}) {
|
|
|
277
327
|
return (active = {
|
|
278
328
|
go: (u, how) => nav.navigate(app(u), how === 'replace' ? { history: 'replace' } : undefined),
|
|
279
329
|
check,
|
|
280
|
-
stop: () => ((
|
|
330
|
+
stop: () => (quit(), nav.removeEventListener('navigate', h)),
|
|
281
331
|
})
|
|
282
332
|
}
|
|
283
333
|
|
|
@@ -292,12 +342,12 @@ export function router(o = {}) {
|
|
|
292
342
|
if (g === false) return push || history.pushState(history.state, '', here) // back/forward blocked: stay
|
|
293
343
|
if (g !== true) return go(new URL(app(g), location.href).href, push, hops + 1)
|
|
294
344
|
if (push) save(), history[push === 'replace' ? 'replaceState' : 'pushState']({ y: 0 }, '', u) // push: true | 'replace' | false (back/forward)
|
|
295
|
-
await load(u)
|
|
345
|
+
if (!(await load(u))) return // a newer navigation (or a full load) owns scroll and focus now
|
|
296
346
|
here = location.href
|
|
297
347
|
const hash = new URL(u, location.href).hash, t = hash && document.getElementById(hash.slice(1))
|
|
298
348
|
if (t) t.scrollIntoView()
|
|
299
|
-
else G.scrollTo
|
|
300
|
-
|
|
349
|
+
else G.scrollTo?.(0, push ? 0 : history.state?.y || 0)
|
|
350
|
+
landed(u)
|
|
301
351
|
}
|
|
302
352
|
const click = e => {
|
|
303
353
|
if (e.defaultPrevented || e.button || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
|
|
@@ -313,20 +363,13 @@ export function router(o = {}) {
|
|
|
313
363
|
if (G.__brainURLOnly?.(here, location.href)) { here = location.href; G.__brainURLRestore?.(); return }
|
|
314
364
|
return go(location.href, false)
|
|
315
365
|
}
|
|
316
|
-
const focusMain = () => {
|
|
317
|
-
const m = document.querySelector(region), h = m && (m.querySelector('h1') || m)
|
|
318
|
-
if (h && h.focus) {
|
|
319
|
-
if (!h.hasAttribute('tabindex')) h.setAttribute('tabindex', '-1')
|
|
320
|
-
h.focus({ preventScroll: true }) // screen readers announce the new view
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
366
|
document.addEventListener('click', click)
|
|
324
|
-
G.addEventListener
|
|
367
|
+
G.addEventListener?.('popstate', pop)
|
|
325
368
|
ahead.scan()
|
|
326
369
|
return (active = {
|
|
327
370
|
go: (u, push = true) => go(app(u), push),
|
|
328
371
|
check,
|
|
329
|
-
stop: () => ((
|
|
372
|
+
stop: () => (quit(), document.removeEventListener('click', click), G.removeEventListener?.('popstate', pop)),
|
|
330
373
|
})
|
|
331
374
|
}
|
|
332
375
|
|
|
@@ -349,11 +392,11 @@ const FILE = /\.(?!html?$)[a-z0-9]{2,5}$/i
|
|
|
349
392
|
const SECRET = /(^|[?&])(token|code|key|sig|signature|auth|otp|state)=/i
|
|
350
393
|
export function predict(o = {}, same = () => false) {
|
|
351
394
|
const off = { scan() {}, stop() {}, take: async () => null }
|
|
352
|
-
const meta = G.document
|
|
395
|
+
const meta = G.document?.querySelector?.('meta[name=brain-prefetch]')
|
|
353
396
|
if (o.prefetch === false || (meta && /^(off|false|0)$/i.test(meta.getAttribute('content') || '')) || !G.document || !G.fetch) return off
|
|
354
|
-
const net = G.navigator
|
|
397
|
+
const net = G.navigator?.connection
|
|
355
398
|
const thin = () => !!net && (net.saveData || /(^|-)2g$/.test(net.effectiveType || ''))
|
|
356
|
-
const cache = new Map(), queue = [], pre = new Map(), running = new Map(), pending = new Map(), planned = new Map()
|
|
399
|
+
const cache = new Map(), rank = new Map(), queue = [], pre = new Map(), running = new Map(), pending = new Map(), planned = new Map()
|
|
357
400
|
let seen = new WeakSet(), busy = 0, count = 0, io = null, idle = 0, epoch = 0, dead = false, bytes = 0, worker = null, serial = 0
|
|
358
401
|
const key = h => {
|
|
359
402
|
try { const u = new URL(h, location.href); u.hash = ''; return u.href } catch { return null }
|
|
@@ -364,43 +407,41 @@ export function predict(o = {}, same = () => false) {
|
|
|
364
407
|
return !NOPE.test(u.pathname) && !FILE.test(u.pathname) && !SECRET.test(u.search)
|
|
365
408
|
}
|
|
366
409
|
const ok = a => {
|
|
367
|
-
if (!a
|
|
368
|
-
for (let n = a; n
|
|
410
|
+
if (!a?.getAttribute?.('href') || a.getAttribute('target') || a.hasAttribute('download')) return null
|
|
411
|
+
for (let n = a; n?.getAttribute; n = n.parentNode) if (n.getAttribute('data-prefetch') === 'false') return null
|
|
369
412
|
const k = key(a.href || a.getAttribute('href'))
|
|
370
413
|
return safe(k) ? k : null
|
|
371
414
|
}
|
|
372
415
|
const warm = list => {
|
|
373
|
-
if (
|
|
416
|
+
if (dead) return
|
|
374
417
|
let n = 0
|
|
375
418
|
for (const href of list) {
|
|
376
419
|
const u = key(href)
|
|
377
|
-
if (!u || pre.has(u) || !same(u) || !new URL(u).pathname.includes('/_b/') || ++n > 8) continue
|
|
420
|
+
if (!u || pre.has(u) || pre.size > 47 || !same(u) || !new URL(u).pathname.includes('/_b/') || ++n > 8) continue
|
|
378
421
|
const l = document.createElement('link')
|
|
379
422
|
l.setAttribute('rel', /\.(js|jsbrain)(\?|$)/.test(u) ? 'modulepreload' : 'prefetch')
|
|
380
423
|
l.setAttribute('href', u)
|
|
381
424
|
l.setAttribute('fetchpriority', 'low')
|
|
382
425
|
document.head.appendChild(l)
|
|
383
426
|
pre.set(u, l)
|
|
384
|
-
while (pre.size > 48) { const [k, node] = pre.entries().next().value; node.remove(); pre.delete(k) }
|
|
385
427
|
}
|
|
386
428
|
}
|
|
387
429
|
const remove = k => { const c = cache.get(k); if (c) bytes -= c.bytes || 0; cache.delete(k) }
|
|
388
430
|
const prune = () => {
|
|
389
|
-
for (const [k, c] of cache) if (Date.now() - c.at > FRESH) remove(k)
|
|
390
431
|
while (cache.size > MAX_KEPT || bytes > 4 * 1024 * 1024) remove(cache.keys().next().value)
|
|
391
432
|
}
|
|
392
433
|
const crashed = () => {
|
|
393
|
-
worker
|
|
434
|
+
worker?.terminate(); worker = null
|
|
394
435
|
for (const p of pending.values()) { clearTimeout(p.timer); p.resolve(null) }
|
|
395
436
|
pending.clear()
|
|
396
437
|
}
|
|
397
|
-
if (G.Worker &&
|
|
438
|
+
if (G.Worker && o.prefetchWorker) {
|
|
398
439
|
try {
|
|
399
440
|
const url = new URL(o.prefetchWorker, location.href)
|
|
400
441
|
if (url.origin === location.origin) {
|
|
401
442
|
worker = new Worker(url.href, { type: 'module' })
|
|
402
443
|
worker.onmessage = e => {
|
|
403
|
-
const m = e.data, p =
|
|
444
|
+
const m = e.data, p = pending.get(m?.id)
|
|
404
445
|
if (!p) return
|
|
405
446
|
clearTimeout(p.timer); pending.delete(m.id); p.resolve(m.ok ? m : null)
|
|
406
447
|
}
|
|
@@ -412,7 +453,7 @@ export function predict(o = {}, same = () => false) {
|
|
|
412
453
|
const background = (k, controller) => {
|
|
413
454
|
if (worker) return new Promise(resolve => {
|
|
414
455
|
const id = ++serial
|
|
415
|
-
const done = () => { const p = pending.get(id); if (p) { clearTimeout(p.timer); pending.delete(id); resolve(null); worker
|
|
456
|
+
const done = () => { const p = pending.get(id); if (p) { clearTimeout(p.timer); pending.delete(id); resolve(null); worker?.postMessage({ id, cancel: true }) } }
|
|
416
457
|
const timer = setTimeout(done, 6500)
|
|
417
458
|
pending.set(id, { resolve, timer })
|
|
418
459
|
controller.signal.addEventListener('abort', done, { once: true })
|
|
@@ -422,19 +463,20 @@ export function predict(o = {}, same = () => false) {
|
|
|
422
463
|
}
|
|
423
464
|
|
|
424
465
|
const get = k => {
|
|
425
|
-
const
|
|
466
|
+
const controller = new AbortController()
|
|
426
467
|
running.set(k, controller)
|
|
427
468
|
const timer = setTimeout(() => controller.abort(), 6000)
|
|
428
|
-
const c = { at, epoch
|
|
469
|
+
const c = { at: Date.now(), epoch, bytes: 0, t: timer }
|
|
429
470
|
c.p = background(k, controller).then(m => {
|
|
430
|
-
if (!m || dead ||
|
|
471
|
+
if (!m || dead || c.epoch !== epoch || controller.signal.aborted) { if (cache.get(k) === c) remove(k); return null }
|
|
431
472
|
const headers = Array.isArray(m.headers) ? new Headers(m.headers) : m.headers
|
|
432
473
|
const cc = headers.get('cache-control') || '', freshness = /(?:^|,)\s*(?:s-maxage|max-age)=(\d+)/i.exec(cc)
|
|
433
474
|
if (m.assets) warm(m.assets)
|
|
434
475
|
if (planned.has(k)) warm(planned.get(k))
|
|
435
|
-
// Personalized and dynamically revalidated HTML always goes back through the server on navigation
|
|
476
|
+
// Personalized and dynamically revalidated HTML always goes back through the server on navigation, unless that
|
|
477
|
+
// navigation was already waiting for this very request (taken in flight: as fresh as a new one).
|
|
436
478
|
// Speculation still warms its immutable code/assets; it never reuses another tab's prior session data.
|
|
437
|
-
if (/private|no-store/i.test(cc) || /no-cache/i.test(cc) && headers.get('x-brain-static') !== '1') { if (cache.get(k) === c) remove(k); return null }
|
|
479
|
+
if (!c.taken && (/private|no-store/i.test(cc) || /no-cache/i.test(cc) && headers.get('x-brain-static') !== '1')) { if (cache.get(k) === c) remove(k); return null }
|
|
438
480
|
// Transient page memory belongs to this tab and write epoch, never to CacheStorage or the worker.
|
|
439
481
|
c.bytes = m.text.length * 2
|
|
440
482
|
if (cache.get(k) === c) bytes += c.bytes
|
|
@@ -445,7 +487,7 @@ export function predict(o = {}, same = () => false) {
|
|
|
445
487
|
return c.p
|
|
446
488
|
}
|
|
447
489
|
const pump = () => {
|
|
448
|
-
if (dead || document.hidden || net
|
|
490
|
+
if (dead || document.hidden || net?.saveData) return
|
|
449
491
|
while (busy < PARALLEL && queue.length) {
|
|
450
492
|
const k = queue.shift()
|
|
451
493
|
if (cache.has(k)) continue
|
|
@@ -454,16 +496,19 @@ export function predict(o = {}, same = () => false) {
|
|
|
454
496
|
}
|
|
455
497
|
}
|
|
456
498
|
const later = f => G.requestIdleCallback ? G.requestIdleCallback(f, { timeout: 2000 }) : setTimeout(f, 200)
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
499
|
+
// The queue is ordered by rank, lower first: intent, then main content by position, the compiler's plan, then footer
|
|
500
|
+
// and aside. A click never waits here: take() or its own fetch.
|
|
501
|
+
const want = (k, now, at = 1e6) => {
|
|
502
|
+
if (!k || dead || cache.has(k) || count >= (now ? MAX_KEPT : MAX_PAGE) || (!now && !same(k, false))) return // only intent warms a full-load route
|
|
460
503
|
const i = queue.indexOf(k)
|
|
461
504
|
if (i >= 0) queue.splice(i, 1); else count++
|
|
462
|
-
|
|
463
|
-
|
|
505
|
+
rank.set(k, now ? -1 : at)
|
|
506
|
+
queue.push(k)
|
|
507
|
+
queue.sort((a, b) => rank.get(a) - rank.get(b))
|
|
508
|
+
if (now) pump()
|
|
509
|
+
else if (!idle) idle = later(() => { idle = 0; pump() })
|
|
464
510
|
}
|
|
465
511
|
const intent = e => {
|
|
466
|
-
if (net && net.saveData) return
|
|
467
512
|
let a = e.target
|
|
468
513
|
while (a && a.tagName !== 'A') a = a.parentNode
|
|
469
514
|
want(ok(a), true)
|
|
@@ -471,42 +516,43 @@ export function predict(o = {}, same = () => false) {
|
|
|
471
516
|
const evs = ['pointerover', 'focusin', 'touchstart']
|
|
472
517
|
for (const t of evs) document.addEventListener(t, intent, { passive: true, capture: true })
|
|
473
518
|
const forget = () => {
|
|
474
|
-
epoch++;
|
|
519
|
+
epoch++; queue.length = 0; cache.clear(); bytes = 0; count = 0
|
|
475
520
|
for (const c of running.values()) c.abort()
|
|
476
521
|
running.clear()
|
|
477
522
|
}
|
|
478
523
|
document.addEventListener('submit', forget, { capture: true })
|
|
479
|
-
|
|
480
|
-
const visible = () => { if (!document.hidden) pump() }
|
|
481
|
-
document.addEventListener('visibilitychange', visible)
|
|
524
|
+
G.addEventListener?.('brain:write', forget)
|
|
482
525
|
if (G.IntersectionObserver) io = new IntersectionObserver(es => {
|
|
483
|
-
for (const
|
|
526
|
+
for (const { target: a, isIntersecting } of es) {
|
|
527
|
+
const k = ok(a), i = queue.indexOf(k)
|
|
528
|
+
if (isIntersecting) thin() || want(k, false, (a.closest('footer,aside') ? 1e7 : 0) + a.getBoundingClientRect().top)
|
|
529
|
+
else if (i >= 0) queue.splice(i, 1) // left the viewport before its turn
|
|
530
|
+
}
|
|
484
531
|
}, { rootMargin: '200px' })
|
|
485
532
|
return {
|
|
486
533
|
scan() {
|
|
487
534
|
if (dead) return
|
|
488
|
-
count = 0;
|
|
535
|
+
count = 0; queue.length = 0; planned.clear(); seen = new WeakSet(); io?.disconnect()
|
|
489
536
|
const anchors = document.querySelectorAll('a[href]'), allowed = new Set()
|
|
490
537
|
for (const a of anchors) { const k = ok(a); if (k) allowed.add(k); if (io && !seen.has(a)) { seen.add(a); io.observe(a) } }
|
|
491
538
|
// Compiler-generated graph, validated against the current DOM: hidden/opted-out links cannot sneak in.
|
|
492
|
-
const plan = document.getElementById
|
|
539
|
+
const plan = document.getElementById('brain-next')
|
|
493
540
|
if (plan && !thin()) {
|
|
494
541
|
try {
|
|
495
542
|
const map = JSON.parse(plan.textContent)
|
|
496
|
-
if (map.v === 1
|
|
543
|
+
if (map.v === 1) for (const item of map.next.slice(0, MAX_PAGE)) {
|
|
497
544
|
const k = key(item.url)
|
|
498
|
-
if (allowed.has(k) && safe(k)) { if (
|
|
545
|
+
if (allowed.has(k) && safe(k)) { if (item.assets) planned.set(k, item.assets.slice(0, 16)); want(k, false) }
|
|
499
546
|
}
|
|
500
547
|
} catch {}
|
|
501
548
|
}
|
|
502
549
|
},
|
|
503
550
|
stop() {
|
|
504
551
|
if (dead) return
|
|
505
|
-
forget(); dead = true; crashed(); io
|
|
552
|
+
forget(); dead = true; crashed(); io?.disconnect()
|
|
506
553
|
for (const t of evs) document.removeEventListener(t, intent, { capture: true })
|
|
507
554
|
document.removeEventListener('submit', forget, { capture: true })
|
|
508
|
-
|
|
509
|
-
G.removeEventListener && G.removeEventListener('brain:write', forget)
|
|
555
|
+
G.removeEventListener?.('brain:write', forget)
|
|
510
556
|
for (const l of pre.values()) l.remove()
|
|
511
557
|
pre.clear()
|
|
512
558
|
},
|
|
@@ -514,11 +560,10 @@ export function predict(o = {}, same = () => false) {
|
|
|
514
560
|
const k = key(u), c = k && cache.get(k)
|
|
515
561
|
if (!c) return null
|
|
516
562
|
remove(k)
|
|
517
|
-
//
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
if (!r) { const controller = running.get(k); controller && controller.abort() }
|
|
563
|
+
// The navigation reuses the request already on its way (never a second one) and drops its speculative deadline.
|
|
564
|
+
clearTimeout(c.t)
|
|
565
|
+
c.taken = 1
|
|
566
|
+
const r = await c.p
|
|
522
567
|
return r && !dead && c.epoch === epoch && Date.now() - c.at < r.ttl ? r : null
|
|
523
568
|
},
|
|
524
569
|
}
|
|
@@ -526,11 +571,14 @@ export function predict(o = {}, same = () => false) {
|
|
|
526
571
|
|
|
527
572
|
const HOLD = ['click', 'submit']
|
|
528
573
|
function hold(el) {
|
|
529
|
-
if (el.$hold) el.$hold() // a newer navigation drops what the previous page held
|
|
530
574
|
const q = []
|
|
531
575
|
const keep = e => {
|
|
532
576
|
const path = []
|
|
533
|
-
|
|
577
|
+
// a link needs no mounted view: it navigates now (a real href; a `#` one waits for its handler like a button)
|
|
578
|
+
for (let n = e.target; n && n !== el; n = n.parentNode) {
|
|
579
|
+
if (n.tagName === 'A' && (n.getAttribute('href') || '#')[0] != '#') return
|
|
580
|
+
path.unshift([].indexOf.call(n.parentNode.childNodes, n))
|
|
581
|
+
}
|
|
534
582
|
q.push([e, path])
|
|
535
583
|
e.preventDefault()
|
|
536
584
|
e.stopImmediatePropagation()
|
|
@@ -551,15 +599,13 @@ function hold(el) {
|
|
|
551
599
|
set(v) {
|
|
552
600
|
done()
|
|
553
601
|
el.$d = v
|
|
602
|
+
el.$mounted?.()
|
|
603
|
+
el.$mounted = null
|
|
554
604
|
for (const [e, path] of q) {
|
|
555
605
|
let n = el
|
|
556
|
-
for (const i of path) n = n
|
|
557
|
-
|
|
606
|
+
for (const i of path) n = n?.childNodes[i]
|
|
607
|
+
n?.dispatchEvent(new e.constructor(e.type, e))
|
|
558
608
|
}
|
|
559
609
|
},
|
|
560
610
|
})
|
|
561
611
|
}
|
|
562
|
-
function walkAll(n, out = []) {
|
|
563
|
-
for (const c of n.childNodes) if (c.nodeType === 1) out.push(c), walkAll(c, out)
|
|
564
|
-
return out
|
|
565
|
-
}
|