@christianirack/brainllm-runtime 0.8.8 → 0.8.10
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-form.js +1 -1
- package/brain-lazy.js +12 -5
- package/brain-router.js +17 -7
- package/brain.js +40 -8
- package/package.json +1 -1
- package/server/server.mjs +246 -69
package/brain-form.js
CHANGED
|
@@ -305,7 +305,7 @@ function createForm(schema, o, readRule, validateRule, remote) {
|
|
|
305
305
|
else {
|
|
306
306
|
saved.v = untrack(now) // saved: no longer dirty
|
|
307
307
|
// `return { redirect: '/x' }` from the server function: the same page the 303 reaches without JS (§38)
|
|
308
|
-
if (typeof r?.redirect === 'string' && r.redirect
|
|
308
|
+
if (typeof r?.redirect === 'string' && /^\/(?![\/\\])/.test(r.redirect) && !/[\\\x00-\x20\x7f]/.test(r.redirect)) go(r.redirect)
|
|
309
309
|
}
|
|
310
310
|
return r
|
|
311
311
|
} catch (x) {
|
package/brain-lazy.js
CHANGED
|
@@ -23,6 +23,16 @@ function reloadOnce() {
|
|
|
23
23
|
}
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
// The observer is built in its own scope and reaches `go` through a box emptied on stop. A callback closing over
|
|
27
|
+
// lazy()'s scope kept the owner, the props and the built component alive after unmount and disconnect() (measured:
|
|
28
|
+
// parent and child survived forced GCs for the whole 10 s `--heap` grace, B0601; Chrome still holds the observer).
|
|
29
|
+
function visible(el, go) {
|
|
30
|
+
const box = { go }
|
|
31
|
+
const io = new IntersectionObserver(es => es.some(e => e.isIntersecting) && box.go?.(), { rootMargin: '200px' })
|
|
32
|
+
io.observe(el)
|
|
33
|
+
return () => { box.go = null; io.disconnect() }
|
|
34
|
+
}
|
|
35
|
+
|
|
26
36
|
export function lazy(el, imp, mode, props = {}) {
|
|
27
37
|
const owner = I.owner()
|
|
28
38
|
let dead = false, stop = () => {}, retry = () => {}, clicked = null, built
|
|
@@ -77,11 +87,8 @@ export function lazy(el, imp, mode, props = {}) {
|
|
|
77
87
|
}
|
|
78
88
|
} else if (G.document && document.visibilityState === 'hidden') {
|
|
79
89
|
go() // a hidden tab runs no observer: without this the content is missing until the tab is shown
|
|
80
|
-
} else if (G.IntersectionObserver)
|
|
81
|
-
|
|
82
|
-
io.observe(el)
|
|
83
|
-
stop = () => io.disconnect()
|
|
84
|
-
} else go()
|
|
90
|
+
} else if (G.IntersectionObserver) stop = visible(el, go)
|
|
91
|
+
else go()
|
|
85
92
|
if (owner)
|
|
86
93
|
fx(() => () => {
|
|
87
94
|
dead = true
|
package/brain-router.js
CHANGED
|
@@ -15,6 +15,10 @@
|
|
|
15
15
|
// else → full page load: the server decides.
|
|
16
16
|
import { prefetchPage } from './brain-prefetch-shared.js'
|
|
17
17
|
const G = globalThis
|
|
18
|
+
// The view mounted after a swap is a new page: its components number their instance ids from the start (codegen #268:
|
|
19
|
+
// the first keeps `id`, later ones `id~2`), as the server does per render. Without this, a layout header mounted again
|
|
20
|
+
// on each navigation counted the page it replaced and came back as `main-nav~2`, so `#main-nav` CSS stopped applying.
|
|
21
|
+
const fresh = () => { G.__brainIds = (G.__brainIds || 0) + 1 }
|
|
18
22
|
// Base path (<meta name="brain-base" content="/app/">, §23 #148): links in the HTML already carry it; guards and
|
|
19
23
|
// routes work with app paths (strip), and app paths given to go()/navigate()/redirect() get it (app). '/' when absent.
|
|
20
24
|
const base = () => G.document?.querySelector('meta[name=brain-base]')?.getAttribute('content') || '/'
|
|
@@ -73,17 +77,20 @@ export function redirect(url, status = 303) {
|
|
|
73
77
|
throw { redirect: String(url), status }
|
|
74
78
|
}
|
|
75
79
|
export const deny = (reason = 'Forbidden') => ({ deny: String(reason) })
|
|
80
|
+
// A guard redirects to same-site paths only: `//host`, `/\\host` and a control character after the first `/` (browsers
|
|
81
|
+
// drop tabs and newlines) leave the site. Anything else is a full load, where the server's redirect() refuses it.
|
|
82
|
+
const local = u => typeof u === 'string' && /^\/(?![\/\\])/.test(u) && !/[\\\x00-\x20\x7f]/.test(u)
|
|
76
83
|
// guard result → true (go on) · false (cancel) · string (redirect) · null (full load: the server decides, e.g. 403)
|
|
77
84
|
const outcome = v =>
|
|
78
85
|
v && typeof v === 'object'
|
|
79
86
|
? typeof v.redirect === 'string'
|
|
80
|
-
? v.redirect
|
|
87
|
+
? local(v.redirect) ? v.redirect : null
|
|
81
88
|
: v.deny !== undefined
|
|
82
89
|
? null
|
|
83
90
|
: true
|
|
84
|
-
: v === false ||
|
|
91
|
+
: v === false || local(v)
|
|
85
92
|
? v
|
|
86
|
-
: true
|
|
93
|
+
: typeof v === 'string' ? null : true
|
|
87
94
|
|
|
88
95
|
export function router(o = {}) {
|
|
89
96
|
const region = o.region || 'main'
|
|
@@ -124,7 +131,7 @@ export function router(o = {}) {
|
|
|
124
131
|
v = await g(ctx)
|
|
125
132
|
} catch (e) {
|
|
126
133
|
if (asked) return null
|
|
127
|
-
return
|
|
134
|
+
return local(e?.redirect) ? e.redirect : null
|
|
128
135
|
}
|
|
129
136
|
v = outcome(v)
|
|
130
137
|
if (v !== true) return asked ? null : v
|
|
@@ -170,6 +177,7 @@ export function router(o = {}) {
|
|
|
170
177
|
const el = document.querySelector(region), mount = c.m || c.mount
|
|
171
178
|
if (w.done || my !== seq || !el || !mount) return // the page arrived first
|
|
172
179
|
dispose(el)
|
|
180
|
+
fresh()
|
|
173
181
|
for (const n of [...el.childNodes]) if (!/^(STYLE|LINK)$/.test(n.nodeName)) n.remove() // the view's CSS stays (BUILD #35)
|
|
174
182
|
el.$d = mount(v.default, el, {})
|
|
175
183
|
})
|
|
@@ -207,6 +215,7 @@ export function router(o = {}) {
|
|
|
207
215
|
const swap = () => {
|
|
208
216
|
if (my !== seq) return
|
|
209
217
|
dispose(cur)
|
|
218
|
+
fresh()
|
|
210
219
|
cur.replaceChildren(...[...next.childNodes].map(adopt))
|
|
211
220
|
// Scripts parsed by DOMParser never run: the region's own scripts (the view's mount, `e.$d = mount(...)`) are
|
|
212
221
|
// recreated so they execute. Already-imported modules come from the module map: their state survives.
|
|
@@ -490,6 +499,7 @@ export function predict(o = {}, same = () => false) {
|
|
|
490
499
|
if (dead || document.hidden || net?.saveData) return
|
|
491
500
|
while (busy < PARALLEL && queue.length) {
|
|
492
501
|
const k = queue.shift()
|
|
502
|
+
rank.delete(k) // rank only orders the queue: it leaves with the entry
|
|
493
503
|
if (cache.has(k)) continue
|
|
494
504
|
busy++
|
|
495
505
|
get(k).finally(() => { busy--; pump() })
|
|
@@ -516,7 +526,7 @@ export function predict(o = {}, same = () => false) {
|
|
|
516
526
|
const evs = ['pointerover', 'focusin', 'touchstart']
|
|
517
527
|
for (const t of evs) document.addEventListener(t, intent, { passive: true, capture: true })
|
|
518
528
|
const forget = () => {
|
|
519
|
-
epoch++; queue.length = 0; cache.clear(); bytes = 0; count = 0
|
|
529
|
+
epoch++; queue.length = 0; rank.clear(); cache.clear(); bytes = 0; count = 0
|
|
520
530
|
for (const c of running.values()) c.abort()
|
|
521
531
|
running.clear()
|
|
522
532
|
}
|
|
@@ -526,13 +536,13 @@ export function predict(o = {}, same = () => false) {
|
|
|
526
536
|
for (const { target: a, isIntersecting } of es) {
|
|
527
537
|
const k = ok(a), i = queue.indexOf(k)
|
|
528
538
|
if (isIntersecting) thin() || want(k, false, (a.closest('footer,aside') ? 1e7 : 0) + a.getBoundingClientRect().top)
|
|
529
|
-
else if (i >= 0) queue.splice(i, 1) // left the viewport before its turn
|
|
539
|
+
else if (i >= 0) queue.splice(i, 1), rank.delete(k) // left the viewport before its turn
|
|
530
540
|
}
|
|
531
541
|
}, { rootMargin: '200px' })
|
|
532
542
|
return {
|
|
533
543
|
scan() {
|
|
534
544
|
if (dead) return
|
|
535
|
-
count = 0; queue.length = 0; planned.clear(); seen = new WeakSet(); io?.disconnect()
|
|
545
|
+
count = 0; queue.length = 0; rank.clear(); planned.clear(); seen = new WeakSet(); io?.disconnect()
|
|
536
546
|
const anchors = document.querySelectorAll('a[href]'), allowed = new Set()
|
|
537
547
|
for (const a of anchors) { const k = ok(a); if (k) allowed.add(k); if (io && !seen.has(a)) { seen.add(a); io.observe(a) } }
|
|
538
548
|
// Compiler-generated graph, validated against the current DOM: hidden/opted-out links cannot sneak in.
|
package/brain.js
CHANGED
|
@@ -277,11 +277,16 @@ export function insert(p, f, end) {
|
|
|
277
277
|
|
|
278
278
|
// aria-* and data-* are enumerated, not boolean: true and false render as text (k[4] is their dash)
|
|
279
279
|
// A select only takes a value its options offer, and {#each}/{#if} options mount after attr() or change later.
|
|
280
|
+
// An array (<select multiple>) selects each option it lists (value attribute, else text, as the browser reads it).
|
|
281
|
+
const pick = (el, v) => {
|
|
282
|
+
if (!Array.isArray(v)) return el.value !== v && (el.value = v)
|
|
283
|
+
for (const o of el.querySelectorAll('option')) o.selected = v.includes(o.getAttribute('value') ?? o.textContent)
|
|
284
|
+
}
|
|
280
285
|
const keepSelected = (el, f, M = globalThis.MutationObserver) =>
|
|
281
286
|
M && new M(() => {
|
|
282
|
-
const i = el.selectedIndex
|
|
283
|
-
el
|
|
284
|
-
if (el.selectedIndex < 0) el.selectedIndex = i // no option matches yet: the browser's choice stays
|
|
287
|
+
const i = el.selectedIndex, v = typeof f == 'function' ? untrack(f) : f
|
|
288
|
+
pick(el, v)
|
|
289
|
+
if (el.selectedIndex < 0 && !Array.isArray(v)) el.selectedIndex = i // no option matches yet: the browser's choice stays
|
|
285
290
|
}).observe(el, { childList: true, subtree: true })
|
|
286
291
|
|
|
287
292
|
export function attr(el, k, f) {
|
|
@@ -290,18 +295,45 @@ export function attr(el, k, f) {
|
|
|
290
295
|
k.startsWith('style:')
|
|
291
296
|
? ((k = k.slice(6)), v => (v == null || v === false ? el.style.removeProperty(k) : el.style.setProperty(k, v)))
|
|
292
297
|
: /^(value|checked|selected)$/.test(k) && !(k == 'value' && el.tagName == 'OPTION') // an option's value is its attribute (SSR, value="")
|
|
293
|
-
?
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
298
|
+
? el.tagName == 'SELECT' && k == 'value'
|
|
299
|
+
? (keepSelected(el, f), v => pick(el, v))
|
|
300
|
+
: v => {
|
|
301
|
+
if (el[k] !== v) el[k] = v
|
|
302
|
+
}
|
|
303
|
+
: v => (v == null || (v === false && k[4] != '-') ? el.removeAttribute(k) : el.setAttribute(k, v === true && k[4] != '-' ? '' : typeof v == 'string' ? safeUrl(el, k, v) : v))
|
|
297
304
|
)
|
|
298
305
|
}
|
|
306
|
+
// Attributes whose value is a URL the browser follows or loads (§31): one list for the browser and SSR (server.mjs renders
|
|
307
|
+
// through this same attr()); brain-check security.rs URL_ATTRS checks literal values against the same names.
|
|
308
|
+
// `url`: one URL · `list`: a srcset (`url descriptor, …`) · `space`: space-separated URLs. `data` is a URL on <object> only.
|
|
309
|
+
const URL_ATTRS = { href: 'url', src: 'url', action: 'url', formaction: 'url', poster: 'url', 'xlink:href': 'url', cite: 'url', data: 'url', background: 'url', longdesc: 'url', manifest: 'url', codebase: 'url', icon: 'url', srcset: 'list', imagesrcset: 'list', ping: 'space' }
|
|
310
|
+
const safeUrl = (el, k, v, kind = Object.hasOwn(URL_ATTRS, k.toLowerCase()) && URL_ATTRS[k.toLowerCase()]) =>
|
|
311
|
+
!kind || (k.toLowerCase() == 'data' && el.tagName != 'OBJECT') ? v
|
|
312
|
+
: kind == 'url' ? inBase(v)
|
|
313
|
+
: kind == 'space' ? v.split(/\s+/).filter(Boolean).map(u => inBase(u)).filter(Boolean).join(' ')
|
|
314
|
+
: srcsetCandidates(v).map(([u, d]) => [inBase(u), d]).filter(([u]) => u).map(([u, d]) => d ? u + ' ' + d : u).join(', ')
|
|
315
|
+
// `a.jpg 1x, data:image/png;base64,AA== 2x` → [[url, descriptor], …] (HTML srcset parsing: a URL ends at whitespace,
|
|
316
|
+
// trailing commas end a candidate without descriptor, descriptors run to the next comma).
|
|
317
|
+
const srcsetCandidates = (v, out = [], i = 0) => {
|
|
318
|
+
for (;;) {
|
|
319
|
+
while (i < v.length && /[\s,]/.test(v[i])) i++
|
|
320
|
+
if (i >= v.length) return out
|
|
321
|
+
let j = i
|
|
322
|
+
while (j < v.length && !/\s/.test(v[j])) j++
|
|
323
|
+
let url = v.slice(i, j), d = ''
|
|
324
|
+
if (/,+$/.test(url)) url = url.replace(/,+$/, '')
|
|
325
|
+
else { const e = v.indexOf(',', j); d = v.slice(j, e < 0 ? v.length : e).trim(); j = e < 0 ? v.length : e }
|
|
326
|
+
out.push([url, d])
|
|
327
|
+
i = j
|
|
328
|
+
}
|
|
329
|
+
}
|
|
299
330
|
// Base path (§23 #148): <meta name="brain-base"> in the browser, globalThis.__brainBase while server.mjs renders.
|
|
300
331
|
// A computed app URL ('/posts/7') gets it; literal ones already have it from the build.
|
|
301
332
|
const base = (m = document.querySelector('meta[name=brain-base]')) => globalThis.__brainBase || (m ? m.getAttribute('content') : '/')
|
|
302
333
|
// javascript:, vbscript: and data: (other than data:image/) in a bound URL are dropped and reported (B0213, §31)
|
|
334
|
+
// (tabs and newlines inside the scheme do not hide it: the browser strips them before reading the scheme)
|
|
303
335
|
const inBase = (v, b = base()) =>
|
|
304
|
-
|
|
336
|
+
/^[\s\x00-\x1f]*(javascript|vbscript|data(?!:image\/)):/i.test(v.replace(/[\t\n\r]/g, ''))
|
|
305
337
|
? (oops(Error('B0213 blocked URL scheme')), '')
|
|
306
338
|
: v[0] == '/' && v[1] != '/' && !(v + '/').startsWith(b) ? b + v.slice(1) : v
|
|
307
339
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@christianirack/brainllm-runtime",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.10",
|
|
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
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// It serves, in this order:
|
|
8
8
|
// GET /_health → 200 {"ok":true,"build":"<sig>"}
|
|
9
9
|
// GET /health[/live|/ready] → forwarded to the native backend, or 200 {"ok":true} without one
|
|
10
|
+
// GET /_brain/health[/live|/ready] → the same probe (mapped to /health…), never forwarded as a /_brain call
|
|
10
11
|
// POST /_r/<hash>/<fn> → RPC to a `server:` function (same origin + X-Brain-RPC; body, rate and time limits from brain.json `server`)
|
|
11
12
|
// GET routes in the manifest → SSR: load(ctx) → render the view on the DOM shim → page with props and SEO head
|
|
12
13
|
// GET files in dist/ → static (the smallest accepted .zst/.br/.gz sibling; /_b/* immutable), clean URLs
|
|
@@ -193,7 +194,7 @@ const cacheSearch = url => {
|
|
|
193
194
|
return kept.size ? '?' + kept : ''
|
|
194
195
|
}
|
|
195
196
|
// Messages this file writes itself, safe to log as the cause; anything else in an error message may hold private data.
|
|
196
|
-
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
|
|
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'])
|
|
197
198
|
const CODE_SHAPE = /^[A-Z][A-Z0-9_]{0,63}$/
|
|
198
199
|
const varyEncoding = v => (/accept-encoding/i.test(v || '') ? v : [v, 'Accept-Encoding'].filter(Boolean).join(', '))
|
|
199
200
|
function bodyEncoding(req, status, headers) {
|
|
@@ -226,17 +227,31 @@ function googleSignInPage(page) {
|
|
|
226
227
|
|
|
227
228
|
// One configured loopback authority. No user-controlled target, proxy, redirect, auth cache or pending queue.
|
|
228
229
|
// ctx.backend.files(bucket): the same file calls as job and route handlers (docs/backend/files.md).
|
|
229
|
-
function bucketClient(target, key, bucket) {
|
|
230
|
+
function bucketClient(target, key, bucket, readBudgetMs = () => 0) {
|
|
230
231
|
const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i
|
|
231
232
|
if (typeof bucket !== 'string' || !/^[a-z][a-z0-9_]{0,47}$/.test(bucket)) throw new HttpError(400, 'Invalid native bucket name')
|
|
232
233
|
const base = '/_brain/files/' + bucket
|
|
233
234
|
const idPath = id => { if (typeof id !== 'string' || !UUID.test(id)) throw new HttpError(404, 'No file has this id; file ids are UUIDs'); return base + '/' + id }
|
|
235
|
+
// A read waits for a draining or restarting backend like ctx.backend reads do (nativeAuthBridge); a write never repeats.
|
|
234
236
|
const send = async (method, path, body, timeout) => {
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
237
|
+
const end = Date.now() + (method === 'GET' ? readBudgetMs() : 0)
|
|
238
|
+
for (let wait = 250; ; wait = Math.min(wait * 2, 2000)) {
|
|
239
|
+
let response, failure, notNow = false
|
|
240
|
+
try { response = await fetch(new URL(path, target), { method, redirect: 'error', signal: AbortSignal.timeout(timeout), headers: { 'x-api-key': key }, body }) } catch (e) {
|
|
241
|
+
failure = new HttpError(503, 'Native backend is unavailable', { temporary: true }); notNow = e?.name !== 'TimeoutError'
|
|
242
|
+
}
|
|
243
|
+
if (response?.ok) return response
|
|
244
|
+
if (response) {
|
|
245
|
+
let code
|
|
246
|
+
try { code = (await response.json())?.error?.code } catch {}
|
|
247
|
+
const retryAfter = Number(response.headers.get('retry-after'))
|
|
248
|
+
notNow = [502, 503, 504].includes(response.status) && (!code || code === 'DRAINING' || code === 'OVERLOADED')
|
|
249
|
+
failure = new HttpError(response.status >= 400 && response.status < 500 ? response.status : 503, 'Native file request failed', { temporary: response.status === 429 || response.status === 503 || notNow, ...(typeof code === 'string' && /^[A-Z][A-Z0-9_]{0,63}$/.test(code) ? { code } : {}), ...(retryAfter > 0 ? { retryAfter } : {}) })
|
|
250
|
+
}
|
|
251
|
+
const pause = Math.max(wait, (failure.retryAfter || 0) * 1000)
|
|
252
|
+
if (!notNow || Date.now() + pause > end) throw failure
|
|
253
|
+
await new Promise(done => setTimeout(done, pause))
|
|
254
|
+
}
|
|
240
255
|
}
|
|
241
256
|
const json = async response => { try { return await response.json() } catch { throw new HttpError(502, 'Native file response is invalid') } }
|
|
242
257
|
return Object.freeze({
|
|
@@ -266,7 +281,13 @@ function bucketClient(target, key, bucket) {
|
|
|
266
281
|
async remove(id) { await send('DELETE', idPath(id), undefined, 10000); return null },
|
|
267
282
|
})
|
|
268
283
|
}
|
|
269
|
-
|
|
284
|
+
// Failures of a native read that may be asked again (a gateway error, DRAINING, OVERLOADED), marked where built.
|
|
285
|
+
const askAgain = new WeakSet()
|
|
286
|
+
// `readBudget`: ms (or a function giving them) a read may spend waiting for a draining or restarting backend; the
|
|
287
|
+
// server passes its load timeout minus a second, so the page still answers within it.
|
|
288
|
+
export function nativeAuthBridge(raw, refused, serviceKey, report, ownSession, readBudget = 8000) {
|
|
289
|
+
const budget = typeof readBudget === 'function' ? readBudget : () => readBudget
|
|
290
|
+
const readBudgetMs = () => Math.max(0, Math.min(Number(budget()) || 0, 30000))
|
|
270
291
|
// How to fix the configuration is for the operator: a visitor of a production app reads a generic message.
|
|
271
292
|
const configuration = (status, detail, rid, shown = 'This service is not available right now') => {
|
|
272
293
|
if (process.env.NODE_ENV !== 'production') return new HttpError(status, detail)
|
|
@@ -308,9 +329,9 @@ export function nativeAuthBridge(raw, refused, serviceKey, report, ownSession) {
|
|
|
308
329
|
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')
|
|
309
330
|
return Object.freeze({id:user.id,email:user.email,role:user.role,verified:user.verified})
|
|
310
331
|
}
|
|
311
|
-
const unavailable = () => new HttpError(503,
|
|
332
|
+
const unavailable = (why = 'the backend did not answer') => new HttpError(503, `Native authentication is unavailable: ${why}`)
|
|
312
333
|
const call = (incoming, response, path, method, rid, origin) => {
|
|
313
|
-
if (stopped || active >= 8) return Promise.reject(unavailable())
|
|
334
|
+
if (stopped || active >= 8) return Promise.reject(unavailable(stopped ? 'the server is shutting down' : '8 session checks are already in flight'))
|
|
314
335
|
const cookie = cookies(incoming, method === 'POST')
|
|
315
336
|
const client = forwardedFor(incoming)
|
|
316
337
|
active++
|
|
@@ -350,7 +371,7 @@ export function nativeAuthBridge(raw, refused, serviceKey, report, ownSession) {
|
|
|
350
371
|
})
|
|
351
372
|
})
|
|
352
373
|
owned.add(outgoing); incoming.once('aborted', cancel); response.once('close', disconnected)
|
|
353
|
-
timer = setTimeout(() => outgoing.destroy(unavailable()), 2000)
|
|
374
|
+
timer = setTimeout(() => outgoing.destroy(unavailable('the backend took more than 2 s')), 2000)
|
|
354
375
|
outgoing.once('error', () => finish(true)); outgoing.end()
|
|
355
376
|
} catch { outgoing?.destroy(); finish(true) }
|
|
356
377
|
})
|
|
@@ -363,7 +384,7 @@ export function nativeAuthBridge(raw, refused, serviceKey, report, ownSession) {
|
|
|
363
384
|
let origin
|
|
364
385
|
try { origin = new URL(process.env.PUBLIC_SITE_URL).origin } catch { origin = 'https://' + (req.headers['x-forwarded-host'] || req.headers.host) }
|
|
365
386
|
return new Promise((resolve, reject) => {
|
|
366
|
-
if (stopped || active >= 8) return reject(unavailable())
|
|
387
|
+
if (stopped || active >= 8) return reject(unavailable(stopped ? 'the server is shutting down' : '8 session checks are already in flight'))
|
|
367
388
|
active++
|
|
368
389
|
let settled = false
|
|
369
390
|
const done = (error, value) => { if (settled) return; settled = true; active--; clearTimeout(timer); error ? reject(unavailable()) : resolve(value) }
|
|
@@ -388,7 +409,7 @@ export function nativeAuthBridge(raw, refused, serviceKey, report, ownSession) {
|
|
|
388
409
|
} catch { done(true) }
|
|
389
410
|
})
|
|
390
411
|
})
|
|
391
|
-
const timer = setTimeout(() => outgoing.destroy(unavailable()), 2000)
|
|
412
|
+
const timer = setTimeout(() => outgoing.destroy(unavailable('the backend took more than 2 s')), 2000)
|
|
392
413
|
owned.add(outgoing); outgoing.once('close', () => owned.delete(outgoing))
|
|
393
414
|
outgoing.once('error', () => done(true)); outgoing.end()
|
|
394
415
|
})
|
|
@@ -602,7 +623,12 @@ export function nativeAuthBridge(raw, refused, serviceKey, report, ownSession) {
|
|
|
602
623
|
const code = typeof answer?.code === 'string' && /^[A-Z][A-Z0-9_]{0,63}$/.test(answer.code) ? answer.code : undefined
|
|
603
624
|
const fix = code && typeof answer.fix === 'string' ? answer.fix.slice(0, 512) : undefined
|
|
604
625
|
const wait = Number(headers['retry-after'])
|
|
605
|
-
|
|
626
|
+
// A gateway error, or a backend draining/at capacity, is "not now": a read may ask again (below). A code
|
|
627
|
+
// of the backend's own (BATCH_TOO_SLOW…) is an answer on purpose and is never repeated.
|
|
628
|
+
const notNow = [502, 503, 504].includes(status) && (!code || code === 'DRAINING' || code === 'OVERLOADED')
|
|
629
|
+
const failure = new HttpError(status >= 400 && status < 500 ? status : 503, NATIVE_FAILED, { code, fix, temporary: status === 429 || status === 503 || notNow, ...(wait > 0 ? { retryAfter: wait } : {}) })
|
|
630
|
+
if (notNow) askAgain.add(failure)
|
|
631
|
+
throw failure
|
|
606
632
|
}
|
|
607
633
|
if (status === 204) { value = null; return }
|
|
608
634
|
if (!String(headers['content-type'] || '').startsWith('application/json')) throw new HttpError(502, 'Native resource response must be JSON')
|
|
@@ -610,15 +636,20 @@ export function nativeAuthBridge(raw, refused, serviceKey, report, ownSession) {
|
|
|
610
636
|
if (remembers && typeof headers.etag === 'string') rememberRead(path, headers.etag, data)
|
|
611
637
|
}
|
|
612
638
|
try {
|
|
613
|
-
//
|
|
614
|
-
|
|
639
|
+
// An autodeploy drains the backend (503 DRAINING, then a closed port) and starts the new one: a read waits for
|
|
640
|
+
// it within the load budget instead of failing the page, honoring Retry-After. A write is never repeated.
|
|
641
|
+
const end = Date.now() + readBudgetMs()
|
|
642
|
+
for (let wait = 250; ; wait = Math.min(wait * 2, 2000)) {
|
|
615
643
|
try {
|
|
616
644
|
await proxy(req, response, new URL(path, target), rid, answered, 2 * 1024 * 1024)
|
|
617
645
|
break
|
|
618
646
|
} catch (e) {
|
|
619
|
-
|
|
647
|
+
const transient = ['ECONNREFUSED', 'ECONNRESET'].includes(e?.transport) || askAgain.has(e)
|
|
648
|
+
const pause = Math.max(wait, (e?.retryAfter || 0) * 1000)
|
|
649
|
+
if (method !== 'GET' || !transient || stopped || response.destroyed || Date.now() + pause > end) throw e
|
|
620
650
|
req.destroy()
|
|
621
|
-
await new Promise(done => setTimeout(done,
|
|
651
|
+
await new Promise(done => setTimeout(done, pause))
|
|
652
|
+
if (stopped || response.destroyed) throw e
|
|
622
653
|
req = conditional(outgoing())
|
|
623
654
|
}
|
|
624
655
|
}
|
|
@@ -841,7 +872,7 @@ export function nativeAuthBridge(raw, refused, serviceKey, report, ownSession) {
|
|
|
841
872
|
// A declared file bucket from trusted server code, with BRAIN_SERVICE_API_KEY and its files.<bucket> scope
|
|
842
873
|
files: bucket => {
|
|
843
874
|
if (!serviceKey) throw configuration(503, 'ctx.backend.files needs BRAIN_SERVICE_API_KEY: create an API key with a files.<bucket> scope')
|
|
844
|
-
return bucketClient(target, serviceKey, bucket)
|
|
875
|
+
return bucketClient(target, serviceKey, bucket, readBudgetMs)
|
|
845
876
|
},
|
|
846
877
|
slugRedirect: (name, slug) => resource(incoming, response, rid, name).slugRedirect(slug),
|
|
847
878
|
// A declared operation, as this visitor (docs/backend/transactions.md).
|
|
@@ -900,7 +931,7 @@ export function createServer(o = {}) {
|
|
|
900
931
|
const env = Object.freeze(visibleEnv)
|
|
901
932
|
const backendSession = o.backendSession ?? env.BRAIN_BACKEND_SESSION
|
|
902
933
|
if (backendSession && backendSession !== 'app') throw Error("BRAIN_BACKEND_SESSION must be app, or unset for the backend's own session")
|
|
903
|
-
const native = nativeAuthBridge(o.backendURL ?? env.BRAIN_BACKEND_URL, (...why) => originRefused(...why), serviceKey, (...entry) => log(...entry), backendSession === 'app')
|
|
934
|
+
const native = nativeAuthBridge(o.backendURL ?? env.BRAIN_BACKEND_URL, (...why) => originRefused(...why), serviceKey, (...entry) => log(...entry), backendSession === 'app', () => TIMEOUT - 1000)
|
|
904
935
|
// Using the native backend does not have to mean it owns the visitor's session: `app` keeps the signed cookie.
|
|
905
936
|
const nativeSession = native && backendSession !== 'app'
|
|
906
937
|
if (o.nativeSockets !== undefined && typeof o.nativeSockets !== 'boolean') throw Error('nativeSockets must be an explicit boolean')
|
|
@@ -910,6 +941,7 @@ export function createServer(o = {}) {
|
|
|
910
941
|
const sink = o.log || ((level, msg, extra) => console[level === 'error' ? 'error' : 'log'](json({ t: new Date().toISOString(), level, msg, ...extra })))
|
|
911
942
|
// Requests in flight by rid: a 5xx whose cause reached no log line gets one when the response closes (dispatch below).
|
|
912
943
|
const liveRequests = new Map()
|
|
944
|
+
const hooked = new WeakSet() // core modules whose error hook reports to this server's log (render below)
|
|
913
945
|
let statusPageWindow = 0, statusPageLogs = 0
|
|
914
946
|
const log = (level, msg, extra = {}) => {
|
|
915
947
|
const safe = { ...extra }
|
|
@@ -918,12 +950,13 @@ export function createServer(o = {}) {
|
|
|
918
950
|
if (typeof safe.stack === 'string') {
|
|
919
951
|
const kind = /^(TypeError|ReferenceError|RangeError|SyntaxError|AggregateError|Error):/.exec(safe.stack)?.[1] || 'Error'
|
|
920
952
|
safe.errorType = kind
|
|
921
|
-
|
|
953
|
+
// "not now" (a 429/503, a backend restarting or busy) is not a defect: its own code, so alerts can tell them apart
|
|
954
|
+
safe.code = safe.temporary ? 'NODE_TEMPORARY_ERROR' : kind === 'Error' ? 'NODE_INTERNAL_ERROR' : 'NODE_' + kind.replace(/Error$/, '').toUpperCase() + '_ERROR'
|
|
922
955
|
const truncated = safe.stack.length > 32768 || safe.stack.split('\n').length > 41
|
|
923
956
|
safe.traceTruncated = truncated
|
|
924
957
|
safe.stack = safe.stack.slice(0, 32768).split('\n').filter(line => /^\s+at (?:async )?(?:[A-Za-z0-9_.$<>]+(?: \[as [A-Za-z0-9_$]+\])? \()?(?:file:\/\/\/|\/|node:)[^\s()?#]*:\d+:\d+\)?$/.test(line) && line.length <= 1024).slice(0, 40).join('\n')
|
|
925
958
|
safe.private = true; safe.valuesCaptured = false
|
|
926
|
-
safe.fix = kind === 'TypeError' ? 'Check nullable values, argument types and the data contract at these source frames' : kind === 'ReferenceError' ? 'Check the missing binding or import at these source frames' : kind === 'RangeError' ? 'Check recursive calls, array sizes and configured resource budgets at these source frames' : kind === 'SyntaxError' ? 'Check module syntax or JSON input parsing at these source frames without logging input values' : 'Inspect these source frames and the request reference; reproduce with synthetic inputs'
|
|
959
|
+
safe.fix = safe.thrown ? `A non-Error value (${safe.thrown}) was thrown, so there are no source frames; throw an Error (or HttpError) instead` : safe.temporary ? 'A dependency answered not now (rate limit, restart, overload or a closed connection); the same request can succeed later. If it persists, check the backend health and its Retry-After' : kind === 'TypeError' ? 'Check nullable values, argument types and the data contract at these source frames' : kind === 'ReferenceError' ? 'Check the missing binding or import at these source frames' : kind === 'RangeError' ? 'Check recursive calls, array sizes and configured resource budgets at these source frames' : kind === 'SyntaxError' ? 'Check module syntax or JSON input parsing at these source frames without logging input values' : 'Inspect these source frames and the request reference; reproduce with synthetic inputs'
|
|
927
960
|
}
|
|
928
961
|
// a compiler budget failure (brain-budget.js) keeps its own code, limit, source position and fix
|
|
929
962
|
const b = safe.budgetFailure
|
|
@@ -949,7 +982,11 @@ export function createServer(o = {}) {
|
|
|
949
982
|
// log fields for a thrown value: its stack (sanitized by log) plus, for B09xx budget failures, the typed fields only
|
|
950
983
|
const trace = e => {
|
|
951
984
|
const out = { stack: String((e && e.stack) || e) }
|
|
952
|
-
|
|
985
|
+
// a thrown plain object, string or undefined has no stack: say what kind of value it was (never the value itself)
|
|
986
|
+
if (!(e instanceof Error)) out.thrown = e === null ? 'null' : Array.isArray(e) ? 'array' : typeof e
|
|
987
|
+
if (e && typeof e === 'object' && (e.temporary === true || e.status === 429 || e.status === 503)) out.temporary = true
|
|
988
|
+
if (e instanceof HttpError && (OWN_CAUSES.has(e.message) || e.message.startsWith('Native authentication is unavailable:'))) out.cause = e.message
|
|
989
|
+
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'
|
|
953
990
|
if (e && CODE_SHAPE.test(e.transport || '')) out.transport = e.transport
|
|
954
991
|
if (e && typeof e.code === 'string' && CODE_SHAPE.test(e.code)) out.backendCode = e.code
|
|
955
992
|
if (e && e.kind === 'budget' && /^B09\d\d$/.test(e.code) && typeof e.budget === 'string' && /^[a-zA-Z]{1,16}$/.test(e.budget)) {
|
|
@@ -1135,6 +1172,34 @@ export function createServer(o = {}) {
|
|
|
1135
1172
|
if (!mods.has(rel)) mods.set(rel, import(new URL(rel.replace(/^\//, ''), distURL).href))
|
|
1136
1173
|
return mods.get(rel)
|
|
1137
1174
|
}
|
|
1175
|
+
// Every module a request can import (the DOM shim, the core, each route's view, layouts, guards, middleware, server
|
|
1176
|
+
// module, dictionary, loading and error views, the RPC modules), imported once, one after another. `node server.mjs`
|
|
1177
|
+
// runs it before `brain:ready`, so the first request after a (re)start finds them loaded instead of reading,
|
|
1178
|
+
// compiling and evaluating the app's module graph (and its packages) while the visitor waits. A module that throws
|
|
1179
|
+
// stays rejected in `mods`, exactly as the request that imports it would have found it; it is counted, never fatal.
|
|
1180
|
+
const warm = async () => {
|
|
1181
|
+
const want = new Set(['_server/dom.js', mf.core])
|
|
1182
|
+
const add = r => {
|
|
1183
|
+
if (!r || typeof r !== 'object') return
|
|
1184
|
+
for (const k of ['view', 'server', 'loading', 'i18n']) if (typeof r[k] === 'string') want.add(r[k])
|
|
1185
|
+
for (const k of ['layouts', 'guards', 'middleware']) for (const m of r[k] || []) if (typeof m === 'string') want.add(m)
|
|
1186
|
+
add(r.error)
|
|
1187
|
+
}
|
|
1188
|
+
for (const r of mf.routes) add(r)
|
|
1189
|
+
add(mf.notFound)
|
|
1190
|
+
add(mf.forbidden)
|
|
1191
|
+
for (const m of Object.values(mf.rpc)) if (typeof m === 'string') want.add(m)
|
|
1192
|
+
for (const m of ['_server/brain-hydrate.js', '_server/brain-form.js']) if (file(m)) want.add(m)
|
|
1193
|
+
let failed = 0
|
|
1194
|
+
for (const rel of want) {
|
|
1195
|
+
try {
|
|
1196
|
+
await load(rel)
|
|
1197
|
+
} catch {
|
|
1198
|
+
failed++
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
return { modules: want.size, failed }
|
|
1202
|
+
}
|
|
1138
1203
|
|
|
1139
1204
|
const context = (req, res, url, extra) => {
|
|
1140
1205
|
const cookies = parseCookies(req.headers.cookie)
|
|
@@ -1161,15 +1226,19 @@ export function createServer(o = {}) {
|
|
|
1161
1226
|
let ssrBytes = 0
|
|
1162
1227
|
async function cached(req, res, route, params, url, rid) {
|
|
1163
1228
|
const n = +route.cache, ms = n * 1000, key = (route.locale || '') + url.pathname + cacheSearch(url)
|
|
1164
|
-
|
|
1229
|
+
// `kept.answer`: what this render answered when it was not a 200 (an error page, a redirect, a not-found), so the
|
|
1230
|
+
// request that started it replays that answer instead of running load() and its backend reads a second time
|
|
1231
|
+
const fill = (kept = {}) => {
|
|
1165
1232
|
let failed = false
|
|
1233
|
+
const set = {}
|
|
1166
1234
|
return new Promise(ok => {
|
|
1167
1235
|
const cap = { req, $capture: (out, h, st) => {
|
|
1168
1236
|
const headers = Object.fromEntries(Object.entries(h).map(([k, v]) => [k.toLowerCase(), v]))
|
|
1169
1237
|
const privateResponse = headers['set-cookie'] || headers['vary'] || /private|no-store|no-cache/i.test(headers['cache-control'] || '')
|
|
1170
1238
|
if (st >= 500) failed = true
|
|
1239
|
+
if (st !== 200) kept.answer = { page: out, h, st, set }
|
|
1171
1240
|
ok(st === 200 ? { out, h, cacheable: !privateResponse, etag: 'W/"' + tag(out) + '"', t: Date.now(), bytes: Buffer.byteLength(out) } : null)
|
|
1172
|
-
}, writeHead(st) { if (st >= 500) failed = true }, end: ()
|
|
1241
|
+
}, writeHead(st, h) { if (st >= 500) failed = true; kept.answer = { st, h: h || {}, set } }, end: body => { if (kept.answer && kept.answer.page === undefined) kept.answer.body = body; ok(null) }, setHeader(k, v) { set[k] = v }, getHeader: name => (name === 'x-request-id' ? rid : set[name]), once() {}, removeListener() {} }
|
|
1173
1242
|
ssr(req, cap, route, params, url, rid, null, 200, null, true).catch(() => ok(null))
|
|
1174
1243
|
}).then(c => {
|
|
1175
1244
|
// A failed revalidation keeps the copy until 2N, which is what `stale-if-error` promises downstream.
|
|
@@ -1207,14 +1276,23 @@ export function createServer(o = {}) {
|
|
|
1207
1276
|
if (c?.cacheable) return serve(c)
|
|
1208
1277
|
return ssr(req, res, route, params, url, rid, null, 200, null, true)
|
|
1209
1278
|
}
|
|
1210
|
-
const
|
|
1279
|
+
const kept = {}
|
|
1280
|
+
const filling = fill(kept).finally(() => FILLING.delete(key))
|
|
1211
1281
|
FILLING.set(key, filling)
|
|
1212
1282
|
c = await filling
|
|
1213
1283
|
if (c) return serve(c)
|
|
1214
1284
|
// the first render already asked the backend and logged why; another would only spend more of a limit it just hit
|
|
1215
1285
|
if (req.$brainFailedReads?.temporary) return comeBackPage(req, res, route, params, url, rid)
|
|
1216
1286
|
delete req.$brainFailedReads
|
|
1217
|
-
|
|
1287
|
+
// a redirect, a not-found or an error page: the answer that render produced, not a second load() (and its reads)
|
|
1288
|
+
const a = kept.answer
|
|
1289
|
+
if (a && !res.headersSent) {
|
|
1290
|
+
for (const [k, v] of Object.entries(a.set)) res.setHeader(k, v)
|
|
1291
|
+
if (a.page !== undefined) return html(res, a.st, a.page, a.h)
|
|
1292
|
+
res.writeHead(a.st, a.h)
|
|
1293
|
+
return void res.end(a.body)
|
|
1294
|
+
}
|
|
1295
|
+
return ssr(req, res, route, params, url, rid, null, 200, null, true) // nothing to replay (the render threw): as usual
|
|
1218
1296
|
}
|
|
1219
1297
|
const comeBackPage = (req, res, route, params, url, rid) => {
|
|
1220
1298
|
res.setHeader('retry-after', String(Math.min(Math.ceil(req.$brainFailedReads.retryAfter) || 5, 3600)))
|
|
@@ -1310,7 +1388,8 @@ export function createServer(o = {}) {
|
|
|
1310
1388
|
.replace('<!--brain:props-->', () => jsonInHtml(props))
|
|
1311
1389
|
// a page behind a guard passes it only for its visitor: it stays out of the index even when it renders (§33)
|
|
1312
1390
|
if (route.guards && route.guards.length) res.setHeader('x-robots-tag', NOINDEX)
|
|
1313
|
-
|
|
1391
|
+
// with seo.trailingSlash "ignore" both forms answer: the canonical takes the form the sitemap lists (no slash)
|
|
1392
|
+
let out = head(page, seo, SLASH === 'always' || url.pathname === '/' ? url.pathname : url.pathname.replace(/\/+$/, '') || '/', url.search)
|
|
1314
1393
|
// route params for route() in the browser (the client has no route table)
|
|
1315
1394
|
if (params && Object.keys(params).length) out = out.replace('</head>', () => `<meta name="brain-params" content="${esc(JSON.stringify(params))}"></head>`)
|
|
1316
1395
|
if (forms) out = out.replace('</body>', () => `<script type="application/json" id="brain-forms">${jsonInHtml(forms)}</script></body>`)
|
|
@@ -1449,6 +1528,18 @@ export function createServer(o = {}) {
|
|
|
1449
1528
|
const root = doc.createElement('div')
|
|
1450
1529
|
const core = await load(mf.core)
|
|
1451
1530
|
const mount = core.mount || core.m
|
|
1531
|
+
// The core's console fallback (`BRAIN_ERROR: …`) is for the browser: on the server an fx/drv error goes to this
|
|
1532
|
+
// render's failures (logged with rid and path by ssr()), once. A component boundary (brain-guard) collects its own.
|
|
1533
|
+
if (core._err && !hooked.has(core)) {
|
|
1534
|
+
hooked.add(core)
|
|
1535
|
+
core._err((e, x, w) => {
|
|
1536
|
+
if (w === 'render' || w === 'B0908') return // brain-guard reports these itself, with the component's name
|
|
1537
|
+
for (let o = x; o; o = o.nodeType ? o.parentNode : o.p) if (o.$g) return
|
|
1538
|
+
const into = globalThis.__brainSSR
|
|
1539
|
+
if (into) into.push({ e, w, comp: null })
|
|
1540
|
+
else log('error', 'component failed after SSR', { where: w, ...trace(e) }) // an async fx of a finished render
|
|
1541
|
+
})
|
|
1542
|
+
}
|
|
1452
1543
|
const V = (await load(route.view)).default
|
|
1453
1544
|
const L = []
|
|
1454
1545
|
for (const l of route.layouts || []) L.push((await load(l)).default)
|
|
@@ -1499,8 +1590,16 @@ export function createServer(o = {}) {
|
|
|
1499
1590
|
if (req.method !== 'POST') return fail(405, 'Use POST', { $h: { allow: 'POST' } })
|
|
1500
1591
|
if (req.headers['x-brain-rpc'] !== '1') return fail(403, 'Missing X-Brain-RPC header')
|
|
1501
1592
|
if (!sameOrigin(req)) return originRefused('B0741', req, rid, 'rpc'), fail(403, 'Cross-origin request', { brainCode: 'B0741' })
|
|
1502
|
-
const modPath = mf.rpc[hash]
|
|
1503
|
-
if (!modPath
|
|
1593
|
+
const modPath = fnId(hash + '/' + fn) && Object.hasOwn(mf.rpc, hash) && mf.rpc[hash]
|
|
1594
|
+
if (!modPath) return fail(404, 'Unknown function')
|
|
1595
|
+
let f
|
|
1596
|
+
try {
|
|
1597
|
+
f = serverFn(await load(modPath), fn)
|
|
1598
|
+
} catch (e) {
|
|
1599
|
+
log('error', 'rpc module failed to load', { rid, fn: hash + '/' + fn, ...trace(e) })
|
|
1600
|
+
return fail(500, 'Internal error', { rid })
|
|
1601
|
+
}
|
|
1602
|
+
if (typeof f !== 'function') return fail(404, 'Unknown function') // a guessed name never gets a rate-limit key
|
|
1504
1603
|
const ra = overLimit(ipOf(req) + ' ' + hash + '/' + fn, LIMIT) // before reading the body
|
|
1505
1604
|
if (ra) return fail(429, 'Too many requests', { $h: { 'retry-after': String(ra) } })
|
|
1506
1605
|
let raw
|
|
@@ -1512,13 +1611,6 @@ export function createServer(o = {}) {
|
|
|
1512
1611
|
}
|
|
1513
1612
|
if (native) await native.prepare(req, res, rid)
|
|
1514
1613
|
const ctx = context(req, res, url, { params: {} })
|
|
1515
|
-
let f
|
|
1516
|
-
try {
|
|
1517
|
-
f = (await load(modPath))[fn]
|
|
1518
|
-
} catch (e) {
|
|
1519
|
-
log('error', 'rpc module failed to load', { rid, fn: hash + '/' + fn, ...trace(e) })
|
|
1520
|
-
return fail(500, 'Internal error', { rid })
|
|
1521
|
-
}
|
|
1522
1614
|
const r = await callRpc({ fn: f, args: raw, ctx, timeout: TIMEOUT })
|
|
1523
1615
|
if (r.cause) log('error', r.status === 504 ? 'rpc timeout' : 'rpc failed', { rid, fn: hash + '/' + fn, ms: TIMEOUT, ...trace(r.cause) })
|
|
1524
1616
|
const b = r.status >= 500 ? { ...r.body, rid } : r.body
|
|
@@ -1551,8 +1643,8 @@ export function createServer(o = {}) {
|
|
|
1551
1643
|
if (e.aborted) return
|
|
1552
1644
|
return plain(413, tooBig(rid, url.pathname))
|
|
1553
1645
|
}
|
|
1554
|
-
const id = new URLSearchParams(raw).get('_brain') || '', modPath = mf.rpc
|
|
1555
|
-
if (!modPath) return plain(400, 'Unknown form')
|
|
1646
|
+
const id = new URLSearchParams(raw).get('_brain') || '', fid = fnId(id), modPath = fid && Object.hasOwn(mf.rpc, fid.hash) && mf.rpc[fid.hash]
|
|
1647
|
+
if (!modPath) return plain(400, 'Unknown form') // only `<hash>/<name>`: no rate-limit key for anything else
|
|
1556
1648
|
if (native) await native.prepare(req, res, rid)
|
|
1557
1649
|
const ctx = context(req, res, url, { params })
|
|
1558
1650
|
let module, rules
|
|
@@ -1564,7 +1656,7 @@ export function createServer(o = {}) {
|
|
|
1564
1656
|
log('error', 'form module failed to load', { rid, fn: id, ...trace(e) })
|
|
1565
1657
|
return failPage(res, 500, rid)
|
|
1566
1658
|
}
|
|
1567
|
-
const r = await callForm({ module, raw, ctx, rules, key: ipOf(req) + ' ' +
|
|
1659
|
+
const r = await callForm({ module, raw, ctx, rules, key: ipOf(req) + ' ' + fid.hash + '/' + fid.fn, limit: LIMIT, timeout: TIMEOUT, locale: route.locale })
|
|
1568
1660
|
if (r.cause) log('error', 'form failed', { rid, fn: id, ...trace(r.cause) })
|
|
1569
1661
|
if (r.status === 303) return send(res, 303, '', { location: withBase(r.location), 'cache-control': 'no-store', ...ctx.session.headers() })
|
|
1570
1662
|
if (r.status === 422) return ssr(req, res, route, params, url, rid, r.state, 422)
|
|
@@ -1676,6 +1768,9 @@ export function createServer(o = {}) {
|
|
|
1676
1768
|
}
|
|
1677
1769
|
|
|
1678
1770
|
// ---------- static ----------
|
|
1771
|
+
// Never served (brain-pack private.rs holds the same list for the Rust handler and every generated host config).
|
|
1772
|
+
const PRIVATE_PREFIXES = ['_server', '_brain', 'server.mjs']
|
|
1773
|
+
const PRIVATE_FILES = ['_headers', '_redirects']
|
|
1679
1774
|
// §33 content negotiation: `Accept` preferring text/markdown gets the page's Markdown twin (`/pricing` → pricing.md,
|
|
1680
1775
|
// `/` → index.md) when the build published one (`seo.markdown`, default true; never for noindex pages).
|
|
1681
1776
|
function publicStaticPath(pathname) {
|
|
@@ -1683,8 +1778,8 @@ export function createServer(o = {}) {
|
|
|
1683
1778
|
try { path = decodeURIComponent(pathname) } catch { return null }
|
|
1684
1779
|
if (/[\\\x00]/.test(path)) return null
|
|
1685
1780
|
const clean = normalize(path).replaceAll('\\', '/')
|
|
1686
|
-
const rel = clean.replace(/^\/+/, '')
|
|
1687
|
-
if (
|
|
1781
|
+
const rel = clean.replace(/^\/+/, '').toLowerCase() // a case-insensitive file system opens _SERVER/ as _server/
|
|
1782
|
+
if (PRIVATE_PREFIXES.some(p => rel.startsWith(p)) || PRIVATE_FILES.includes(rel) || clean.split('/').some(part => part.startsWith('.') && part.toLowerCase() !== '.well-known')) return null
|
|
1688
1783
|
return path
|
|
1689
1784
|
}
|
|
1690
1785
|
function serveTwin(req, res, url) {
|
|
@@ -1706,6 +1801,15 @@ export function createServer(o = {}) {
|
|
|
1706
1801
|
const href = tag && /\bhref="(https?:\/\/[^"<>\s]+)"/.exec(tag)?.[1].replaceAll('&', '&')
|
|
1707
1802
|
return href && URL.canParse(href) && !/\/[:*]/.test(new URL(href).pathname) ? { link: `<${href}>; rel="canonical"` } : {}
|
|
1708
1803
|
}
|
|
1804
|
+
// What exists whatever the routes say: generated sitemaps, built files and the icon browsers ask for by habit.
|
|
1805
|
+
async function serveFixed(req, res, url) {
|
|
1806
|
+
if (DYN.length && /^\/sitemap(-static|-dynamic(-\d+)?)?\.xml$/.test(url.pathname) && (await serveSitemap(req, res, url))) return true
|
|
1807
|
+
if (serveStatic(req, res, url)) return true
|
|
1808
|
+
if (url.pathname !== '/favicon.ico' || !file('favicon.svg')) return false
|
|
1809
|
+
send(res, 301, '', { location: withBase('/favicon.svg'), 'cache-control': 'public, max-age=86400' })
|
|
1810
|
+
return true
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1709
1813
|
function serveStatic(req, res, url) {
|
|
1710
1814
|
const p = publicStaticPath(url.pathname)
|
|
1711
1815
|
if (p === null) return false
|
|
@@ -1749,6 +1853,9 @@ export function createServer(o = {}) {
|
|
|
1749
1853
|
// /health is reserved for the backend (B0735), so an app cannot declare it and deploy gates ask this port for it. An app
|
|
1750
1854
|
// page at that path still wins: this only runs where the answer would have been a 404. Nothing here names the backend.
|
|
1751
1855
|
const isProbe = path => path === '/health' || path === '/health/live' || path === '/health/ready'
|
|
1856
|
+
// /_brain/health[/live|/ready] is the same probe under the framework prefix; the backend only serves /health, so it is
|
|
1857
|
+
// answered here (mapped to that route) instead of being forwarded with the rest of /_brain and coming back a 404.
|
|
1858
|
+
const brainProbe = path => path.startsWith('/_brain/health') && isProbe(path.slice(7)) ? path.slice(7) : null
|
|
1752
1859
|
const answerProbe = async (req, res, url, rid) => {
|
|
1753
1860
|
const headers = { 'content-type': MIME['.json'], 'cache-control': 'no-store' }
|
|
1754
1861
|
if (req.method !== 'GET' && req.method !== 'HEAD') return notFound(req, res)
|
|
@@ -1833,10 +1940,15 @@ export function createServer(o = {}) {
|
|
|
1833
1940
|
let url = full
|
|
1834
1941
|
if (BASE !== '/') {
|
|
1835
1942
|
if (full.pathname === BASE.slice(0, -1)) return send(res, 301, '', { location: BASE + full.search })
|
|
1836
|
-
if (!full.pathname.startsWith(BASE))
|
|
1943
|
+
if (!full.pathname.startsWith(BASE)) {
|
|
1944
|
+
const probe = isProbe(full.pathname) ? full.pathname : brainProbe(full.pathname)
|
|
1945
|
+
return probe ? await answerProbe(req, res, new URL(probe, full), rid) : notFound(req, res)
|
|
1946
|
+
}
|
|
1837
1947
|
url = new URL('/' + full.pathname.slice(BASE.length) + full.search, full) // the app path
|
|
1838
1948
|
}
|
|
1839
1949
|
req.$brainPath = url.pathname
|
|
1950
|
+
const probe = brainProbe(url.pathname)
|
|
1951
|
+
if (probe) return await answerProbe(req, res, new URL(probe, url), rid)
|
|
1840
1952
|
if (native && /^\/_brain\/files(?:\/|$)/.test(url.pathname)) return await native.fileStream(req,res,url,rid)
|
|
1841
1953
|
if (native && /^\/(?:api(?:\/|$)|_brain(?:\/|$)|s\/)/.test(url.pathname)) return await native.proxy(req,res,url,rid,send)
|
|
1842
1954
|
const declaredRoute = native && nativeRoutes.find(route => route.rx.test(url.pathname))
|
|
@@ -1867,6 +1979,8 @@ export function createServer(o = {}) {
|
|
|
1867
1979
|
if (!m) continue
|
|
1868
1980
|
// a form page without load is prerendered: GET serves that file (build ETag); only its POST needs the server
|
|
1869
1981
|
if (!post && route.forms && serveStatic(req, res, url)) return
|
|
1982
|
+
// a parameter never hides a real file: `/[code]` must not swallow /favicon.svg, /sitemap.xml or a prerendered /login
|
|
1983
|
+
if (!post && m.groups && (await serveFixed(req, res, url))) return
|
|
1870
1984
|
if (!post && serveTwin(req, res, url)) return
|
|
1871
1985
|
const paged = post ? null : pageParameter(route, url)
|
|
1872
1986
|
if (paged === 404) return notFound(req, res)
|
|
@@ -1874,15 +1988,13 @@ export function createServer(o = {}) {
|
|
|
1874
1988
|
return await (post ? formPost : ssr)(req, res, route, m.groups ? { ...m.groups } : {}, url, rid)
|
|
1875
1989
|
}
|
|
1876
1990
|
if (post) return send(res, 405, 'Method not allowed', { allow: 'GET, HEAD' })
|
|
1877
|
-
if (
|
|
1878
|
-
if (serveStatic(req, res, url)) return
|
|
1879
|
-
// browsers still ask for /favicon.ico: the app's SVG icon answers it
|
|
1880
|
-
if (url.pathname === '/favicon.ico' && file('favicon.svg')) return send(res, 301, '', { location: withBase('/favicon.svg'), 'cache-control': 'public, max-age=86400' })
|
|
1991
|
+
if (await serveFixed(req, res, url)) return
|
|
1881
1992
|
if (isProbe(url.pathname)) return await answerProbe(req, res, url, rid)
|
|
1882
1993
|
notFound(req, res)
|
|
1883
1994
|
} catch (e) {
|
|
1884
1995
|
const busy = e instanceof HttpError && e.code === 'BRIDGE_BUSY'
|
|
1885
1996
|
if (busy) log('warn', 'backend bridge busy', { rid, path: req.$brainPath || String(req.url || '').split('?')[0], fix: e.fix })
|
|
1997
|
+
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 } : {}) })
|
|
1886
1998
|
else log('error', 'unhandled', { rid, path: req.url, ...trace(e) })
|
|
1887
1999
|
try {
|
|
1888
2000
|
if (busy && !res.headersSent) { res.setHeader('retry-after', String(e.retryAfter)); failPage(res, 503, rid, { code: e.code, message: 'Service Unavailable', fix: e.fix }) }
|
|
@@ -1901,6 +2013,8 @@ export function createServer(o = {}) {
|
|
|
1901
2013
|
server.headersTimeout = 15000
|
|
1902
2014
|
// With a native backend the file upload deadline is enforced per request (above); 30 s stays for every other body.
|
|
1903
2015
|
server.requestTimeout = native ? 320000 : 30000
|
|
2016
|
+
// `warm: true` (`node server.mjs`): import the app's modules now; requests that arrive meanwhile share the same imports
|
|
2017
|
+
if (o.warm) server.$brainWarm = warm()
|
|
1904
2018
|
return server
|
|
1905
2019
|
}
|
|
1906
2020
|
|
|
@@ -2084,19 +2198,50 @@ export async function runChain({ guards = [], middleware = [], ctx }) {
|
|
|
2084
2198
|
}
|
|
2085
2199
|
|
|
2086
2200
|
// ---------- shared request logic (server.mjs handlers and the dev server's sidecar call the same code, §11.1) ----------
|
|
2087
|
-
|
|
2201
|
+
// Fixed 60 s window per key (ip + the validated `hash/fn`). Callers count only a function that exists, so the keys are
|
|
2202
|
+
// bounded by clients × real functions; HITS_MAX is the hard cap anyway (the oldest window goes first, like REVOKED) and a
|
|
2203
|
+
// key past HITS_KEY_MAX characters is stored as its digest. The map stays ordered by window start: a new window is
|
|
2204
|
+
// re-inserted at the end, so the expired entries are always the first ones.
|
|
2205
|
+
const HITS = new Map() // key → [windowStart, count]
|
|
2206
|
+
export const HITS_MAX = 50000, HITS_KEY_MAX = 256
|
|
2207
|
+
export const rateLimitKeys = () => HITS.size
|
|
2208
|
+
const WINDOW = 60000
|
|
2209
|
+
const dropExpired = now => {
|
|
2210
|
+
for (const [k, [t]] of HITS) {
|
|
2211
|
+
if (now - t <= WINDOW) break
|
|
2212
|
+
HITS.delete(k)
|
|
2213
|
+
}
|
|
2214
|
+
}
|
|
2088
2215
|
let sweeping = null
|
|
2089
|
-
//
|
|
2216
|
+
// Returns 0, or the seconds to wait (Retry-After).
|
|
2090
2217
|
export function overLimit(key, max = 60) {
|
|
2091
|
-
if (!sweeping)
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
for (const [k, [t]] of HITS) if (now - t > 60000) HITS.delete(k)
|
|
2095
|
-
}, 60000)).unref()
|
|
2218
|
+
if (!sweeping) (sweeping = setInterval(() => dropExpired(Date.now()), WINDOW)).unref()
|
|
2219
|
+
key = String(key)
|
|
2220
|
+
if (key.length > HITS_KEY_MAX) key = createHash('sha256').update(key).digest('base64url')
|
|
2096
2221
|
const now = Date.now()
|
|
2097
2222
|
let h = HITS.get(key)
|
|
2098
|
-
if (!h || now - h[0] >
|
|
2099
|
-
|
|
2223
|
+
if (!h || now - h[0] > WINDOW) {
|
|
2224
|
+
if (h) HITS.delete(key)
|
|
2225
|
+
dropExpired(now)
|
|
2226
|
+
while (HITS.size >= HITS_MAX) HITS.delete(HITS.keys().next().value)
|
|
2227
|
+
HITS.set(key, (h = [now, 0]))
|
|
2228
|
+
}
|
|
2229
|
+
return ++h[1] > max ? Math.ceil((h[0] + WINDOW - now) / 1000) : 0
|
|
2230
|
+
}
|
|
2231
|
+
// A server function's id, `<hash>/<name>`: the RPC path and a form's `_brain` field. Anything else is refused before
|
|
2232
|
+
// it reaches the rate limit (a suffix such as `hash/login/1` would otherwise run `login` under a fresh budget).
|
|
2233
|
+
const FN_ID = /^([\w-]{1,64})\/([A-Za-z_$][\w$]{0,127})$/
|
|
2234
|
+
export const fnId = id => {
|
|
2235
|
+
const m = typeof id === 'string' ? FN_ID.exec(id) : null
|
|
2236
|
+
return m ? { hash: m[1], fn: m[2] } : null
|
|
2237
|
+
}
|
|
2238
|
+
// The only functions RPC and form posts may run: the ones a view declares with `server:`, which the generated server
|
|
2239
|
+
// module lists in `$server`. Its other exports (`load`, `paths`, `sitemap`, `seo`, `head`, `$forms`) run behind the
|
|
2240
|
+
// route's guards or at render time and are never callable by name; a module without the list exposes nothing.
|
|
2241
|
+
export const serverFn = (module, name) => {
|
|
2242
|
+
const list = module && module.$server
|
|
2243
|
+
const f = Array.isArray(list) && typeof name === 'string' && list.includes(name) ? module[name] : undefined
|
|
2244
|
+
return typeof f === 'function' ? f : undefined
|
|
2100
2245
|
}
|
|
2101
2246
|
// t(), fmt and locale() read globalThis.__brainLocale. A render sets it while nothing else runs (renders are serialized);
|
|
2102
2247
|
// load(), guards and form handlers overlap across awaits, so each carries its page's language in the async context.
|
|
@@ -2137,11 +2282,11 @@ export function parseArgs(raw) {
|
|
|
2137
2282
|
// Runs one `server:` function → { status, body, headers?, cause? }. `args`: raw body string or an array. With `key`, the
|
|
2138
2283
|
// rate limit applies here too. `cause` (500/504) is the real error, for the caller's log; the body stays generic.
|
|
2139
2284
|
export async function callRpc({ fn, args, ctx, key, limit = 60, timeout = 10000 }) {
|
|
2285
|
+
if (typeof fn !== 'function') return { status: 404, body: { error: 'Unknown function' } } // never counted: no key per guess
|
|
2140
2286
|
if (key) {
|
|
2141
2287
|
const ra = overLimit(key, limit)
|
|
2142
2288
|
if (ra) return { status: 429, body: { error: 'Too many requests' }, headers: { 'retry-after': String(ra) } }
|
|
2143
2289
|
}
|
|
2144
|
-
if (typeof fn !== 'function') return { status: 404, body: { error: 'Unknown function' } }
|
|
2145
2290
|
let a
|
|
2146
2291
|
try {
|
|
2147
2292
|
a = typeof args === 'string' || args == null ? parseArgs(args || '') : args
|
|
@@ -2172,12 +2317,14 @@ export async function callRpc({ fn, args, ctx, key, limit = 60, timeout = 10000
|
|
|
2172
2317
|
// English text for the server's log: a form or a server function never shows it to a visitor
|
|
2173
2318
|
const NATIVE_FAILED = 'Native resource request failed'
|
|
2174
2319
|
export async function callForm({ module, raw, ctx, rules, key, limit = 60, timeout = 10000, locale }) {
|
|
2175
|
-
const q = new URLSearchParams(raw || ''), id = q.get('_brain') || '', fn = id
|
|
2320
|
+
const q = new URLSearchParams(raw || ''), id = q.get('_brain') || '', fn = fnId(id)?.fn
|
|
2321
|
+
// exactly `<hash>/<name>` naming a `server:` function, checked before the rate limit counts anything
|
|
2322
|
+
const run = fn && serverFn(module, fn)
|
|
2323
|
+
if (!run) return { status: 400, body: { error: 'Unknown form' } }
|
|
2176
2324
|
if (key) {
|
|
2177
2325
|
const ra = overLimit(key, limit)
|
|
2178
2326
|
if (ra) return { status: 429, body: { error: 'Too many requests' }, headers: { 'retry-after': String(ra) } }
|
|
2179
2327
|
}
|
|
2180
|
-
if (!/^[A-Za-z_$][\w$]*$/.test(fn) || typeof (module && module[fn]) !== 'function') return { status: 400, body: { error: 'Unknown form' } }
|
|
2181
2328
|
const schema = module.$forms && module.$forms[fn]
|
|
2182
2329
|
const G = globalThis, had = '__brainLocale' in G, prev = G.__brainLocale
|
|
2183
2330
|
let values, errors, generic
|
|
@@ -2194,7 +2341,7 @@ export async function callForm({ module, raw, ctx, rules, key, limit = 60, timeo
|
|
|
2194
2341
|
let r
|
|
2195
2342
|
try {
|
|
2196
2343
|
ctx.signal ||= newSignal()
|
|
2197
|
-
r = await withTimeout(pageLocale.run(locale, () => Promise.resolve(
|
|
2344
|
+
r = await withTimeout(pageLocale.run(locale, () => Promise.resolve(run(ctx, values))), timeout, ctx)
|
|
2198
2345
|
} catch (e) {
|
|
2199
2346
|
if (e && e.errors && typeof e.errors === 'object') return again(e.errors, '')
|
|
2200
2347
|
if (e instanceof HttpError && e.status >= 400 && e.status < 500) return again({}, e.message === NATIVE_FAILED ? generic : e.message)
|
|
@@ -2358,11 +2505,11 @@ export function prefersMarkdown(accept) {
|
|
|
2358
2505
|
function head(page, seo, path, search = '') {
|
|
2359
2506
|
let h = page
|
|
2360
2507
|
// `<meta seo query="page">`: these parameters make a different page; any other (a dialog, a tracking tag) does not
|
|
2361
|
-
let identity = ''
|
|
2508
|
+
let identity = '', query = ''
|
|
2362
2509
|
h = h.replace(/<meta name="brain-query" content="([^"]*)">/, (_, keys) => {
|
|
2363
2510
|
const given = new URLSearchParams(search), kept = new URLSearchParams()
|
|
2364
2511
|
for (const key of keys.split(/[\s,]+/)) for (const value of key ? given.getAll(key) : []) kept.append(key, value)
|
|
2365
|
-
if (kept.size) identity = esc('?' + kept)
|
|
2512
|
+
if (kept.size) identity = esc((query = '?' + kept))
|
|
2366
2513
|
return ''
|
|
2367
2514
|
})
|
|
2368
2515
|
if (path) {
|
|
@@ -2371,7 +2518,7 @@ function head(page, seo, path, search = '') {
|
|
|
2371
2518
|
h = h.replace(/(<link[^>]*rel=["']canonical["'][^>]*href=")([^"]*)(")/i, (m, a, v, b) => a + real(v) + b)
|
|
2372
2519
|
h = h.replace(/(<meta[^>]*property=["']og:url["'][^>]*content=")([^"]*)(")/i, (m, a, v, b) => a + real(v) + b)
|
|
2373
2520
|
}
|
|
2374
|
-
if (!seo) return h
|
|
2521
|
+
if (!seo) return graph(h, path, query)
|
|
2375
2522
|
const meta = (attr, key, val, escaped) => {
|
|
2376
2523
|
const tag = `<meta ${attr}="${esc(key)}" content="${escaped ? val.replace(/"/g, '"') : esc(val)}">`
|
|
2377
2524
|
const rx = new RegExp(`<meta[^>]*\\b${attr}=["']${key.replace(/[.*+?^${}()|[\]\\:]/g, '\\$&')}["'][^>]*>`, 'i')
|
|
@@ -2382,9 +2529,13 @@ function head(page, seo, path, search = '') {
|
|
|
2382
2529
|
if (v == null || k === '$head' || k === 'schema' || k === 'lastmod') continue
|
|
2383
2530
|
if (k === 'title') h = /<title>[\s\S]*?<\/title>/i.test(h) ? h.replace(/<title>[\s\S]*?<\/title>/i, () => `<title>${esc(v)}</title>`) : h.replace('</head>', () => `<title>${esc(v)}</title></head>`)
|
|
2384
2531
|
else if (k === 'canonical') {
|
|
2385
|
-
|
|
2532
|
+
// a root-relative canonical takes the origin the build wrote into the template (PUBLIC_SITE_URL), as it does on
|
|
2533
|
+
// a prerendered page: a relative canonical is ignored by search engines
|
|
2534
|
+
const built = /^\/(?!\/)/.test(v) && /<link[^>]*rel=["']canonical["'][^>]*href="(https?:\/\/[^/"]+)/i.exec(h)?.[1]
|
|
2535
|
+
const url = built ? built + v : v
|
|
2536
|
+
const tag = `<link rel="canonical" href="${esc(url)}">`
|
|
2386
2537
|
h = /<link[^>]*rel=["']canonical["'][^>]*>/i.test(h) ? h.replace(/<link[^>]*rel=["']canonical["'][^>]*>/i, () => tag) : h.replace('</head>', () => tag + '</head>')
|
|
2387
|
-
if (seo['og:url'] == null) meta('property', 'og:url',
|
|
2538
|
+
if (seo['og:url'] == null) meta('property', 'og:url', url) // og:url follows the canonical unless the view sets its own
|
|
2388
2539
|
} else if (k === 'jsonld') h = h.replace('</head>', () => `<script type="application/ld+json">${jsonInHtml(v)}</script></head>`)
|
|
2389
2540
|
else if (/^(og|twitter|article):/.test(k)) meta('property', k, v)
|
|
2390
2541
|
else if (typeof v === 'string') meta('name', k, v)
|
|
@@ -2421,7 +2572,26 @@ function head(page, seo, path, search = '') {
|
|
|
2421
2572
|
if (/<link[^>]*rel=["']canonical["']/i.test(seo.$head)) h = h.replace(/<link[^>]*rel=["']canonical["'][^>]*>/gi, '')
|
|
2422
2573
|
h = h.replace('</head>', () => seo.$head + '</head>')
|
|
2423
2574
|
}
|
|
2424
|
-
return h
|
|
2575
|
+
return graph(h, path, query, seo)
|
|
2576
|
+
}
|
|
2577
|
+
// The WebSite + WebPage graph the build wrote into the template (brain-seo ServerHead::apply): its pattern URLs become
|
|
2578
|
+
// this request's, like the canonical, and the WebPage takes this request's title and description.
|
|
2579
|
+
function graph(h, path, query, seo) {
|
|
2580
|
+
return h.replace(/(<script type="application\/ld\+json">)(\{"@context":"https:\/\/schema\.org","@graph"[\s\S]*?)(<\/script>)/, (m, a, body, b) => {
|
|
2581
|
+
let g
|
|
2582
|
+
try { g = JSON.parse(body) } catch { return m }
|
|
2583
|
+
const url = v => (typeof v === 'string' && path ? v.replace(/^(https?:\/\/[^/#]+)(\/[^#]*)(#.*)?$/, (u, o, p, hash = '') => (/\/[:*]/.test(p) ? o + path + query + hash : u)) : v)
|
|
2584
|
+
const title = /<title>([\s\S]*?)<\/title>/i.exec(h)?.[1]
|
|
2585
|
+
for (const node of g['@graph'] ?? []) {
|
|
2586
|
+
for (const k of ['@id', 'url']) if (k in node) node[k] = url(node[k])
|
|
2587
|
+
if (node.isPartOf?.['@id']) node.isPartOf['@id'] = url(node.isPartOf['@id'])
|
|
2588
|
+
if ([node['@type']].flat().includes('WebPage')) {
|
|
2589
|
+
if (title && node.name == null) node.name = title.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, '&')
|
|
2590
|
+
if (seo?.description != null && node.description == null) node.description = String(seo.description)
|
|
2591
|
+
}
|
|
2592
|
+
}
|
|
2593
|
+
return a + jsonInHtml(g) + b
|
|
2594
|
+
})
|
|
2425
2595
|
}
|
|
2426
2596
|
// `/_b/<hash>.js` imports inside the chunks resolve to files in dist/ (same hook as the build's prerender)
|
|
2427
2597
|
let hooked = false
|
|
@@ -2442,6 +2612,8 @@ function hook(base, app = '/') {
|
|
|
2442
2612
|
}
|
|
2443
2613
|
|
|
2444
2614
|
const LISTEN_WAIT_MS = 10000
|
|
2615
|
+
// the supervisor gives an instance 30 s to print brain:ready: warming may hold it this long at most
|
|
2616
|
+
const WARM_WAIT_MS = 15000
|
|
2445
2617
|
// `node server.mjs` (directly or under `brain start`); paths compared after symlinks (/var → /private/var on macOS)
|
|
2446
2618
|
function real(p) {
|
|
2447
2619
|
try {
|
|
@@ -2454,12 +2626,17 @@ if (process.argv[1] && real(process.argv[1]) === real(fileURLToPath(import.meta.
|
|
|
2454
2626
|
process.env.NODE_ENV = 'production'
|
|
2455
2627
|
// A closed log pipe is the operator's matter: requests do not depend on it.
|
|
2456
2628
|
for (const stream of [process.stdout, process.stderr]) stream.on('error', error => { if (error.code !== 'EPIPE') throw error })
|
|
2457
|
-
const s = createServer({ dist: fileURLToPath(new URL('.', import.meta.url)) })
|
|
2629
|
+
const s = createServer({ dist: fileURLToPath(new URL('.', import.meta.url)), warm: true })
|
|
2458
2630
|
const fd = ENV.BRAIN_LISTEN_FD
|
|
2459
2631
|
const where = fd ? { fd: Number(fd) } : { port: +(ENV.PORT || 3000), host: ENV.HOST || '127.0.0.1' }
|
|
2460
|
-
s.once('listening', () => {
|
|
2632
|
+
s.once('listening', async () => {
|
|
2633
|
+
// ready once the app's modules are imported (see `warm`), so a reload only switches traffic to a warm instance;
|
|
2634
|
+
// a slow machine gets WARM_WAIT_MS at most, the rest keeps importing while requests are served
|
|
2635
|
+
let timer
|
|
2636
|
+
const warmed = await Promise.race([s.$brainWarm, new Promise(r => (timer = setTimeout(r, WARM_WAIT_MS)))])
|
|
2637
|
+
clearTimeout(timer)
|
|
2461
2638
|
process.stdout.write(`brain:ready ${process.pid}\n`) // the supervisor waits for this line (§34)
|
|
2462
|
-
console.log(json({ t: new Date().toISOString(), level: 'info', msg: 'listening', ...(fd ? { fd: Number(fd) } : where) }))
|
|
2639
|
+
console.log(json({ t: new Date().toISOString(), level: 'info', msg: 'listening', ...(fd ? { fd: Number(fd) } : where), ...(warmed ? { warmModules: warmed.modules, warmFailed: warmed.failed } : { warm: 'continuing' }) }))
|
|
2463
2640
|
})
|
|
2464
2641
|
// A restart may find the previous instance still draining: wait for it, briefly. Two live servers never share a port.
|
|
2465
2642
|
const waitUntil = Date.now() + LISTEN_WAIT_MS
|