@christianirack/brainllm-runtime 0.8.12 → 0.9.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-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
+ }
@@ -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())
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 })
package/brain-url.js CHANGED
@@ -45,10 +45,35 @@ export function urlState(initial, options) {
45
45
  }
46
46
 
47
47
  // A native UI primitive stays stateless in author source; its DOM adapter owns navigation state and cleanup.
48
- export function urlDialog(element, key) {
49
- if (!element || !key || G.__brainSSR || G.__brainRoute || !G.location) return
48
+ export function urlDialog(element, key, options) {
49
+ if (!element || G.__brainSSR || G.__brainRoute || !G.location) return
50
50
  return I.root(dispose => {
51
51
  let alive = true
52
+ if (options?.dismissible === true) {
53
+ let startedOutside = false
54
+ const outside = event => {
55
+ if (event.target !== element || !element.open) return false
56
+ const bounds = element.getBoundingClientRect()
57
+ return event.clientX < bounds.left || event.clientX > bounds.right || event.clientY < bounds.top || event.clientY > bounds.bottom
58
+ }
59
+ const down = event => { startedOutside = !event.defaultPrevented && event.button === 0 && event.isPrimary !== false && outside(event) }
60
+ const cancel = () => { startedOutside = false }
61
+ const click = event => {
62
+ const dismiss = alive && !event.defaultPrevented && startedOutside && event.button === 0 && outside(event)
63
+ startedOutside = false
64
+ if (dismiss) { event.preventDefault(); event.stopPropagation(); element.close() }
65
+ }
66
+ element.addEventListener('pointerdown', down)
67
+ element.addEventListener('pointercancel', cancel)
68
+ element.addEventListener('click', click)
69
+ fx(() => () => {
70
+ alive = false
71
+ element.removeEventListener('pointerdown', down)
72
+ element.removeEventListener('pointercancel', cancel)
73
+ element.removeEventListener('click', click)
74
+ })
75
+ }
76
+ if (!key) return dispose
52
77
  const opened = urlState(!!element.open, {key})
53
78
  const read = () => { if (alive) opened.v = !!element.open }
54
79
  const apply = value => {
@@ -59,7 +84,15 @@ export function urlDialog(element, key) {
59
84
  fx(() => {
60
85
  const value = opened.v
61
86
  if (element.isConnected) apply(value)
62
- else queueMicrotask(() => apply(value))
87
+ else {
88
+ const wasOpen = !!element.open
89
+ queueMicrotask(() => {
90
+ // An exposed open() may run immediately after mount, before this initial deferred sync.
91
+ // Preserve that newer DOM action instead of applying the old initial state over it.
92
+ if (alive && !!element.open !== wasOpen) read()
93
+ else apply(value)
94
+ })
95
+ }
63
96
  })
64
97
  const observer = G.MutationObserver ? new G.MutationObserver(read) : null
65
98
  observer?.observe(element, {attributes:true,attributeFilter:['open']})
package/brain.js CHANGED
@@ -350,20 +350,26 @@ export function _ev(e) {
350
350
  // a data property, not a getter: V8 keeps accessors in the event's shared hidden class, so a getter closure
351
351
  // would keep the first event (and its component) alive for the life of the page
352
352
  Object.defineProperty(e, 'currentTarget', { configurable: true, value: n })
353
- try {
354
- // n moves on with the loop: keep the element for a later rejection
355
- const r = h.call(n, e), el = n
356
- if (r && r.then) r.then(null, x => oops(x, el, 'on:' + e.type)) // async handler: a rejected await too
357
- } catch (x) {
358
- oops(x, n, 'on:' + e.type)
359
- }
353
+ eventCall(n, h, e)
360
354
  if (e.cancelBubble) break
361
355
  }
362
356
  }
363
357
  })
364
358
  }
359
+ function eventCall(el, h, e) {
360
+ try {
361
+ const r = h.call(el, e)
362
+ if (r && r.then) r.then(null, x => oops(x, el, 'on:' + e.type))
363
+ } catch (x) { oops(x, el, 'on:' + e.type) }
364
+ }
365
365
  export function on(el, t, h) {
366
- if (/^(focus|blur|mouseenter|mouseleave|load|error|scroll)$/.test(t)) return el.addEventListener(t, e => batch(() => h(e)))
366
+ if (/^(focus|blur|mouseenter|mouseleave|pointerenter|pointerleave|load|error|scroll|scrollend|resize|close|cancel|toggle|beforetoggle|invalid|abort|canplay|canplaythrough|durationchange|emptied|ended|loadeddata|loadedmetadata|loadstart|pause|play|playing|progress|ratechange|seeked|seeking|stalled|suspend|timeupdate|volumechange|waiting|encrypted|waitingforkey)$/.test(t)) {
367
+ const listener = e => batch(() => eventCall(el, h, e))
368
+ return fx(() => {
369
+ el.addEventListener(t, listener)
370
+ return () => el.removeEventListener(t, listener)
371
+ })
372
+ }
367
373
  el['$' + t] = h
368
374
  document.addEventListener(t, _ev) // once per type: the DOM ignores the same listener added again
369
375
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@christianirack/brainllm-runtime",
3
- "version": "0.8.12",
3
+ "version": "0.9.0",
4
4
  "description": "The BRAINLLM runtime for the Rust-built web compiler for LLMs: dependency-free ES modules (signals, DOM, router, data, forms, channels). The brain binary embeds this same version.",
5
5
  "type": "module",
6
6
  "exports": {
package/server/server.mjs CHANGED
@@ -26,25 +26,30 @@ import { installNativeSockets } from './native-sockets.mjs'
26
26
  import { llmClient } from './llm.mjs'
27
27
  import { BlockList, isIP } from 'node:net'
28
28
  import { Readable } from 'node:stream'
29
+ import { pipeline } from 'node:stream/promises'
29
30
  import zlib from 'node:zlib'
30
31
  import { createCipheriv, createDecipheriv, createHash, createHmac, hkdfSync, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'
31
- import { closeSync, existsSync, openSync, readFileSync, readSync, realpathSync, statSync, lstatSync } from 'node:fs'
32
+ import { constants as FS, existsSync, readFileSync, realpathSync, statSync, lstatSync } from 'node:fs'
33
+ import { open } from 'node:fs/promises'
32
34
  import * as Module from 'node:module'
33
35
  import { basename, dirname, extname, join, normalize, resolve, sep } from 'node:path'
34
36
  import { fileURLToPath, pathToFileURL } from 'node:url'
35
37
 
36
38
  const MIME = {
37
39
  '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8',
40
+ '.brain': 'text/html; charset=utf-8', '.jsbrain': 'text/javascript; charset=utf-8', '.cssbrain': 'text/css; charset=utf-8',
38
41
  '.css': 'text/css; charset=utf-8', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png',
39
42
  '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.avif': 'image/avif', '.gif': 'image/gif',
40
43
  '.ico': 'image/x-icon', '.woff2': 'font/woff2', '.woff': 'font/woff', '.txt': 'text/plain; charset=utf-8',
41
44
  '.xml': 'application/xml', '.webmanifest': 'application/manifest+json', '.map': 'application/json',
42
45
  '.mp4': 'video/mp4', '.webm': 'video/webm', '.pdf': 'application/pdf', '.wasm': 'application/wasm',
46
+ '.m3u8': 'application/vnd.apple.mpegurl', '.m3u': 'application/vnd.apple.mpegurl', '.m4s': 'video/iso.segment',
47
+ '.mpd': 'application/dash+xml', '.ts': 'video/mp2t', '.vtt': 'text/vtt; charset=utf-8', '.mp3': 'audio/mpeg',
43
48
  '.md': 'text/markdown; charset=utf-8', // llms.txt copies of the pages (§33), as brain start's static server sends them
44
49
  }
45
50
  // feeds are .xml files with their own type (SEO, §32)
46
51
  const FEEDS = { 'rss.xml': 'application/rss+xml; charset=utf-8', 'atom.xml': 'application/atom+xml; charset=utf-8' }
47
- const SECURITY = {
52
+ const DEFAULT_SECURITY = {
48
53
  'strict-transport-security': 'max-age=63072000; includeSubDomains',
49
54
  'x-content-type-options': 'nosniff',
50
55
  'referrer-policy': 'strict-origin-when-cross-origin',
@@ -114,8 +119,10 @@ function safeLocation(value) {
114
119
  function linkTo(route, path) {
115
120
  const t = route && route.hrefs
116
121
  if (!t || typeof path !== 'string' || path[0] !== '/' || path[1] === '/') return path
117
- const m = /^[^?#]*/.exec(path)[0]
118
- const n = t.s[m] || (new RegExp(t.re).test(m) && (m === '/' ? t.p : t.p + m))
122
+ const m = /^[^?#]*/.exec(path)[0], key = m.replace(/\/+$/, '') || '/'
123
+ if (key === t.p || key.startsWith(t.p + '/')) return path
124
+ let n = t.s[key] || (new RegExp(t.re).test(key) && (key === '/' ? t.p : t.p + key))
125
+ if (n && key !== '/' && m.endsWith('/') && !n.endsWith('/')) n += '/'
119
126
  return n ? n + path.slice(m.length) : path
120
127
  }
121
128
  function localizeLinks(route, html) {
@@ -194,7 +201,9 @@ const cacheSearch = url => {
194
201
  return kept.size ? '?' + kept : ''
195
202
  }
196
203
  // Messages this file writes itself, safe to log as the cause; anything else in an error message may hold private data.
197
- const OWN_CAUSES = new Set(['Backend request unavailable', 'Backend request timed out', 'Backend response exceeds its byte budget', 'Backend request capacity is unavailable', 'Backend stream capacity is unavailable', 'Native file request failed', 'Native file response is invalid', 'Native resource response must be JSON', 'Native resource response is invalid', 'Native resource request failed', 'Invalid final response status', 'Invalid configured redirect location', 'timeout'])
204
+ const AUTH_UNAVAILABLE_CAUSES = new Set(['the backend did not answer', 'the server is shutting down', '8 session checks are already in flight', 'the backend took more than 2 s'].map(why => `Native authentication is unavailable: ${why}`))
205
+ const BACKEND_UNAVAILABLE_CAUSES = new Set(['Backend request unavailable', 'Backend request timed out', 'Backend response exceeds its byte budget', 'Native backend is unavailable', 'Native file request failed', 'Native file response is invalid', 'Native resource response must be JSON', 'Native resource response is invalid', 'Native resource request failed', 'Native authentication response is unavailable', ...AUTH_UNAVAILABLE_CAUSES])
206
+ const OWN_CAUSES = new Set([...BACKEND_UNAVAILABLE_CAUSES, 'Backend request capacity is unavailable', 'Backend stream capacity is unavailable', 'Invalid same-site redirect location', 'Invalid redirect status', 'Invalid final response status', 'Invalid configured redirect location', 'Static file validators are busy; retry shortly', 'Static file validator deadline exceeded; retry shortly', 'Static file changed; retry shortly', 'Static file changed while computing its validator', 'timeout'])
198
207
  const CODE_SHAPE = /^[A-Z][A-Z0-9_]{0,63}$/
199
208
  const varyEncoding = v => (/accept-encoding/i.test(v || '') ? v : [v, 'Accept-Encoding'].filter(Boolean).join(', '))
200
209
  function bodyEncoding(req, status, headers) {
@@ -239,6 +248,14 @@ function googleSignInPage(page) {
239
248
 
240
249
  // One-use provider cookies (Google nonce, Apple nonce, LinkedIn state): relayed to the backend and cleared at logout.
241
250
  const PROVIDER_COOKIES = ['__Host-brain-google', '__Host-brain-apple', '__Host-brain-linkedin']
251
+ // The native backend owns state, nonce and provider configuration. Only this fixed protocol destination is external.
252
+ function providerStartRedirect(method, path, status, location) {
253
+ if (!['GET', 'HEAD'].includes(method) || path !== '/_brain/auth/linkedin/start' || status !== 303 || typeof location !== 'string' || /[\\\x00-\x20\x7f]/.test(location)) return false
254
+ try {
255
+ const target = new URL(location)
256
+ return target.origin === 'https://www.linkedin.com' && target.pathname === '/oauth/v2/authorization' && !target.username && !target.password && !target.hash
257
+ } catch { return false }
258
+ }
242
259
  // One configured loopback authority. No user-controlled target, proxy, redirect, auth cache or pending queue.
243
260
  // ctx.backend.files(bucket): the same file calls as job and route handlers (docs/backend/files.md).
244
261
  function bucketClient(target, key, bucket, readBudgetMs = () => 0) {
@@ -341,7 +358,15 @@ export function nativeAuthBridge(raw, refused, serviceKey, report, ownSession, r
341
358
  }
342
359
  const safeUser = user => {
343
360
  if (!user || typeof user.id !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(user.id) || typeof user.email !== 'string' || user.email.length > 254 || typeof user.role !== 'string' || !/^[a-z][a-z0-9_]{0,47}$/.test(user.role) || ['public','owner'].includes(user.role) || typeof user.verified !== 'boolean') throw new HttpError(503,'Native authentication response is unavailable')
344
- return Object.freeze({id:user.id,email:user.email,role:user.role,verified:user.verified})
361
+ const clean = {id:user.id,email:user.email,role:user.role,verified:user.verified}
362
+ if (typeof user.name === 'string' && user.name.trim() && user.name.length <= 400 && [...user.name].length <= 200 && !/[\u0000-\u001f\u007f-\u009f]/.test(user.name)) clean.name = user.name.trim()
363
+ if (typeof user.picture === 'string' && Buffer.byteLength(user.picture) <= 2048 && !/[\u0000-\u001f\u007f-\u009f]/.test(user.picture)) {
364
+ try {
365
+ const url = new URL(user.picture)
366
+ if (url.protocol === 'https:' && url.hostname && !url.username && !url.password) clean.picture = user.picture
367
+ } catch {}
368
+ }
369
+ return Object.freeze(clean)
345
370
  }
346
371
  const unavailable = (why = 'the backend did not answer') => new HttpError(503, `Native authentication is unavailable: ${why}`)
347
372
  const call = (incoming, response, path, method, rid, origin) => {
@@ -540,7 +565,7 @@ export function nativeAuthBridge(raw, refused, serviceKey, report, ownSession, r
540
565
  const cookies = nativeCookies(backend.headers['set-cookie'])
541
566
  if (cookies.length) safe['set-cookie'] = cookies
542
567
  if (backend.statusCode >= 500 && !res.$capture) res.$brainCause = 'answered by the native backend'
543
- try { reply(res,backend.statusCode || 502,req.method === 'HEAD' ? '' : Buffer.concat(chunks,bytes),safe); resolve() } catch (e) { reject(e) }
568
+ try { reply(res,backend.statusCode || 502,req.method === 'HEAD' ? '' : Buffer.concat(chunks,bytes),safe,providerStartRedirect(req.method,url.pathname,backend.statusCode,safe.location)); resolve() } catch (e) { reject(e) }
544
569
  }
545
570
  const cancel = () => { outgoing?.destroy(); finish(true) }
546
571
  const data = chunk => {
@@ -999,7 +1024,7 @@ export function createServer(o = {}) {
999
1024
  // a thrown plain object, string or undefined has no stack: say what kind of value it was (never the value itself)
1000
1025
  if (!(e instanceof Error)) out.thrown = e === null ? 'null' : Array.isArray(e) ? 'array' : typeof e
1001
1026
  if (e && typeof e === 'object' && (e.temporary === true || e.status === 429 || e.status === 503)) out.temporary = true
1002
- if (e instanceof HttpError && (OWN_CAUSES.has(e.message) || e.message.startsWith('Native authentication is unavailable:'))) out.cause = e.message
1027
+ if (e instanceof HttpError && OWN_CAUSES.has(e.message)) out.cause = e.message
1003
1028
  if (e && e.code === 'ERR_MODULE_NOT_FOUND') out.fix = 'this process runs an older build whose files are gone; restart it after brain build'
1004
1029
  if (e && CODE_SHAPE.test(e.transport || '')) out.transport = e.transport
1005
1030
  if (e && typeof e.code === 'string' && CODE_SHAPE.test(e.code)) out.backendCode = e.code
@@ -1010,6 +1035,11 @@ export function createServer(o = {}) {
1010
1035
  return out
1011
1036
  }
1012
1037
  const mf = readManifest(dist)
1038
+ // The build policy controls attribution independently of CSP and other security headers.
1039
+ let compilerBranding=mf.compilerBranding!==false
1040
+ try {const path=join(dist,'brain-build.json'),meta=lstatSync(path);if(meta.isFile()&&!meta.isSymbolicLink()&&meta.size<=16384&&JSON.parse(readFileSync(path,'utf8')).compilerBranding===false)compilerBranding=false}catch{}
1041
+ const SECURITY={...DEFAULT_SECURITY}
1042
+ if(!compilerBranding)delete SECURITY['x-generated-by']
1013
1043
  // brain.json `server` (§11.1), written into the manifest by the build; createServer options and env still win
1014
1044
  const SL = mf.server || {}
1015
1045
  const TIMEOUT = +(o.timeout ?? env.BRAIN_TIMEOUT_MS ?? SL.timeoutMs ?? 10000)
@@ -1183,17 +1213,22 @@ export function createServer(o = {}) {
1183
1213
  // HTML: `no-cache` + ETag (strong for files, weak for SSR) → a repeat visit gets 304 with no body (§35); the ETag is
1184
1214
  // taken before the per-response nonce is injected, so it is stable
1185
1215
  const html = (res, status, page, headers = {}, etag) => {
1216
+ // A 304 updates the cached document's policy too; keep it identical to its 200 without minting a CSP nonce.
1217
+ headers = { 'cache-control': 'no-cache', ...(googleSignInPage(page) ? { 'cross-origin-opener-policy': 'same-origin-allow-popups' } : {}), ...(mf.build ? { 'x-brain-build': mf.build } : {}), ...headers }
1186
1218
  if (etag && status === 200) {
1187
1219
  headers = { etag, ...headers }
1188
1220
  const inm = res.req.headers['if-none-match']
1189
1221
  if (inm && inm.split(',').some(t => t.trim().replace(/^W\//, '') === etag.replace(/^W\//, ''))) return send(res, 304, '', headers)
1190
1222
  }
1223
+ // RFC 9111 section 4.3.5 lets HEAD update cached GET headers without replacing its body.
1224
+ // As with 304, omit a new body-bound policy so the cached script nonce remains authorized.
1225
+ if (res.req.method === 'HEAD') return send(res, status, '', { 'content-type': MIME['.html'], ...headers })
1191
1226
  const nonce = randomBytes(16).toString('base64')
1192
1227
  // behavior analytics (docs/backend/analytics.md): with a native backend every page loads its collector as an async
1193
1228
  // module served by that backend, outside the build and the route budgets; BRAIN_ANALYTICS=off skips the tag
1194
1229
  if (analyticsTag && !page.includes(analyticsTag)) page = page.includes('</head>') ? page.replace('</head>', analyticsTag + '</head>') : page + analyticsTag
1195
1230
  const body = page.replace(/<script(?![^>]*\bnonce=)/g, `<script nonce="${nonce}"`)
1196
- send(res, status, body, { 'content-type': MIME['.html'], 'content-security-policy': withNonce(mf.csp || CSP0, nonce), 'cache-control': 'no-cache', ...(googleSignInPage(page) ? { 'cross-origin-opener-policy': 'same-origin-allow-popups' } : {}), ...(mf.build ? { 'x-brain-build': mf.build } : {}), ...headers })
1231
+ send(res, status, body, { 'content-type': MIME['.html'], 'content-security-policy': withNonce(mf.csp || CSP0, nonce), ...headers })
1197
1232
  }
1198
1233
  const notFound = (req, res) => failPage(res, 404, res.getHeader('x-request-id'))
1199
1234
  // `gone()` from load (§33): 410 tells crawlers the URL was removed for good; views/410.brain, else the 404 page
@@ -1206,7 +1241,7 @@ export function createServer(o = {}) {
1206
1241
  }
1207
1242
  // `/403`, `/404.html`, `/503`…: the status when the app has that page, else 0 and the path is an ordinary one
1208
1243
  const reservedStatus = pathname => {
1209
- const m = /^\/(403|404|410|5\d\d)(?:\.html)?$/.exec(pathname)
1244
+ const m = /^\/(403|404|410|5\d\d)(?:\.(?:html|brain))?$/.exec(pathname)
1210
1245
  if (!m) return 0
1211
1246
  const n = +m[1]
1212
1247
  return file(`${n}.html`) || (mf.errors && mf.errors[n]) || (n === 403 && mf.forbidden) || (n === 404 && mf.notFound) ? n : 0
@@ -1357,16 +1392,30 @@ export function createServer(o = {}) {
1357
1392
  }
1358
1393
  const anonymous = req => (req.method === 'GET' || req.method === 'HEAD') && !req.headers.authorization && !req.headers.cookie
1359
1394
 
1395
+ const preloadHints = new WeakMap()
1360
1396
  async function ssr(req, res, route, params, url, rid, forms = null, status = 200, fixed = null, nocache = false) {
1361
1397
  // the route's preloads (the same Link values as _headers): on the response, where hosts turn them into early hints,
1362
1398
  // capped so a long list cannot trip a proxy's header buffer. A 103 (#232) only when opted in, and never to HTTP/1.0.
1363
1399
  if (route.links && route.links.length && !res.$capture && !res.$hinted) {
1364
1400
  res.$hinted = true
1365
- const link = route.links.join(', ')
1366
- if (link.length <= 2048 && !res.headersSent) res.setHeader('link', link)
1367
- if (EARLY_HINTS && res.writeEarlyHints && req.httpVersion !== '1.0') {
1401
+ let selected = preloadHints.get(route)
1402
+ if (!selected) {
1403
+ selected = []
1404
+ let bytes = 0
1405
+ // Preserve graph order within a priority; render-blocking styles and fonts precede script hints.
1406
+ const priority = value => /(?:^|;)\s*as=(?:style|font)(?:;|$)/.test(value) ? 0 : 1
1407
+ for (const value of [...route.links].sort((a, b) => priority(a) - priority(b))) {
1408
+ const size = Buffer.byteLength(value) + (selected.length ? 2 : 0)
1409
+ if (bytes + size > 2048) continue
1410
+ selected.push(value); bytes += size
1411
+ }
1412
+ preloadHints.set(route, selected)
1413
+ if (selected.length < route.links.length) log('warn', 'preload hints trimmed to 2048 bytes; fix: reduce the route preload graph', { route: route.path, kept: selected.length, dropped: route.links.length - selected.length })
1414
+ }
1415
+ if (selected.length && !res.headersSent) res.setHeader('link', selected.join(', '))
1416
+ if (selected.length && EARLY_HINTS && res.writeEarlyHints && req.httpVersion !== '1.0') {
1368
1417
  try {
1369
- res.writeEarlyHints({ link: route.links })
1418
+ res.writeEarlyHints({ link: selected })
1370
1419
  } catch {}
1371
1420
  }
1372
1421
  }
@@ -1445,7 +1494,7 @@ export function createServer(o = {}) {
1445
1494
  // a page behind a guard passes it only for its visitor: it stays out of the index even when it renders (§33)
1446
1495
  if (route.guards && route.guards.length) res.setHeader('x-robots-tag', NOINDEX)
1447
1496
  // with seo.trailingSlash "ignore" both forms answer: the canonical takes the form the sitemap lists (no slash)
1448
- let out = head(page, seo, SLASH === 'always' || url.pathname === '/' ? url.pathname : url.pathname.replace(/\/+$/, '') || '/', url.search)
1497
+ let out = head(page, seo, SLASH === 'always' || url.pathname === '/' ? url.pathname : url.pathname.replace(/\/+$/, '') || '/', url.search, route, BASE.slice(0, -1))
1449
1498
  // route params for route() in the browser (the client has no route table)
1450
1499
  if (params && Object.keys(params).length) out = out.replace('</head>', () => `<meta name="brain-params" content="${esc(JSON.stringify(params))}"></head>`)
1451
1500
  if (forms) out = out.replace('</body>', () => `<script type="application/json" id="brain-forms">${jsonInHtml(forms)}</script></body>`)
@@ -1483,9 +1532,18 @@ export function createServer(o = {}) {
1483
1532
  const seoF = sm.seo || (await load(route.view)).default.seo
1484
1533
  if (route.i18n) await load(route.i18n)
1485
1534
  const nonce = randomBytes(16).toString('base64'), n = h => h.replace(/<script(?![^>]*\bnonce=)/g, `<script nonce="${nonce}"`)
1535
+ globalThis.__brainLocale = route.locale || null
1536
+ let seo0 = null
1537
+ try {
1538
+ seo0 = typeof seoF === 'function' ? seoF({}) : null
1539
+ } finally {
1540
+ globalThis.__brainLocale = null
1541
+ }
1542
+ const initialHead = head(top, seo0, url.pathname, url.search, route, BASE.slice(0, -1))
1486
1543
  const enc = COMPRESS && req.method !== 'HEAD' ? acceptedEncodings(req.headers['accept-encoding'], DYNAMIC)[0] : null
1487
1544
  res.writeHead(200, {
1488
- ...SECURITY, 'content-type': MIME['.html'], 'content-security-policy': withNonce(mf.csp || CSP0, nonce), 'cache-control': 'no-cache',
1545
+ ...SECURITY, 'content-type': MIME['.html'], ...(req.method === 'HEAD' ? {} : { 'content-security-policy': withNonce(mf.csp || CSP0, nonce) }), 'cache-control': 'no-cache',
1546
+ ...(googleSignInPage(initialHead) ? { 'cross-origin-opener-policy': 'same-origin-allow-popups' } : {}),
1489
1547
  ...(mf.build ? { 'x-brain-build': mf.build } : {}), ...extra, ...ctx.session.headers(),
1490
1548
  ...(COMPRESS ? { vary: varyEncoding(extra.vary) } : {}), ...(enc ? { 'content-encoding': enc } : {}),
1491
1549
  })
@@ -1493,15 +1551,8 @@ export function createServer(o = {}) {
1493
1551
  const zs = enc ? encodeStream(enc) : null
1494
1552
  if (zs) zs.pipe(res)
1495
1553
  const write = s => (zs ? (zs.write(s), zs.flush()) : res.write(s))
1496
- globalThis.__brainLocale = route.locale || null
1497
- let seo0 = null
1498
- try {
1499
- seo0 = typeof seoF === 'function' ? seoF({}) : null
1500
- } finally {
1501
- globalThis.__brainLocale = null
1502
- }
1503
1554
  const at = { path: url.pathname, params: params || {}, query: Object.fromEntries(url.searchParams) }
1504
- write(n(head(top, seo0, url.pathname, url.search)))
1555
+ write(n(initialHead))
1505
1556
  // QA #253: the folder's _loading.brain goes in the region with the first chunk (SPEC: shows while load runs),
1506
1557
  // between two marker comments; the last chunk starts with a script that removes them and what is between
1507
1558
  let clear = ''
@@ -1779,7 +1830,9 @@ export function createServer(o = {}) {
1779
1830
  }
1780
1831
  // §33 S01: every sitemap file holds at most 50 000 URLs and SITEMAP_BYTES bytes of UTF-8 XML; the dynamic URLs fill
1781
1832
  // /sitemap-dynamic.xml, /sitemap-dynamic-2.xml, … in sitemap() order, so the split is deterministic
1782
- const SET_HEAD = '<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
1833
+ const XML_SIGNATURE = compilerBranding ? '<!-- by BRAINLLM https://chrisifm.github.io/BRAINLLM/ -->' : ''
1834
+ const XML_HEADER = `<?xml version="1.0" encoding="UTF-8"?>\n${XML_SIGNATURE}\n`
1835
+ const SET_HEAD = XML_HEADER + '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
1783
1836
  const SET_FOOT = '</urlset>\n'
1784
1837
  const bytes = t => Buffer.byteLength(t)
1785
1838
  function pages(urls) {
@@ -1797,7 +1850,9 @@ export function createServer(o = {}) {
1797
1850
  async function serveSitemap(req, res, url) {
1798
1851
  const f = file('/sitemap.xml')
1799
1852
  if (!f) return false
1800
- const text = readFileSync(f, 'utf8')
1853
+ const original = readFileSync(f, 'utf8')
1854
+ const declaration = original.startsWith('<?xml') ? original.indexOf('?>') + 2 : 0
1855
+ const text = !XML_SIGNATURE || original.includes(XML_SIGNATURE) ? original : declaration > 1 ? original.slice(0, declaration) + '\n' + XML_SIGNATURE + original.slice(declaration) : XML_SIGNATURE + '\n' + original
1801
1856
  const origin = mf.site || [...text.matchAll(/<loc>(https?:\/\/[^<]+)<\/loc>/g)].map(m => URL.canParse(m[1]) && new URL(m[1]).origin).find(Boolean)
1802
1857
  if (!origin) return false
1803
1858
  const index = text.includes('<sitemapindex')
@@ -1817,13 +1872,47 @@ export function createServer(o = {}) {
1817
1872
  index
1818
1873
  ? text.replace('</sitemapindex>', `${kids}</sitemapindex>`)
1819
1874
  : split
1820
- ? `<?xml version="1.0" encoding="UTF-8"?>\n<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${child('/sitemap-static.xml')}${kids}</sitemapindex>\n`
1875
+ ? `${XML_HEADER}<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${child('/sitemap-static.xml')}${kids}</sitemapindex>\n`
1821
1876
  : text.replace('</urlset>', sets[0] + '</urlset>'),
1822
1877
  )
1823
1878
  return true
1824
1879
  }
1825
1880
 
1826
1881
  // ---------- static ----------
1882
+ // Content validators use bounded buffers and a bounded metadata cache; a full media file never enters the heap.
1883
+ const fileTags = new Map(), hashingFiles = new Map()
1884
+ async function fileTag(handle, stat) {
1885
+ const key = `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}`
1886
+ if (fileTags.has(key)) return fileTags.get(key)
1887
+ if (hashingFiles.has(key)) return hashingFiles.get(key)
1888
+ if (hashingFiles.size >= 8) throw new HttpError(503, 'Static file validators are busy; retry shortly')
1889
+ const task = (async () => {
1890
+ const hash = createHash('sha256'), buf = Buffer.alloc(65536), until = performance.now() + 30000
1891
+ let offset = 0
1892
+ while (offset < stat.size) {
1893
+ if (performance.now() > until) throw new HttpError(503, 'Static file validator deadline exceeded; retry shortly')
1894
+ const { bytesRead } = await handle.read(buf, 0, Math.min(buf.length, stat.size - offset), offset)
1895
+ if (!bytesRead) throw new HttpError(503, 'Static file changed while computing its validator')
1896
+ hash.update(buf.subarray(0, bytesRead)); offset += bytesRead
1897
+ }
1898
+ const after = await handle.stat()
1899
+ if (after.size !== stat.size || after.mtimeMs !== stat.mtimeMs || after.ctimeMs !== stat.ctimeMs) throw new HttpError(503, 'Static file changed; retry shortly')
1900
+ const etag = '"' + hash.digest('base64url') + '"'
1901
+ fileTags.set(key, etag)
1902
+ if (fileTags.size > 256) fileTags.delete(fileTags.keys().next().value)
1903
+ return etag
1904
+ })()
1905
+ hashingFiles.set(key, task)
1906
+ try { return await task } finally { hashingFiles.delete(key) }
1907
+ }
1908
+ async function streamFile(req, res, handle, status, headers, span) {
1909
+ if (req.method === 'HEAD') return send(res, status, '', headers)
1910
+ res.writeHead(status, { ...SECURITY, ...(server.draining ? { connection: 'close' } : {}), ...headers })
1911
+ // The descriptor stays pinned from stat and hashing through transmission, including atomic deployments.
1912
+ const length = Number(headers['content-length'])
1913
+ if (!length) return res.end()
1914
+ await pipeline(handle.createReadStream({ start: 0, end: length - 1, ...span, autoClose: false }), res)
1915
+ }
1827
1916
  // Never served (brain-pack private.rs holds the same list for the Rust handler and every generated host config).
1828
1917
  const PRIVATE_PREFIXES = ['_server', '_brain', 'server.mjs']
1829
1918
  const PRIVATE_FILES = ['_headers', '_redirects']
@@ -1851,7 +1940,7 @@ export function createServer(o = {}) {
1851
1940
  // site configured) or with a per-request pattern gets no header, so this never invents a URL.
1852
1941
  function twinCanonical(path) {
1853
1942
  const stem = path.replace(/\.md$/, '').replace(/\/+$/, '')
1854
- const page = file(stem ? stem + '.html' : 'index.html') || file(stem + '/index.html')
1943
+ const page = file(stem ? stem + '.html' : 'index.html') || file(stem + '/index.html') || file(stem ? stem + '.brain' : 'index.brain') || file(stem + '/index.brain')
1855
1944
  if (!page) return {}
1856
1945
  const tag = /<link\b[^>]*\brel=["']canonical["'][^>]*>/i.exec(readFileSync(page, 'utf8'))?.[0]
1857
1946
  const href = tag && /\bhref="(https?:\/\/[^"<>\s]+)"/.exec(tag)?.[1].replaceAll('&amp;', '&')
@@ -1860,21 +1949,21 @@ export function createServer(o = {}) {
1860
1949
  // What exists whatever the routes say: generated sitemaps, built files and the icon browsers ask for by habit.
1861
1950
  async function serveFixed(req, res, url) {
1862
1951
  if (DYN.length && /^\/sitemap(-static|-dynamic(-\d+)?)?\.xml$/.test(url.pathname) && (await serveSitemap(req, res, url))) return true
1863
- if (serveStatic(req, res, url)) return true
1952
+ if (await serveStatic(req, res, url)) return true
1864
1953
  if (url.pathname !== '/favicon.ico' || !file('favicon.svg')) return false
1865
1954
  send(res, 301, '', { location: withBase('/favicon.svg'), 'cache-control': 'public, max-age=86400' })
1866
1955
  return true
1867
1956
  }
1868
1957
 
1869
- function serveStatic(req, res, url) {
1958
+ async function serveStatic(req, res, url) {
1870
1959
  const p = publicStaticPath(url.pathname)
1871
1960
  if (p === null) return false
1872
- const candidates = p.endsWith('/') ? [p + 'index.html', ...(p.length > 1 ? [p.slice(0, -1) + '.html'] : [])] : [p, p + '.html', p + '/index.html']
1961
+ const candidates = p.endsWith('/') ? [p + 'index.html', p + 'index.brain', ...(p.length > 1 ? [p.slice(0, -1) + '.html', p.slice(0, -1) + '.brain'] : [])] : [p, p + '.html', p + '/index.html', p + '.brain', p + '/index.brain']
1873
1962
  for (const c of candidates) {
1874
1963
  const f = file(c)
1875
1964
  if (!f) continue
1876
1965
  const ext = extname(f), type = FEEDS[basename(f)] || MIME[ext] || 'application/octet-stream'
1877
- if (ext === '.html') {
1966
+ if (ext === '.html' || ext === '.brain') {
1878
1967
  // §33: the page's Markdown twin (`/pricing` → pricing.md) when the client prefers text/markdown
1879
1968
  if (serveTwin(req, res, url)) return true
1880
1969
  const md = file(mdPath(p))
@@ -1886,34 +1975,39 @@ export function createServer(o = {}) {
1886
1975
  ...(ext === '.md' ? twinCanonical(p) : {}),
1887
1976
  'content-type': type,
1888
1977
  // the worker script must be revalidated on every check, or a CDN could keep an old worker for an hour (§35.7)
1889
- 'cache-control': p.startsWith('/_b/') ? 'public, max-age=31536000, immutable' : p === '/sw.js' ? 'no-cache' : 'public, max-age=3600',
1978
+ 'cache-control': p.startsWith('/_b/') ? 'public, max-age=31536000, immutable' : p === '/sw.js' || /\.(m3u8?|mpd)$/i.test(p) ? 'no-cache' : 'public, max-age=3600',
1890
1979
  vary: 'accept-encoding',
1891
1980
  }
1892
- // Range: media players ask for byte spans (Safari plays no video without them). The span is of the plain file, so
1893
- // a ranged request skips the precompressed siblings; If-Range has no validator to match here, so it gets it all.
1894
- const span = req.headers.range && !req.headers['if-range'] ? byteRange(req.headers.range, statSync(f).size) : null
1895
- if (span === false) return send(res, 416, '', { 'content-range': `bytes */${statSync(f).size}` }), true
1896
- if (span) {
1897
- const len = span.end - span.start + 1, buf = Buffer.alloc(req.method === 'HEAD' ? 0 : len)
1898
- if (buf.length) {
1899
- const fd = openSync(f, 'r')
1900
- try { readSync(fd, buf, 0, len, span.start) } finally { closeSync(fd) }
1901
- }
1902
- send(res, 206, buf, { ...headers, 'accept-ranges': 'bytes', 'content-range': `bytes ${span.start}-${span.end}/${statSync(f).size}`, 'content-length': String(len) })
1903
- return true
1904
- }
1905
1981
  // the smallest precompressed sibling the client accepts (the build keeps only those smaller than the file)
1906
1982
  let best = null
1907
- for (const enc of acceptedEncodings(req.headers['accept-encoding'])) {
1983
+ for (const enc of req.headers.range ? [] : acceptedEncodings(req.headers['accept-encoding'])) {
1908
1984
  const encoded = file(c + (enc === 'br' ? '.br' : enc === 'zstd' ? '.zst' : '.gz'))
1909
1985
  const size = encoded ? statSync(encoded).size : Infinity
1910
1986
  if (encoded && (!best || size < best.size)) best = { enc, encoded, size }
1911
1987
  }
1912
- if (best) {
1913
- send(res, 200, req.method === 'HEAD' ? '' : readFileSync(best.encoded), { ...headers, 'content-encoding': best.enc })
1914
- return true
1988
+ const selected = best ? best.encoded : f
1989
+ const handle = await open(selected, FS.O_RDONLY | (FS.O_NOFOLLOW || 0) | (FS.O_NONBLOCK || 0))
1990
+ try {
1991
+ const stat = await handle.stat()
1992
+ if (!stat.isFile()) throw new Error('Static output must be a regular file')
1993
+ headers.etag = await fileTag(handle, stat)
1994
+ if (res.destroyed) return true
1995
+ if (!best) headers['accept-ranges'] = 'bytes'
1996
+ const inm = req.headers['if-none-match']
1997
+ if (inm && inm.split(',').some(t => t.trim() === '*' || t.trim().replace(/^W\//, '') === headers.etag)) return send(res, 304, '', headers), true
1998
+ const span = req.headers.range && (!req.headers['if-range'] || req.headers['if-range'] === headers.etag) ? byteRange(req.headers.range, stat.size) : null
1999
+ if (span === false) return send(res, 416, '', { ...headers, 'content-range': `bytes */${stat.size}` }), true
2000
+ if (span) {
2001
+ headers['content-range'] = `bytes ${span.start}-${span.end}/${stat.size}`
2002
+ headers['content-length'] = String(span.end - span.start + 1)
2003
+ } else {
2004
+ headers['content-length'] = String(stat.size)
2005
+ if (best) headers['content-encoding'] = best.enc
2006
+ }
2007
+ await streamFile(req, res, handle, span ? 206 : 200, headers, span)
2008
+ } finally {
2009
+ await handle.close()
1915
2010
  }
1916
- send(res, 200, req.method === 'HEAD' ? '' : readFileSync(f), { ...headers, 'accept-ranges': 'bytes' })
1917
2011
  return true
1918
2012
  }
1919
2013
  return false
@@ -2049,7 +2143,7 @@ export function createServer(o = {}) {
2049
2143
  const post = req.method === 'POST'
2050
2144
  if (!post && req.method !== 'GET' && req.method !== 'HEAD') return send(res, 405, 'Method not allowed', { allow: 'GET, HEAD, POST' })
2051
2145
  // a status page opened directly answers its own status: `/403` is never a 200 that a crawler or a probe can trust
2052
- const reserved = post ? 0 : reservedStatus(url.pathname)
2146
+ const reserved = post ? 0 : reservedStatus(publicStaticPath(url.pathname) ?? url.pathname)
2053
2147
  if (reserved === 403) return forbidden(req, res, url, rid, '')
2054
2148
  if (reserved) {
2055
2149
  // Anyone can open it, so the note about it is bounded and is not an error.
@@ -2062,11 +2156,12 @@ export function createServer(o = {}) {
2062
2156
  for (const route of routes) {
2063
2157
  const m = route.rx.exec(url.pathname)
2064
2158
  if (!m) continue
2159
+ const protectedRoute = route.guards?.length || route.middleware?.length
2065
2160
  // a form page without load is prerendered: GET serves that file (build ETag); only its POST needs the server
2066
- if (!post && route.forms && serveStatic(req, res, url)) return
2161
+ if (!post && !protectedRoute && route.forms && (await serveStatic(req, res, url))) return
2067
2162
  // a parameter never hides a real file: `/[code]` must not swallow /favicon.svg, /sitemap.xml or a prerendered /login
2068
- if (!post && m.groups && (await serveFixed(req, res, url))) return
2069
- if (!post && serveTwin(req, res, url)) return
2163
+ if (!post && !protectedRoute && m.groups && (await serveFixed(req, res, url))) return
2164
+ if (!post && !protectedRoute && serveTwin(req, res, url)) return
2070
2165
  const paged = post ? null : pageParameter(route, url)
2071
2166
  if (paged === 404) return notFound(req, res)
2072
2167
  if (paged) return send(res, 301, '', { location: withBase(paged), 'cache-control': 'public, max-age=3600' })
@@ -2080,7 +2175,7 @@ export function createServer(o = {}) {
2080
2175
  } catch (e) {
2081
2176
  const busy = e instanceof HttpError && e.code === 'BRIDGE_BUSY'
2082
2177
  if (busy) log('warn', 'backend bridge busy', { rid, path: req.$brainPath || String(req.url || '').split('?')[0], fix: e.fix })
2083
- else if (e instanceof HttpError && OWN_CAUSES.has(e.message)) log('warn', 'backend unavailable', { rid, path: req.$brainPath || String(req.url || '').split('?')[0], status: e.status, cause: e.message, ...(CODE_SHAPE.test(e.transport || '') ? { transport: e.transport } : {}) })
2178
+ else if (e instanceof HttpError && e.status >= 500 && BACKEND_UNAVAILABLE_CAUSES.has(e.message)) log('warn', 'backend unavailable', { rid, path: req.$brainPath || String(req.url || '').split('?')[0], status: e.status, cause: e.message, ...(CODE_SHAPE.test(e.transport || '') ? { transport: e.transport } : {}) })
2084
2179
  else log('error', 'unhandled', { rid, path: req.url, ...trace(e) })
2085
2180
  try {
2086
2181
  if (busy && !res.headersSent) { res.setHeader('retry-after', String(e.retryAfter)); failPage(res, 503, rid, { code: e.code, message: 'Service Unavailable', fix: e.fix }) }
@@ -2674,10 +2769,23 @@ export function prefersMarkdown(accept) {
2674
2769
  if (html < 0) (html = any), (htmlAt = Infinity) // an explicit type beats a wildcard of the same weight
2675
2770
  return md > 0 && (md > html || (md === html && mdAt < htmlAt))
2676
2771
  }
2772
+ // Root-relative image metadata follows the app base; bare references follow the canonical document URL.
2773
+ export function metadataImage(root, canonical, value) {
2774
+ const encode = s => encodeURI(s.toWellFormed()).replaceAll('%25', '%')
2775
+ value = value.trim()
2776
+ if (!value) return ''
2777
+ if (/^[A-Za-z][A-Za-z0-9+.-]*:/.test(value) || !root) return encode(value)
2778
+ try {
2779
+ const home = new URL(root.endsWith('/') ? root : root + '/')
2780
+ const prefix = home.pathname.replace(/\/$/, '')
2781
+ if (/^\/(?!\/)/.test(value)) value = new URL(value === prefix || value.startsWith(prefix + '/') ? value : prefix + value, home.origin).href
2782
+ return new URL(value, canonical || home).href
2783
+ } catch { return encode(value) }
2784
+ }
2677
2785
  // SEO head from View.seo(props): title, description, canonical, og:*/twitter:*, JSON-LD and the view's <head> block
2678
2786
  // F5: the build writes an SSR route's canonical/og:url from its pattern (/posts/:slug); the request has the real path.
2679
2787
  // A view's own <head> tags replace the generated ones with the same name/property (one tag per key, SPEC «SEO»).
2680
- function head(page, seo, path, search = '') {
2788
+ function head(page, seo, path, search = '', route, base = '') {
2681
2789
  let h = page
2682
2790
  // `<meta seo query="page">`: these parameters make a different page; any other (a dialog, a tracking tag) does not
2683
2791
  let identity = '', query = ''
@@ -2689,11 +2797,13 @@ function head(page, seo, path, search = '') {
2689
2797
  })
2690
2798
  if (path) {
2691
2799
  // the attribute is already escaped HTML: only the request path that replaces a pattern is escaped
2692
- const real = v => v.replace(/^(https?:\/\/[^/]+)?(\/[^"]*)$/, (m, o = '', p) => (/\/[:*]/.test(p) ? o + esc(path) : m)) + identity
2800
+ const real = v => v.replace(/^(https?:\/\/[^/]+)?(\/[^"]*)$/, (m, o = '', p) => (/\/[:*]/.test(p) ? o + esc(base + path) : m)) + identity
2693
2801
  h = h.replace(/(<link[^>]*rel=["']canonical["'][^>]*href=")([^"]*)(")/i, (m, a, v, b) => a + real(v) + b)
2694
2802
  h = h.replace(/(<meta[^>]*property=["']og:url["'][^>]*content=")([^"]*)(")/i, (m, a, v, b) => a + real(v) + b)
2695
2803
  }
2696
- if (!seo) return graph(h, path, query)
2804
+ if (!seo) return graph(h, path ? base + path : path, query)
2805
+ // Match prerendering: blank optional fields are absent; zero and false remain distinct values.
2806
+ seo = Object.fromEntries(Object.entries(seo).filter(([, v]) => v != null && !(typeof v === 'string' && !v.trim())))
2697
2807
  const meta = (attr, key, val, escaped) => {
2698
2808
  const tag = `<meta ${attr}="${esc(key)}" content="${escaped ? val.replace(/"/g, '&quot;') : esc(val)}">`
2699
2809
  const rx = new RegExp(`<meta[^>]*\\b${attr}=["']${key.replace(/[.*+?^${}()|[\]\\:]/g, '\\$&')}["'][^>]*>`, 'i')
@@ -2706,20 +2816,27 @@ function head(page, seo, path, search = '') {
2706
2816
  else if (k === 'canonical') {
2707
2817
  // a root-relative canonical takes the origin the build wrote into the template (PUBLIC_SITE_URL), as it does on
2708
2818
  // a prerendered page: a relative canonical is ignored by search engines
2709
- const built = /^\/(?!\/)/.test(v) && /<link[^>]*rel=["']canonical["'][^>]*href="(https?:\/\/[^/"]+)/i.exec(h)?.[1]
2710
- const url = built ? built + v : v
2819
+ const relative = base && (v + '/').startsWith(base + '/') ? v.slice(base.length) || '/' : v
2820
+ const translated = linkTo(route, relative)
2821
+ const local = /^\/(?!\/)/.test(translated) ? base + translated : translated
2822
+ const built = /^\/(?!\/)/.test(local) && /<link[^>]*rel=["']canonical["'][^>]*href="(https?:\/\/[^/"]+)/i.exec(h)?.[1]
2823
+ const url = built ? built + local : local
2711
2824
  const tag = `<link rel="canonical" href="${esc(url)}">`
2712
2825
  h = /<link[^>]*rel=["']canonical["'][^>]*>/i.test(h) ? h.replace(/<link[^>]*rel=["']canonical["'][^>]*>/i, () => tag) : h.replace('</head>', () => tag + '</head>')
2713
2826
  if (seo['og:url'] == null) meta('property', 'og:url', url) // og:url follows the canonical unless the view sets its own
2714
2827
  } else if (k === 'jsonld') h = h.replace('</head>', () => `<script type="application/ld+json">${jsonInHtml(v)}</script></head>`)
2715
- else if (/^(og|twitter|article):/.test(k)) meta('property', k, v)
2828
+ else if (/^(og|twitter|article):/.test(k)) meta(k.startsWith('twitter:') ? 'name' : 'property', k, v)
2716
2829
  else if (typeof v === 'string') meta('name', k, v)
2717
2830
  }
2718
2831
  // What the build writes for a prerendered page and can only be known here: the social card of this request's values
2719
2832
  // (brain-seo social::head, prerender seo_head). The view's own value always wins.
2720
2833
  const shared = /<title>([\s\S]*?)<\/title>/i.exec(h)?.[1]
2721
2834
  const origin = /<link[^>]*rel=["']canonical["'][^>]*href="(https?:\/\/[^/"]+)/i.exec(h)?.[1]
2722
- if (typeof seo['og:image'] === 'string' && origin && /^\/(?!\/)/.test(seo['og:image'])) meta('property', 'og:image', encodeURI(origin + seo['og:image']))
2835
+ const canonical = /<link[^>]*rel=["']canonical["'][^>]*href="([^"]+)"/i.exec(h)?.[1].replaceAll('&amp;', '&')
2836
+ for (const key of ['og:image', 'og:image:url', 'og:image:secure_url', 'twitter:image', 'twitter:image:src']) if (typeof seo[key] === 'string') {
2837
+ seo[key] = metadataImage(origin ? origin + base : null, canonical, seo[key])
2838
+ meta(key.startsWith('twitter:') ? 'name' : 'property', key, seo[key])
2839
+ }
2723
2840
  if (seo.title != null && seo['og:title'] == null) meta('property', 'og:title', shared, true)
2724
2841
  if (seo.description != null && seo['og:description'] == null) meta('property', 'og:description', seo.description)
2725
2842
  if (seo['og:image'] != null) {
@@ -2735,19 +2852,21 @@ function head(page, seo, path, search = '') {
2735
2852
  if (seo.title != null) node[/^(Article|BlogPosting|NewsArticle|TechArticle|Report)$/.test(T) ? 'headline' : 'name'] = String(seo.title).slice(0, 110)
2736
2853
  if (seo.description != null) node.description = String(seo.description)
2737
2854
  if (canon) node.url = canon[1].replace(/&amp;/g, '&')
2738
- if (seo['og:image'] != null) node.image = String(seo['og:image']).replace(/^\/(?!\/)/, () => (canon && /^https?:\/\/[^/]+/.exec(node.url) || [''])[0] + '/')
2855
+ if (seo['og:image'] != null) node.image = seo['og:image']
2739
2856
  if (seo.lastmod != null) node.dateModified = String(seo.lastmod)
2740
2857
  const own = new RegExp(`<script[^>]*type=["']application/ld\\+json["'][^>]*>\\s*\\{"@context":"https://schema\\.org","@type":"${T}"[\\s\\S]*?</script>`, 'i')
2741
2858
  const tag = `<script type="application/ld+json">${jsonInHtml(node)}</script>`
2742
2859
  h = own.test(h) ? h.replace(own, () => tag) : h.replace('</head>', () => tag + '</head>')
2743
2860
  }
2744
2861
  if (seo.$head) {
2745
- for (const [, attr, key] of seo.$head.matchAll(/<meta[^>]*\b(property|name)=["']([^"']+)["'][^>]*>/gi))
2746
- h = h.replace(new RegExp(`<meta[^>]*\\b${attr}=["']${key.replace(/[.*+?^${}()|[\]\\:]/g, '\\$&')}["'][^>]*>`, 'gi'), '')
2862
+ for (const [, attr, key] of seo.$head.matchAll(/<meta[^>]*\b(property|name)=["']([^"']+)["'][^>]*>/gi)) {
2863
+ const attributes = key.startsWith('twitter:') ? '(?:property|name)' : attr
2864
+ h = h.replace(new RegExp(`<meta[^>]*\\b${attributes}=["']${key.replace(/[.*+?^${}()|[\]\\:]/g, '\\$&')}["'][^>]*>`, 'gi'), '')
2865
+ }
2747
2866
  if (/<link[^>]*rel=["']canonical["']/i.test(seo.$head)) h = h.replace(/<link[^>]*rel=["']canonical["'][^>]*>/gi, '')
2748
2867
  h = h.replace('</head>', () => seo.$head + '</head>')
2749
2868
  }
2750
- return graph(h, path, query, seo)
2869
+ return graph(h, path ? base + path : path, query, seo)
2751
2870
  }
2752
2871
  // The WebSite + WebPage graph the build wrote into the template (brain-seo ServerHead::apply): its pattern URLs become
2753
2872
  // this request's, like the canonical, and the WebPage takes this request's title and description.
@@ -2763,27 +2882,32 @@ function graph(h, path, query, seo) {
2763
2882
  if ([node['@type']].flat().includes('WebPage')) {
2764
2883
  if (title && node.name == null) node.name = title.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&amp;/g, '&')
2765
2884
  if (seo?.description != null && node.description == null) node.description = String(seo.description)
2885
+ if (typeof seo?.['og:image'] === 'string' && seo.jsonld == null) {
2886
+ const image = node.primaryImageOfPage
2887
+ if (!image || (image['@type'] === 'ImageObject' && !image['@id'])) node.primaryImageOfPage = { '@type': 'ImageObject', ...image, url: seo['og:image'] }
2888
+ }
2766
2889
  }
2767
2890
  }
2768
2891
  return a + jsonInHtml(g) + b
2769
2892
  })
2770
2893
  }
2771
- // `/_b/<hash>.js` imports inside the chunks resolve to files in dist/ (same hook as the build's prerender)
2894
+ // Only compiler-owned chunks receive the own-extension module format; ordinary Node imports keep their loader.
2895
+ function resolveChunk(s, c, next, base, app) {
2896
+ if (app !== '/' && s.startsWith(app + '_b/')) s = s.slice(app.length - 1)
2897
+ const mapped = s.startsWith('/_b/')
2898
+ const url = mapped ? new URL('.' + s, base) : s.startsWith('file:') ? new URL(s) : s.startsWith('.') && c.parentURL?.startsWith('file:') ? new URL(s, c.parentURL) : null
2899
+ const owned = new URL('_b/', base).href
2900
+ if (url?.href.startsWith(owned) && url.pathname.endsWith('.jsbrain')) return { url: url.href, format: 'module', shortCircuit: true }
2901
+ if (mapped) return { url: url.href, shortCircuit: true }
2902
+ return next(s, c)
2903
+ }
2772
2904
  let hooked = false
2773
2905
  function hook(base, app = '/') {
2774
2906
  if (hooked) return
2775
2907
  hooked = true
2776
- // chunks import '/_b/x.js', or '/app/_b/x.js' under a base path: both are dist/_b/x.js
2777
- const own = s => {
2778
- if (app !== '/' && s.startsWith(app + '_b/')) s = s.slice(app.length - 1)
2779
- return s.startsWith('/_b/') && { url: new URL('.' + s, base).href, shortCircuit: true }
2780
- }
2781
- // registerHooks (Node 22.15+, in-thread) replaces the deprecated register (DEP0205)
2782
- if (Module.registerHooks) return void Module.registerHooks({ resolve: (s, c, next) => own(s) || next(s, c) })
2783
- Module.register(
2784
- 'data:text/javascript,' +
2785
- encodeURIComponent(`export async function resolve(s, c, n) { const a = ${json(app)}; if (a !== '/' && s.startsWith(a + '_b/')) s = s.slice(a.length - 1); return s.startsWith('/_b/') ? { url: new URL('.' + s, ${json(base)}).href, shortCircuit: true } : n(s, c) }`)
2786
- )
2908
+ // registerHooks (Node 22.15+, in-thread) and the compatibility loader share exactly the same resolver.
2909
+ if (Module.registerHooks) return void Module.registerHooks({ resolve: (s, c, next) => resolveChunk(s, c, next, base, app) })
2910
+ Module.register('data:text/javascript,' + encodeURIComponent(`${resolveChunk.toString()};export async function resolve(s,c,n){return resolveChunk(s,c,n,${json(base)},${json(app)})}`))
2787
2911
  }
2788
2912
 
2789
2913
  const LISTEN_WAIT_MS = 10000
package/shim/dom.js CHANGED
@@ -6,28 +6,19 @@ const VOID = new Set(['area', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link'
6
6
  const esc = s => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
7
7
  const unesc = s => s.replace(/&(amp|lt|gt|quot|#39);/g, (_, e) => ({ amp: '&', lt: '<', gt: '>', quot: '"', '#39': "'" })[e])
8
8
 
9
- export class Event {
9
+ // Keep Node's native event brand: SSR and its test DOM share a process with fetch/WebSocket EventTargets.
10
+ const NativeEvent = globalThis.Event
11
+ export class Event extends NativeEvent {
10
12
  constructor(type, init = {}) {
11
- this.type = type
12
- this.bubbles = !!init.bubbles
13
- this.cancelable = !!init.cancelable
14
- this.target = null
15
- this.currentTarget = null
16
- this.cancelBubble = false
17
- this.defaultPrevented = false
13
+ super(type, init)
18
14
  this._p = []
19
15
  }
20
16
  composedPath() {
21
- return this._p
17
+ return this._p.length ? this._p : super.composedPath()
22
18
  }
23
19
  stopImmediatePropagation() {
24
- this.cancelBubble = this._stop = true
25
- }
26
- stopPropagation() {
27
- this.cancelBubble = true
28
- }
29
- preventDefault() {
30
- this.defaultPrevented = true
20
+ this._stop = true
21
+ super.stopImmediatePropagation()
31
22
  }
32
23
  }
33
24
  export class MouseEvent extends Event {}
@@ -162,7 +153,7 @@ class Node {
162
153
  if (i >= 0) a.splice(i, 1)
163
154
  }
164
155
  dispatchEvent(e) {
165
- e.target = this
156
+ Object.defineProperty(e, 'target', { configurable: true, writable: true, value: this })
166
157
  const path = []
167
158
  for (let n = this; n; n = n.parentNode || n.host) path.push(n)
168
159
  if (path[path.length - 1].nodeType === 9 && path[path.length - 1].defaultView) path.push(path[path.length - 1].defaultView)
package/sw/brain-sw.js CHANGED
@@ -14,9 +14,16 @@
14
14
  // cache that survives builds, bounded by entries and age; personal responses are never stored. `cache.update:
15
15
  // "prompt"` keeps a new worker waiting (while an older one controls tabs) until a page posts `brain:skip-waiting`.
16
16
  const B = self.BRAIN_BUILD || { hash: 'dev', precache: [], offline: null }
17
+ const IMMUTABLE = Array.isArray(B.immutable) ? new Set(B.immutable) : null
18
+ const isHashed = path => IMMUTABLE ? IMMUTABLE.has(path) : path.startsWith(BASE + '_b/')
17
19
  const BASE = B.base || '/' // app served under a base path (§23 #148); precache/offline URLs already carry it
18
20
  const NS = 'brain' + BASE.replace(/\/$/, '') // `brain` at the root, `brain/app` under /app/
19
21
  const BUILD = NS + '-' + B.hash, META = NS + ':meta'
22
+ let priorImmutable
23
+ const previousAssets = () => priorImmutable ||= caches.open(META).then(cache => cache.match('/__brain_immutable')).then(async response => {
24
+ const manifests = response ? await response.json() : {}
25
+ return new Set(Object.values(manifests).filter(Array.isArray).flat().filter(path => typeof path === 'string'))
26
+ }).catch(() => new Set())
20
27
  const NAV_TIMEOUT = 3000
21
28
  // precache downloads at once: hundreds in one burst tripped per-IP proxy rate limits (429) and competed with the page
22
29
  const PRECACHE_PARALLEL = 4
@@ -40,7 +47,7 @@ self.addEventListener('install', e => {
40
47
  // update downloads only what changed
41
48
  const olds = (await caches.keys()).filter(k => k.startsWith(NS + '-') && k !== BUILD)
42
49
  const kept = async u => {
43
- if (!u.startsWith(BASE + '_b/')) return null
50
+ if (!isHashed(u)) return null
44
51
  for (const k of olds) {
45
52
  const r = await (await caches.open(k)).match(u)
46
53
  if (r) return r
@@ -74,6 +81,24 @@ self.addEventListener('activate', e => {
74
81
  const gens = r ? await r.json() : []
75
82
  const keep = [...gens.filter(g => g !== BUILD).slice(-1), BUILD]
76
83
  await meta.put('/__brain_generations', new Response(JSON.stringify(keep)))
84
+ // Keep exact custom asset identities for the same two generations as their bytes, including old open tabs.
85
+ const stored = await meta.match('/__brain_immutable')
86
+ if (IMMUTABLE || stored) {
87
+ const decoded = stored ? await stored.json().catch(() => ({})) : {}
88
+ const old = decoded && typeof decoded === 'object' ? decoded : {}
89
+ const manifests = {}
90
+ for (const generation of keep) {
91
+ if (generation === BUILD) manifests[generation] = [...(IMMUTABLE || [])]
92
+ else if (Array.isArray(old[generation])) manifests[generation] = old[generation]
93
+ else if (generation.startsWith(NS + '-')) {
94
+ // A pre-inventory worker treated only its historical namespace as immutable; infer no other cached URL.
95
+ const entries = await (await caches.open(generation)).keys()
96
+ manifests[generation] = entries.map(entry => new URL(entry.url, self.location.origin)).filter(url => url.origin === self.location.origin && url.pathname.startsWith(BASE + '_b/')).map(url => url.pathname)
97
+ }
98
+ }
99
+ await meta.put('/__brain_immutable', new Response(JSON.stringify(manifests)))
100
+ priorImmutable = Promise.resolve(new Set(Object.values(manifests).flat()))
101
+ }
77
102
  for (const k of await caches.keys())
78
103
  if ((k.startsWith(NS + '-') && !keep.includes(k)) || (k.startsWith(NS + ':route:') && !ROUTES.some(r => r.cache === k))) await caches.delete(k)
79
104
  await self.clients.claim()
@@ -91,10 +116,18 @@ self.addEventListener('fetch', e => {
91
116
  if (req.method !== 'GET') return
92
117
  const url = new URL(req.url)
93
118
  if (url.origin !== self.location.origin || url.pathname.startsWith(BASE + '_r/') || url.pathname === BASE + 'sw.js') return
119
+ // A cached whole response cannot satisfy a byte range; live manifests must never lag one revalidation behind.
120
+ if (req.headers?.has?.('range') || /\.(m3u8?|mpd)$/i.test(url.pathname)) return
94
121
  // offline: false (B.offline null) → navigations go straight to the network: no page cache, no offline page
95
122
  if (req.mode === 'navigate') return B.offline ? e.respondWith(navigate(e)) : undefined
96
- if (url.pathname.startsWith(BASE + '_b/')) return e.respondWith(hashed(e))
123
+ if (isHashed(url.pathname)) return e.respondWith(hashed(e))
97
124
  const rt = route(url.pathname)
125
+ // A custom layout can change between builds; only exact retained inventory entries use old immutable bytes.
126
+ if (IMMUTABLE) return e.respondWith((async () => {
127
+ if ((await previousAssets()).has(url.pathname)) return hashed(e)
128
+ if (rt) return rt.strategy === 'network-only' ? fetch(req) : data(e, rt)
129
+ return req.destination ? revalidate(e) : fetch(req)
130
+ })())
98
131
  if (rt) return e.respondWith(rt.strategy === 'network-only' ? fetch(req) : data(e, rt))
99
132
  // only files the page itself loads (img, style, script, font, manifest…); fetch()/XHR data is never touched (§35:
100
133
  // data is not cached by default)
package/ui/dialog.brain CHANGED
@@ -1,10 +1,10 @@
1
- <meta props="title:str closeLabel:str='Close' urlKey:str=''" emits="close" expose="open close">
1
+ <meta props="title:str closeLabel:str='Close' urlKey:str='' dismissible:bool=false" emits="close" expose="open close">
2
2
  <script>
3
3
  let dlg = ref()
4
4
  // native modal <dialog>: focus moves inside, the rest of the page is inert, Escape closes
5
5
  const open = () => { if (!dlg.open) dlg.showModal() }
6
6
  const close = () => { if (dlg.open) dlg.close() }
7
- fx(() => { if (dlg) return urlDialog(dlg, urlKey) })
7
+ fx(() => { if (dlg) return urlDialog(dlg, urlKey, { dismissible }) })
8
8
  </script>
9
9
  <dialog bind:ref={dlg} aria-labelledby="dlg-title" on:close={emit('close')}>
10
10
  <header>