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