@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 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.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 authentication is temporarily unavailable', '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
+ 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
- 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)
@@ -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, 'Native authentication is temporarily unavailable')
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
- 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,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
- if (e instanceof HttpError && OWN_CAUSES.has(e.message)) out.cause = e.message
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
- 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 = {}) => {
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: () => 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() {} }
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 filling = fill().finally(() => FILLING.delete(key))
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
- 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
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
- 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)
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 || !/^[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
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[id.split('/')[0]]
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) + ' ' + 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 })
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 (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
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('&amp;', '&')
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)) 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
+ }
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 (DYN.length && /^\/sitemap(-static|-dynamic(-\d+)?)?\.xml$/.test(url.pathname) && (await serveSitemap(req, res, url))) return
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
- 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
+ }
2088
2215
  let sweeping = null
2089
- // 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).
2090
2217
  export function overLimit(key, max = 60) {
2091
- if (!sweeping)
2092
- (sweeping = setInterval(() => {
2093
- const now = Date.now()
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] > 60000) HITS.set(key, (h = [now, 0]))
2099
- 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
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.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' } }
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(module[fn](ctx, values))), timeout, ctx)
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, '&quot;') : 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
- 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)}">`
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', 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
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(/&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
+ })
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