@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 +4 -2
- package/brain-list.js +9 -0
- package/brain-prefetch-shared.js +1 -1
- package/brain-router.js +11 -4
- package/brain-text.js +56 -0
- package/brain-url.js +36 -3
- package/brain.js +14 -8
- package/package.json +1 -1
- package/server/server.mjs +203 -79
- package/shim/dom.js +8 -17
- package/sw/brain-sw.js +35 -2
- package/ui/dialog.brain +2 -2
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
|
-
|
|
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
|
+
}
|
package/brain-prefetch-shared.js
CHANGED
|
@@ -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 ||
|
|
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
|
|
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
|
-
|
|
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))
|
|
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.
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 &&
|
|
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),
|
|
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
|
-
|
|
1366
|
-
if (
|
|
1367
|
-
|
|
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:
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
?
|
|
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('&', '&')
|
|
@@ -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
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
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 &&
|
|
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, '"') : 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
|
|
2710
|
-
const
|
|
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
|
-
|
|
2835
|
+
const canonical = /<link[^>]*rel=["']canonical["'][^>]*href="([^"]+)"/i.exec(h)?.[1].replaceAll('&', '&')
|
|
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(/&/g, '&')
|
|
2738
|
-
if (seo['og:image'] != null) node.image =
|
|
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
|
-
|
|
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(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").replace(/&/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
|
-
//
|
|
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
|
-
//
|
|
2777
|
-
|
|
2778
|
-
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
7
7
|
const unesc = s => s.replace(/&(amp|lt|gt|quot|#39);/g, (_, e) => ({ amp: '&', lt: '<', gt: '>', quot: '"', '#39': "'" })[e])
|
|
8
8
|
|
|
9
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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>
|