@christianirack/brainllm-runtime 0.8.9 → 0.8.11

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 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[0] === '/' && r.redirect[1] !== '/') go(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
- const io = new IntersectionObserver(es => es.some(e => e.isIntersecting) && go(), { rootMargin: '200px' })
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 || typeof v === 'string'
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 typeof e?.redirect === 'string' ? e.redirect : null
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.value = typeof f == 'function' ? untrack(f) : f
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
- ? ((el.tagName == 'SELECT' && k == 'value') ? keepSelected(el, f) : 0, v => {
294
- if (el[k] !== v) el[k] = v
295
- })
296
- : v => (v == null || (v === false && k[4] != '-') ? el.removeAttribute(k) : el.setAttribute(k, v === true && k[4] != '-' ? '' : /^(href|src|action|formaction|poster)$/.test(k) && typeof v == 'string' ? inBase(v) : v))
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
- /^\s*(javascript|vbscript|data(?!:image\/)):/i.test(v)
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.9",
3
+ "version": "0.8.11",
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
@@ -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
- let response
236
- try { response = await fetch(new URL(path, target), { method, redirect: 'error', signal: AbortSignal.timeout(timeout), headers: { 'x-api-key': key }, body }) } catch { throw new HttpError(503, 'Native backend is unavailable') }
237
- if (response.ok) return response
238
- await response.body?.cancel()
239
- throw new HttpError(response.status >= 400 && response.status < 500 ? response.status : 503, 'Native file request failed')
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
- export function nativeAuthBridge(raw, refused, serviceKey, report, ownSession) {
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)
@@ -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
- throw new HttpError(status >= 400 && status < 500 ? status : 503, NATIVE_FAILED, { code, fix, temporary: status === 429 || status === 503, ...(wait > 0 ? { retryAfter: wait } : {}) })
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
- // A restarting backend refuses connections for a few seconds: a read waits for it instead of failing the page.
614
- for (let wait = 250; ; wait *= 2) {
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
- if (method !== 'GET' || wait > 2000 || stopped || response.destroyed || !['ECONNREFUSED', 'ECONNRESET'].includes(e?.transport)) throw e
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, wait))
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
- safe.code = kind === 'Error' ? 'NODE_INTERNAL_ERROR' : 'NODE_' + kind.replace(/Error$/, '').toUpperCase() + '_ERROR'
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,6 +982,9 @@ 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) }
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
952
988
  if (e instanceof HttpError && (OWN_CAUSES.has(e.message) || e.message.startsWith('Native authentication is unavailable:'))) out.cause = e.message
953
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'
954
990
  if (e && CODE_SHAPE.test(e.transport || '')) out.transport = e.transport
@@ -1136,6 +1172,34 @@ export function createServer(o = {}) {
1136
1172
  if (!mods.has(rel)) mods.set(rel, import(new URL(rel.replace(/^\//, ''), distURL).href))
1137
1173
  return mods.get(rel)
1138
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
+ }
1139
1203
 
1140
1204
  const context = (req, res, url, extra) => {
1141
1205
  const cookies = parseCookies(req.headers.cookie)
@@ -1162,15 +1226,19 @@ export function createServer(o = {}) {
1162
1226
  let ssrBytes = 0
1163
1227
  async function cached(req, res, route, params, url, rid) {
1164
1228
  const n = +route.cache, ms = n * 1000, key = (route.locale || '') + url.pathname + cacheSearch(url)
1165
- const fill = () => {
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 = {}) => {
1166
1232
  let failed = false
1233
+ const set = {}
1167
1234
  return new Promise(ok => {
1168
1235
  const cap = { req, $capture: (out, h, st) => {
1169
1236
  const headers = Object.fromEntries(Object.entries(h).map(([k, v]) => [k.toLowerCase(), v]))
1170
1237
  const privateResponse = headers['set-cookie'] || headers['vary'] || /private|no-store|no-cache/i.test(headers['cache-control'] || '')
1171
1238
  if (st >= 500) failed = true
1239
+ if (st !== 200) kept.answer = { page: out, h, st, set }
1172
1240
  ok(st === 200 ? { out, h, cacheable: !privateResponse, etag: 'W/"' + tag(out) + '"', t: Date.now(), bytes: Buffer.byteLength(out) } : null)
1173
- }, writeHead(st) { if (st >= 500) failed = true }, end: () => ok(null), setHeader() {}, getHeader: name => (name === 'x-request-id' ? rid : undefined), once() {}, removeListener() {} }
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() {} }
1174
1242
  ssr(req, cap, route, params, url, rid, null, 200, null, true).catch(() => ok(null))
1175
1243
  }).then(c => {
1176
1244
  // A failed revalidation keeps the copy until 2N, which is what `stale-if-error` promises downstream.
@@ -1208,14 +1276,23 @@ export function createServer(o = {}) {
1208
1276
  if (c?.cacheable) return serve(c)
1209
1277
  return ssr(req, res, route, params, url, rid, null, 200, null, true)
1210
1278
  }
1211
- const filling = fill().finally(() => FILLING.delete(key))
1279
+ const kept = {}
1280
+ const filling = fill(kept).finally(() => FILLING.delete(key))
1212
1281
  FILLING.set(key, filling)
1213
1282
  c = await filling
1214
1283
  if (c) return serve(c)
1215
1284
  // the first render already asked the backend and logged why; another would only spend more of a limit it just hit
1216
1285
  if (req.$brainFailedReads?.temporary) return comeBackPage(req, res, route, params, url, rid)
1217
1286
  delete req.$brainFailedReads
1218
- return ssr(req, res, route, params, url, rid, null, 200, null, true) // a redirect or an error: as usual
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
1219
1296
  }
1220
1297
  const comeBackPage = (req, res, route, params, url, rid) => {
1221
1298
  res.setHeader('retry-after', String(Math.min(Math.ceil(req.$brainFailedReads.retryAfter) || 5, 3600)))
@@ -1311,7 +1388,8 @@ export function createServer(o = {}) {
1311
1388
  .replace('<!--brain:props-->', () => jsonInHtml(props))
1312
1389
  // a page behind a guard passes it only for its visitor: it stays out of the index even when it renders (§33)
1313
1390
  if (route.guards && route.guards.length) res.setHeader('x-robots-tag', NOINDEX)
1314
- let out = head(page, seo, url.pathname, url.search)
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)
1315
1393
  // route params for route() in the browser (the client has no route table)
1316
1394
  if (params && Object.keys(params).length) out = out.replace('</head>', () => `<meta name="brain-params" content="${esc(JSON.stringify(params))}"></head>`)
1317
1395
  if (forms) out = out.replace('</body>', () => `<script type="application/json" id="brain-forms">${jsonInHtml(forms)}</script></body>`)
@@ -1450,6 +1528,18 @@ export function createServer(o = {}) {
1450
1528
  const root = doc.createElement('div')
1451
1529
  const core = await load(mf.core)
1452
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
+ }
1453
1543
  const V = (await load(route.view)).default
1454
1544
  const L = []
1455
1545
  for (const l of route.layouts || []) L.push((await load(l)).default)
@@ -1500,8 +1590,16 @@ export function createServer(o = {}) {
1500
1590
  if (req.method !== 'POST') return fail(405, 'Use POST', { $h: { allow: 'POST' } })
1501
1591
  if (req.headers['x-brain-rpc'] !== '1') return fail(403, 'Missing X-Brain-RPC header')
1502
1592
  if (!sameOrigin(req)) return originRefused('B0741', req, rid, 'rpc'), fail(403, 'Cross-origin request', { brainCode: 'B0741' })
1503
- const modPath = mf.rpc[hash]
1504
- if (!modPath || !/^[A-Za-z_$][\w$]*$/.test(fn)) return fail(404, 'Unknown function')
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
1505
1603
  const ra = overLimit(ipOf(req) + ' ' + hash + '/' + fn, LIMIT) // before reading the body
1506
1604
  if (ra) return fail(429, 'Too many requests', { $h: { 'retry-after': String(ra) } })
1507
1605
  let raw
@@ -1513,13 +1611,6 @@ export function createServer(o = {}) {
1513
1611
  }
1514
1612
  if (native) await native.prepare(req, res, rid)
1515
1613
  const ctx = context(req, res, url, { params: {} })
1516
- let f
1517
- try {
1518
- f = (await load(modPath))[fn]
1519
- } catch (e) {
1520
- log('error', 'rpc module failed to load', { rid, fn: hash + '/' + fn, ...trace(e) })
1521
- return fail(500, 'Internal error', { rid })
1522
- }
1523
1614
  const r = await callRpc({ fn: f, args: raw, ctx, timeout: TIMEOUT })
1524
1615
  if (r.cause) log('error', r.status === 504 ? 'rpc timeout' : 'rpc failed', { rid, fn: hash + '/' + fn, ms: TIMEOUT, ...trace(r.cause) })
1525
1616
  const b = r.status >= 500 ? { ...r.body, rid } : r.body
@@ -1552,8 +1643,8 @@ export function createServer(o = {}) {
1552
1643
  if (e.aborted) return
1553
1644
  return plain(413, tooBig(rid, url.pathname))
1554
1645
  }
1555
- const id = new URLSearchParams(raw).get('_brain') || '', modPath = mf.rpc[id.split('/')[0]]
1556
- 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
1557
1648
  if (native) await native.prepare(req, res, rid)
1558
1649
  const ctx = context(req, res, url, { params })
1559
1650
  let module, rules
@@ -1565,7 +1656,7 @@ export function createServer(o = {}) {
1565
1656
  log('error', 'form module failed to load', { rid, fn: id, ...trace(e) })
1566
1657
  return failPage(res, 500, rid)
1567
1658
  }
1568
- const r = await callForm({ module, raw, ctx, rules, key: ipOf(req) + ' ' + id, limit: LIMIT, timeout: TIMEOUT, locale: route.locale })
1659
+ const r = await callForm({ module, raw, ctx, rules, key: ipOf(req) + ' ' + fid.hash + '/' + fid.fn, limit: LIMIT, timeout: TIMEOUT, locale: route.locale })
1569
1660
  if (r.cause) log('error', 'form failed', { rid, fn: id, ...trace(r.cause) })
1570
1661
  if (r.status === 303) return send(res, 303, '', { location: withBase(r.location), 'cache-control': 'no-store', ...ctx.session.headers() })
1571
1662
  if (r.status === 422) return ssr(req, res, route, params, url, rid, r.state, 422)
@@ -1677,6 +1768,9 @@ export function createServer(o = {}) {
1677
1768
  }
1678
1769
 
1679
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']
1680
1774
  // §33 content negotiation: `Accept` preferring text/markdown gets the page's Markdown twin (`/pricing` → pricing.md,
1681
1775
  // `/` → index.md) when the build published one (`seo.markdown`, default true; never for noindex pages).
1682
1776
  function publicStaticPath(pathname) {
@@ -1684,8 +1778,8 @@ export function createServer(o = {}) {
1684
1778
  try { path = decodeURIComponent(pathname) } catch { return null }
1685
1779
  if (/[\\\x00]/.test(path)) return null
1686
1780
  const clean = normalize(path).replaceAll('\\', '/')
1687
- const rel = clean.replace(/^\/+/, '')
1688
- if (rel.startsWith('_server') || rel.startsWith('_brain') || rel.startsWith('server.mjs') || rel === '_headers' || rel === '_redirects' || clean.split('/').some(part => part.startsWith('.') && part !== '.well-known')) return null
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
1689
1783
  return path
1690
1784
  }
1691
1785
  function serveTwin(req, res, url) {
@@ -1759,6 +1853,9 @@ export function createServer(o = {}) {
1759
1853
  // /health is reserved for the backend (B0735), so an app cannot declare it and deploy gates ask this port for it. An app
1760
1854
  // page at that path still wins: this only runs where the answer would have been a 404. Nothing here names the backend.
1761
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
1762
1859
  const answerProbe = async (req, res, url, rid) => {
1763
1860
  const headers = { 'content-type': MIME['.json'], 'cache-control': 'no-store' }
1764
1861
  if (req.method !== 'GET' && req.method !== 'HEAD') return notFound(req, res)
@@ -1843,10 +1940,15 @@ export function createServer(o = {}) {
1843
1940
  let url = full
1844
1941
  if (BASE !== '/') {
1845
1942
  if (full.pathname === BASE.slice(0, -1)) return send(res, 301, '', { location: BASE + full.search })
1846
- if (!full.pathname.startsWith(BASE)) return isProbe(full.pathname) ? await answerProbe(req, res, full, rid) : notFound(req, res)
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
+ }
1847
1947
  url = new URL('/' + full.pathname.slice(BASE.length) + full.search, full) // the app path
1848
1948
  }
1849
1949
  req.$brainPath = url.pathname
1950
+ const probe = brainProbe(url.pathname)
1951
+ if (probe) return await answerProbe(req, res, new URL(probe, url), rid)
1850
1952
  if (native && /^\/_brain\/files(?:\/|$)/.test(url.pathname)) return await native.fileStream(req,res,url,rid)
1851
1953
  if (native && /^\/(?:api(?:\/|$)|_brain(?:\/|$)|s\/)/.test(url.pathname)) return await native.proxy(req,res,url,rid,send)
1852
1954
  const declaredRoute = native && nativeRoutes.find(route => route.rx.test(url.pathname))
@@ -1911,6 +2013,8 @@ export function createServer(o = {}) {
1911
2013
  server.headersTimeout = 15000
1912
2014
  // With a native backend the file upload deadline is enforced per request (above); 30 s stays for every other body.
1913
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()
1914
2018
  return server
1915
2019
  }
1916
2020
 
@@ -2094,19 +2198,50 @@ export async function runChain({ guards = [], middleware = [], ctx }) {
2094
2198
  }
2095
2199
 
2096
2200
  // ---------- shared request logic (server.mjs handlers and the dev server's sidecar call the same code, §11.1) ----------
2097
- const HITS = new Map() // `${key}` → [windowStart, count]
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
+ }
2098
2215
  let sweeping = null
2099
- // Fixed 60 s window per key (ip + function). Returns 0, or the seconds to wait (Retry-After).
2216
+ // Returns 0, or the seconds to wait (Retry-After).
2100
2217
  export function overLimit(key, max = 60) {
2101
- if (!sweeping)
2102
- (sweeping = setInterval(() => {
2103
- const now = Date.now()
2104
- for (const [k, [t]] of HITS) if (now - t > 60000) HITS.delete(k)
2105
- }, 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')
2106
2221
  const now = Date.now()
2107
2222
  let h = HITS.get(key)
2108
- if (!h || now - h[0] > 60000) HITS.set(key, (h = [now, 0]))
2109
- return ++h[1] > max ? Math.ceil((h[0] + 60000 - now) / 1000) : 0
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
2110
2245
  }
2111
2246
  // t(), fmt and locale() read globalThis.__brainLocale. A render sets it while nothing else runs (renders are serialized);
2112
2247
  // load(), guards and form handlers overlap across awaits, so each carries its page's language in the async context.
@@ -2147,11 +2282,11 @@ export function parseArgs(raw) {
2147
2282
  // Runs one `server:` function → { status, body, headers?, cause? }. `args`: raw body string or an array. With `key`, the
2148
2283
  // rate limit applies here too. `cause` (500/504) is the real error, for the caller's log; the body stays generic.
2149
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
2150
2286
  if (key) {
2151
2287
  const ra = overLimit(key, limit)
2152
2288
  if (ra) return { status: 429, body: { error: 'Too many requests' }, headers: { 'retry-after': String(ra) } }
2153
2289
  }
2154
- if (typeof fn !== 'function') return { status: 404, body: { error: 'Unknown function' } }
2155
2290
  let a
2156
2291
  try {
2157
2292
  a = typeof args === 'string' || args == null ? parseArgs(args || '') : args
@@ -2182,12 +2317,14 @@ export async function callRpc({ fn, args, ctx, key, limit = 60, timeout = 10000
2182
2317
  // English text for the server's log: a form or a server function never shows it to a visitor
2183
2318
  const NATIVE_FAILED = 'Native resource request failed'
2184
2319
  export async function callForm({ module, raw, ctx, rules, key, limit = 60, timeout = 10000, locale }) {
2185
- const q = new URLSearchParams(raw || ''), id = q.get('_brain') || '', fn = id.split('/')[1] || ''
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' } }
2186
2324
  if (key) {
2187
2325
  const ra = overLimit(key, limit)
2188
2326
  if (ra) return { status: 429, body: { error: 'Too many requests' }, headers: { 'retry-after': String(ra) } }
2189
2327
  }
2190
- if (!/^[A-Za-z_$][\w$]*$/.test(fn) || typeof (module && module[fn]) !== 'function') return { status: 400, body: { error: 'Unknown form' } }
2191
2328
  const schema = module.$forms && module.$forms[fn]
2192
2329
  const G = globalThis, had = '__brainLocale' in G, prev = G.__brainLocale
2193
2330
  let values, errors, generic
@@ -2204,7 +2341,7 @@ export async function callForm({ module, raw, ctx, rules, key, limit = 60, timeo
2204
2341
  let r
2205
2342
  try {
2206
2343
  ctx.signal ||= newSignal()
2207
- r = await withTimeout(pageLocale.run(locale, () => Promise.resolve(module[fn](ctx, values))), timeout, ctx)
2344
+ r = await withTimeout(pageLocale.run(locale, () => Promise.resolve(run(ctx, values))), timeout, ctx)
2208
2345
  } catch (e) {
2209
2346
  if (e && e.errors && typeof e.errors === 'object') return again(e.errors, '')
2210
2347
  if (e instanceof HttpError && e.status >= 400 && e.status < 500) return again({}, e.message === NATIVE_FAILED ? generic : e.message)
@@ -2368,11 +2505,11 @@ export function prefersMarkdown(accept) {
2368
2505
  function head(page, seo, path, search = '') {
2369
2506
  let h = page
2370
2507
  // `<meta seo query="page">`: these parameters make a different page; any other (a dialog, a tracking tag) does not
2371
- let identity = ''
2508
+ let identity = '', query = ''
2372
2509
  h = h.replace(/<meta name="brain-query" content="([^"]*)">/, (_, keys) => {
2373
2510
  const given = new URLSearchParams(search), kept = new URLSearchParams()
2374
2511
  for (const key of keys.split(/[\s,]+/)) for (const value of key ? given.getAll(key) : []) kept.append(key, value)
2375
- if (kept.size) identity = esc('?' + kept)
2512
+ if (kept.size) identity = esc((query = '?' + kept))
2376
2513
  return ''
2377
2514
  })
2378
2515
  if (path) {
@@ -2381,7 +2518,7 @@ function head(page, seo, path, search = '') {
2381
2518
  h = h.replace(/(<link[^>]*rel=["']canonical["'][^>]*href=")([^"]*)(")/i, (m, a, v, b) => a + real(v) + b)
2382
2519
  h = h.replace(/(<meta[^>]*property=["']og:url["'][^>]*content=")([^"]*)(")/i, (m, a, v, b) => a + real(v) + b)
2383
2520
  }
2384
- if (!seo) return h
2521
+ if (!seo) return graph(h, path, query)
2385
2522
  const meta = (attr, key, val, escaped) => {
2386
2523
  const tag = `<meta ${attr}="${esc(key)}" content="${escaped ? val.replace(/"/g, '&quot;') : esc(val)}">`
2387
2524
  const rx = new RegExp(`<meta[^>]*\\b${attr}=["']${key.replace(/[.*+?^${}()|[\]\\:]/g, '\\$&')}["'][^>]*>`, 'i')
@@ -2392,9 +2529,13 @@ function head(page, seo, path, search = '') {
2392
2529
  if (v == null || k === '$head' || k === 'schema' || k === 'lastmod') continue
2393
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>`)
2394
2531
  else if (k === 'canonical') {
2395
- const tag = `<link rel="canonical" href="${esc(v)}">`
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)}">`
2396
2537
  h = /<link[^>]*rel=["']canonical["'][^>]*>/i.test(h) ? h.replace(/<link[^>]*rel=["']canonical["'][^>]*>/i, () => tag) : h.replace('</head>', () => tag + '</head>')
2397
- if (seo['og:url'] == null) meta('property', 'og:url', v) // og:url follows the canonical unless the view sets its own
2538
+ if (seo['og:url'] == null) meta('property', 'og:url', url) // og:url follows the canonical unless the view sets its own
2398
2539
  } else if (k === 'jsonld') h = h.replace('</head>', () => `<script type="application/ld+json">${jsonInHtml(v)}</script></head>`)
2399
2540
  else if (/^(og|twitter|article):/.test(k)) meta('property', k, v)
2400
2541
  else if (typeof v === 'string') meta('name', k, v)
@@ -2431,7 +2572,26 @@ function head(page, seo, path, search = '') {
2431
2572
  if (/<link[^>]*rel=["']canonical["']/i.test(seo.$head)) h = h.replace(/<link[^>]*rel=["']canonical["'][^>]*>/gi, '')
2432
2573
  h = h.replace('</head>', () => seo.$head + '</head>')
2433
2574
  }
2434
- 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(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&amp;/g, '&')
2590
+ if (seo?.description != null && node.description == null) node.description = String(seo.description)
2591
+ }
2592
+ }
2593
+ return a + jsonInHtml(g) + b
2594
+ })
2435
2595
  }
2436
2596
  // `/_b/<hash>.js` imports inside the chunks resolve to files in dist/ (same hook as the build's prerender)
2437
2597
  let hooked = false
@@ -2452,6 +2612,8 @@ function hook(base, app = '/') {
2452
2612
  }
2453
2613
 
2454
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
2455
2617
  // `node server.mjs` (directly or under `brain start`); paths compared after symlinks (/var → /private/var on macOS)
2456
2618
  function real(p) {
2457
2619
  try {
@@ -2464,12 +2626,17 @@ if (process.argv[1] && real(process.argv[1]) === real(fileURLToPath(import.meta.
2464
2626
  process.env.NODE_ENV = 'production'
2465
2627
  // A closed log pipe is the operator's matter: requests do not depend on it.
2466
2628
  for (const stream of [process.stdout, process.stderr]) stream.on('error', error => { if (error.code !== 'EPIPE') throw error })
2467
- const s = createServer({ dist: fileURLToPath(new URL('.', import.meta.url)) })
2629
+ const s = createServer({ dist: fileURLToPath(new URL('.', import.meta.url)), warm: true })
2468
2630
  const fd = ENV.BRAIN_LISTEN_FD
2469
2631
  const where = fd ? { fd: Number(fd) } : { port: +(ENV.PORT || 3000), host: ENV.HOST || '127.0.0.1' }
2470
- 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)
2471
2638
  process.stdout.write(`brain:ready ${process.pid}\n`) // the supervisor waits for this line (§34)
2472
- 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' }) }))
2473
2640
  })
2474
2641
  // A restart may find the previous instance still draining: wait for it, briefly. Two live servers never share a port.
2475
2642
  const waitUntil = Date.now() + LISTEN_WAIT_MS