@christianirack/brainllm-runtime 0.8.2 → 0.8.5

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.
@@ -70,13 +70,17 @@ export function start(config = {}, env = globalThis) {
70
70
  )
71
71
 
72
72
  // ── identity: anonymous first-party visitor (localStorage) and a session that ends after 30 idle minutes
73
+ // A session belongs to one visitor (the server refuses it from another), so without storage the visitor id lives in
74
+ // memory for the page, and a stored session whose visitor is gone or different starts over.
73
75
  let session = null
76
+ let visitor = null
74
77
  const ids = () => {
75
78
  const t = Date.now()
76
79
  let vid = get(VISITOR)
77
- if (typeof vid !== 'string') put(VISITOR, (vid = uuid()))
80
+ if (typeof vid === 'string') visitor = vid
81
+ else put(VISITOR, (vid = visitor = visitor || uuid()))
78
82
  if (!session) session = get(SESSION)
79
- if (!session || typeof session.id !== 'string' || t - session.last > IDLE_MS) session = { id: uuid(), last: t, pv: 0, ctx: 0 }
83
+ if (!session || typeof session.id !== 'string' || session.vid !== vid || t - session.last > IDLE_MS) session = { id: uuid(), vid, last: t, pv: 0, ctx: 0 }
80
84
  session.last = t
81
85
  put(SESSION, session)
82
86
  return vid
package/brain-form.js CHANGED
@@ -112,7 +112,7 @@ function remoteCheck(o, x, checking) {
112
112
  }
113
113
  function createForm(schema, o, readRule, validateRule, remote) {
114
114
  const id = 'f' + ++seq, owner = I.owner() // aria effects are created later (in handlers) but live with the component
115
- const busy = sig(false), error = sig(''), shown = sig(0) // shown: bumped when touched set changes
115
+ const busy = sig(false), error = sig(''), status = sig(0), shown = sig(0) // shown: bumped when touched set changes
116
116
  const checking = sig(0), saved = sig('') // async checks running · JSON of the last saved values
117
117
  const F = [] // flat fields: { path, r, s (value sig), e (error sig), el, touched }
118
118
  const byPath = {}
@@ -230,6 +230,9 @@ function createForm(schema, o, readRule, validateRule, remote) {
230
230
  get error() {
231
231
  return error.v
232
232
  },
233
+ get status() {
234
+ return status.v
235
+ },
233
236
  get dirty() {
234
237
  return now() !== saved.v
235
238
  },
@@ -277,6 +280,7 @@ function createForm(schema, o, readRule, validateRule, remote) {
277
280
  if (!first && x.e.v) first = x
278
281
  }
279
282
  error.v = ''
283
+ status.v = 0
280
284
  shown.v++
281
285
  })
282
286
  if (o.validate && !first) {
@@ -305,6 +309,7 @@ function createForm(schema, o, readRule, validateRule, remote) {
305
309
  }
306
310
  return r
307
311
  } catch (x) {
312
+ status.v = x?.status || 0
308
313
  if (x?.errors) mark(x.errors)
309
314
  // a server failure reads the same as without JS: 4xx and the author's own errors keep their message; 5xx and a
310
315
  // TypeError (the network dropped, or a bug) get the generic one; the runtime's own English sentence (`plain`) gives
@@ -318,7 +323,8 @@ function createForm(schema, o, readRule, validateRule, remote) {
318
323
  },
319
324
  }
320
325
  DIRTY.add(f)
321
- const watch = () => fx(() => void (f.dirty && guardUnload()))
326
+ // outside the fx: the listener outlives the form on purpose, one per page, and is not this effect's to clean up
327
+ const watch = () => fx(() => void (f.dirty && queueMicrotask(guardUnload)))
322
328
  owner ? I.root(watch, owner) : watch()
323
329
  if (owner) I.root(() => fx(() => () => DIRTY.delete(f)), owner)
324
330
  return f
package/brain-inspect.js CHANGED
@@ -214,13 +214,14 @@ function leak(r) {
214
214
  leaks.push(r)
215
215
  push(r)
216
216
  }
217
- function b602(n, what, line) {
217
+ function b602(n, what, line, foreign) {
218
218
  if (!n || flagged.has(n) || n.s >= 3) return
219
219
  flagged.add(n)
220
220
  const i = instOf(n), name = i ? nameOf(i) : '?'
221
221
  const fx = `fx in ${name} (${what})${line ? ' ' + line : ''}`
222
222
  if (i) (b602ByInst.get(i.r) || b602ByInst.set(i.r, []).get(i.r)).push(fx)
223
- leak({ code: 'B0602', h: i ? i.h : null, name, file: i && i.C.f, line, msg: `global ${what} inside an fx without cleanup`, fix: 'return a cleanup function from the fx: `fx(() => { …; return () => remove() })`', fx: [fx] })
223
+ // a listener another module registered while this fx ran is not the component's: name where it really comes from
224
+ leak({ code: 'B0602', h: i ? i.h : null, name, file: foreign && line ? line.replace(/:\d+$/, '') : i && i.C.f, line, msg: `global ${what} inside an fx${foreign && line ? ` of ${name}, registered by ${line},` : ''} without cleanup`, fix: 'return a cleanup function from the fx: `fx(() => { …; return () => remove() })`', fx: [fx] })
224
225
  }
225
226
  function patchGlobalListeners() {
226
227
  for (const t of [G.window, G.document].filter(Boolean)) {
@@ -229,8 +230,8 @@ function patchGlobalListeners() {
229
230
  t.addEventListener = function (type, ...a) {
230
231
  const c = ctx()
231
232
  if (c && c.o === 'fx') {
232
- const line = site().line
233
- O.st.call(G, () => !c.n.k && b602(c.n, `addEventListener('${type}')`, line), 0)
233
+ const { line, foreign } = site()
234
+ O.st.call(G, () => !c.n.k && b602(c.n, `addEventListener('${type}')`, line, foreign), 0)
234
235
  }
235
236
  return add.call(this, type, ...a)
236
237
  }
@@ -264,6 +265,9 @@ const fin = G.FinalizationRegistry ? new FinalizationRegistry(w => (w.dead = 1))
264
265
  // Where a pending request went, without origin or query: evidence for a fix, never a value.
265
266
  const pendingUrls = () => [...inflight.values()].slice(0, 3).map(r => `${r.m} ${r.url.replace(/^[a-z]+:\/\/[^/]+/i, '').split(/[?#]/)[0]}`)
266
267
  const SETTLE_MS = 60000
268
+ // While a suite holds the verdict, an instance only ages from its release: a promise or a script load that begins after
269
+ // the unmount is invisible to the network watch and keeps the instance alive for a bounded time.
270
+ let held_verdict = false
267
271
  function watch(d) {
268
272
  if (!fin) return
269
273
  // its first DOM node: an app that keeps a detached node (a module array, a cache) is the leak to catch. The browser
@@ -279,11 +283,13 @@ function checkLeaks() {
279
283
  for (let j = watched.length - 1; j >= 0; j--) {
280
284
  const w = watched[j]
281
285
  if (w.dead) watched.splice(j, 1)
286
+ else if (held_verdict) w.at = gcs
282
287
  // A request from before the unmount may be what holds it: judge after it settles (bounded, for a long poll).
283
288
  else if (w.net.some(id => inflight.has(id)) && now() - w.since < SETTLE_MS) w.at = gcs
284
289
  else if (gcs - w.at >= 2) {
285
290
  watched.splice(j, 1)
286
- const waited = w.urls.length ? `${w.urls.length > 2 ? 'several requests' : w.urls.join(', ')} ${w.urls.length > 1 ? 'were' : 'was'} in flight when it unmounted; ` : ''
291
+ const finished = !w.net.some(id => inflight.has(id))
292
+ const waited = w.urls.length ? `${w.urls.length > 2 ? 'several requests' : w.urls.join(', ')} ${w.urls.length > 1 ? 'were' : 'was'} in flight when it unmounted${finished ? ' and finished, so look past it' : ''}; ` : ''
287
293
  leak({ code: 'B0601', h: w.h, name: w.name, file: w.file, msg: `unmounted ${w.name} instance still alive after 2 GC cycles`, fix: w.fx.length ? 'add cleanup to the listed fx: they retain the instance' : `${waited}look for what still references its nodes or signals: an in-flight promise (a pending server call, \`.then\` or \`await\`) or timer that writes to the component, global listeners, caches, module closures`, fx: w.fx })
288
294
  }
289
295
  }
@@ -708,7 +714,7 @@ export function inspect(o) {
708
714
  G.document.addEventListener.call(G.document, 'keydown', e => e.altKey && e.shiftKey && e.code === 'KeyB' && toggle())
709
715
  return api
710
716
  }
711
- const api = { tick, drain, comps, onLoaf, onEvents, leaks: () => leaks.slice(), timers: () => [...timers.values()], toggle: () => toggle(), inflight: () => [...inflight.values()].map(r => `${r.m} ${r.url}`), abortInflight: () => { for (const r of inflight.values()) if (r.stop) r.stop() } }
717
+ const api = { tick, drain, comps, onLoaf, onEvents, leaks: () => leaks.slice(), timers: () => [...timers.values()], toggle: () => toggle(), inflight: () => [...inflight.values()].map(r => `${r.m} ${r.url}`), abortInflight: () => { for (const r of inflight.values()) if (r.stop) r.stop() }, holdVerdict: on => (held_verdict = !!on), alive: () => watched.filter(w => !w.dead).length }
712
718
 
713
719
  // ---------- mini panel (Alt+Shift+B): heap, leaks and link to the full debugger (§9.1) ----------
714
720
  let panel = null
package/brain-lazy.js CHANGED
@@ -75,6 +75,8 @@ export function lazy(el, imp, mode, props = {}) {
75
75
  stop = () => {
76
76
  for (const t of ACT) el.removeEventListener(t, f, t === 'click')
77
77
  }
78
+ } else if (G.document && document.visibilityState === 'hidden') {
79
+ go() // a hidden tab runs no observer: without this the content is missing until the tab is shown
78
80
  } else if (G.IntersectionObserver) {
79
81
  const io = new IntersectionObserver(es => es.some(e => e.isIntersecting) && go(), { rootMargin: '200px' })
80
82
  io.observe(el)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@christianirack/brainllm-runtime",
3
- "version": "0.8.2",
3
+ "version": "0.8.5",
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
@@ -81,6 +81,7 @@ export class HttpError extends Error {
81
81
  Object.assign(this, extra)
82
82
  }
83
83
  }
84
+ const bridgeBusy = message => new HttpError(503, message, { code: 'BRIDGE_BUSY', retryAfter: 1, fix: 'The server is at its limit of concurrent backend requests; retry after the Retry-After delay' })
84
85
  // Plain deliberate 4xx signals are portable from server-only application modules; exception messages stay private.
85
86
  function intendedStatus(error) {
86
87
  if (!error || typeof error !== 'object') return null
@@ -179,6 +180,21 @@ export function acceptedEncodings(header = '', order = ['br', 'zstd', 'gzip']) {
179
180
  // already has an encoding, a `no-transform` cache policy or a status without content is sent as is.
180
181
  const COMPRESSIBLE = /^(?:text\/|image\/svg\+xml|application\/(?:json|javascript|xml|[\w.-]+\+(?:json|xml)))/i
181
182
  const DYNAMIC = ['zstd', 'br', 'gzip']
183
+ // Each Vary token once: a relayed backend header and the additions here must not repeat one (tokens are case-insensitive).
184
+ const varyOnce = v => {
185
+ const seen = new Set()
186
+ return String(v).split(',').map(t => t.trim()).filter(t => t && !seen.has(t.toLowerCase()) && seen.add(t.toLowerCase())).join(', ')
187
+ }
188
+ // Parameters that do not make a different page: the list of `<meta seo query>` (brain-check seo.rs, B0503).
189
+ const TRACKING = /^(?:utm_.*|gclid|fbclid|msclkid)$/
190
+ const cacheSearch = url => {
191
+ const kept = new URLSearchParams([...url.searchParams].filter(([key]) => !TRACKING.test(key)))
192
+ kept.sort()
193
+ return kept.size ? '?' + kept : ''
194
+ }
195
+ // 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 CODE_SHAPE = /^[A-Z][A-Z0-9_]{0,63}$/
182
198
  const varyEncoding = v => (/accept-encoding/i.test(v || '') ? v : [v, 'Accept-Encoding'].filter(Boolean).join(', '))
183
199
  function bodyEncoding(req, status, headers) {
184
200
  if (status < 200 || status === 204 || status === 206 || status === 304 || headers['content-encoding'] || !COMPRESSIBLE.test(headers['content-type'] || '') || /no-transform/i.test(headers['cache-control'] || '')) return null
@@ -250,7 +266,7 @@ function bucketClient(target, key, bucket) {
250
266
  async remove(id) { await send('DELETE', idPath(id), undefined, 10000); return null },
251
267
  })
252
268
  }
253
- export function nativeAuthBridge(raw, refused, serviceKey, report) {
269
+ export function nativeAuthBridge(raw, refused, serviceKey, report, ownSession) {
254
270
  // How to fix the configuration is for the operator: a visitor of a production app reads a generic message.
255
271
  const configuration = (status, detail, rid, shown = 'This service is not available right now') => {
256
272
  if (process.env.NODE_ENV !== 'production') return new HttpError(status, detail)
@@ -412,7 +428,7 @@ export function nativeAuthBridge(raw, refused, serviceKey, report) {
412
428
  const nativeCookies = values => (values || []).filter(value => /^(?:__Host-brain(?:-access|-refresh|-google)?)=/.test(value) && value.length <= 8192).slice(0,8)
413
429
  // /_brain/files: both directions stream with backpressure. The bucket enforces its own maxBytes and access checks.
414
430
  function fileStream(req, res, url, rid) {
415
- if (stopped || streams >= 32) throw new HttpError(503, 'Backend stream capacity is unavailable')
431
+ if (stopped || streams >= 32) throw bridgeBusy('Backend stream capacity is unavailable')
416
432
  if (!['GET','HEAD','POST','DELETE'].includes(req.method)) throw new HttpError(405, 'Method not allowed')
417
433
  const ceiling = 2 * 1024 * 1024 * 1024 + 1024 * 1024
418
434
  if (Number(req.headers['content-length'] || 0) > ceiling) throw new HttpError(413, 'Request body is too large')
@@ -430,7 +446,7 @@ export function nativeAuthBridge(raw, refused, serviceKey, report) {
430
446
  }
431
447
  const closed = () => finish()
432
448
  const count = chunk => { sent += chunk.length; if (sent > ceiling) finish(new HttpError(413, 'Request body is too large')) }
433
- const unavailable = () => new HttpError(503, 'Backend request unavailable')
449
+ const unavailable = transport => new HttpError(503, 'Backend request unavailable', { transport })
434
450
  try {
435
451
  outgoing = httpRequest(new URL(url.pathname + url.search, target), { agent:streamAgent, method:req.method, headers:requestHeaders(req, rid, FILE_HEADERS) }, backend => {
436
452
  const safe = { 'cache-control':'no-store' }
@@ -440,9 +456,11 @@ export function nativeAuthBridge(raw, refused, serviceKey, report) {
440
456
  }
441
457
  const cookies = nativeCookies(backend.headers['set-cookie'])
442
458
  if (cookies.length) safe['set-cookie'] = cookies
459
+ if (safe.vary) safe.vary = varyOnce(safe.vary)
460
+ if (backend.statusCode >= 500) res.$brainCause = 'answered by the native backend'
443
461
  res.writeHead(backend.statusCode || 502, safe)
444
- backend.once('error', () => finish(unavailable()))
445
- backend.once('aborted', () => finish(unavailable()))
462
+ backend.once('error', e => finish(unavailable(e.code)))
463
+ backend.once('aborted', () => finish(unavailable('BACKEND_ABORTED')))
446
464
  backend.once('end', () => { res.end(() => finish()) })
447
465
  backend.pipe(res)
448
466
  })
@@ -451,7 +469,7 @@ export function nativeAuthBridge(raw, refused, serviceKey, report) {
451
469
  outgoing.setTimeout(60000, () => finish(new HttpError(504, 'Backend request timed out')))
452
470
  // The backend's own upload deadline is 300 s; nothing else needs a total limit after the headers.
453
471
  if (req.method === 'POST') deadline = setTimeout(() => finish(new HttpError(504, 'Backend request timed out')), 310000)
454
- outgoing.once('error', () => finish(unavailable()))
472
+ outgoing.once('error', e => finish(unavailable(e.code)))
455
473
  res.once('close', closed)
456
474
  req.on('data', count)
457
475
  req.pipe(outgoing)
@@ -459,14 +477,14 @@ export function nativeAuthBridge(raw, refused, serviceKey, report) {
459
477
  })
460
478
  }
461
479
  function proxy(req, res, url, rid, reply, responseLimit = 20 * 1024 * 1024, declared) {
462
- if (stopped || active >= 8) throw new HttpError(503, 'Backend request capacity is unavailable')
480
+ if (stopped || active >= 8) throw bridgeBusy('Backend request capacity is unavailable')
463
481
  if (!['GET','HEAD','POST','PUT','PATCH','DELETE','OPTIONS'].includes(req.method)) throw new HttpError(405, 'Method not allowed')
464
482
  const post = req.method === 'POST'
465
483
  const limit = url.pathname === '/_brain/uploads' && post ? 5 * 1024 * 1024 + 65536 : declared || post && /^\/api\/[^/]+\/batch$/.test(url.pathname) ? 1024 * 1024 : 65536
466
484
  if (Number(req.headers['content-length'] || 0) > limit) throw new HttpError(413, 'Request body is too large')
467
485
  active++
468
486
  return new Promise((resolve, reject) => {
469
- let outgoing, timer, done = false, bytes = 0, requestBytes = 0, failure
487
+ let outgoing, timer, done = false, bytes = 0, requestBytes = 0, failure, transport
470
488
  const chunks = []
471
489
  const headers = requestHeaders(req, rid, declared?.headers)
472
490
  const finish = (error, backend) => {
@@ -474,7 +492,7 @@ export function nativeAuthBridge(raw, refused, serviceKey, report) {
474
492
  done = true; clearTimeout(timer); active--; proxyBytes -= bytes; owned.delete(outgoing)
475
493
  req.removeListener('aborted',cancel); req.removeListener('data',data); req.removeListener('end',end)
476
494
  res.removeListener('close',cancel)
477
- if (error) { if (!res.destroyed) reject(failure || new HttpError(503,'Backend request unavailable')); else resolve(); return }
495
+ if (error) { if (!res.destroyed) reject(failure || new HttpError(503,'Backend request unavailable',{transport})); else resolve(); return }
478
496
  const safe = { 'cache-control':'no-store' }
479
497
  for (const key of RESPONSE_HEADERS) {
480
498
  const value = backend.headers[key]
@@ -486,6 +504,7 @@ export function nativeAuthBridge(raw, refused, serviceKey, report) {
486
504
  }
487
505
  const cookies = nativeCookies(backend.headers['set-cookie'])
488
506
  if (cookies.length) safe['set-cookie'] = cookies
507
+ if (backend.statusCode >= 500 && !res.$capture) res.$brainCause = 'answered by the native backend'
489
508
  try { reply(res,backend.statusCode || 502,req.method === 'HEAD' ? '' : Buffer.concat(chunks,bytes),safe); resolve() } catch (e) { reject(e) }
490
509
  }
491
510
  const cancel = () => { outgoing?.destroy(); finish(true) }
@@ -502,11 +521,11 @@ export function nativeAuthBridge(raw, refused, serviceKey, report) {
502
521
  if (bytes > responseLimit || proxyBytes > 64 * 1024 * 1024) { failure = new HttpError(502,'Backend response exceeds its byte budget'); outgoing.destroy(); finish(true); return }
503
522
  chunks.push(chunk)
504
523
  })
505
- backend.once('end',()=>finish(false,backend)); backend.once('error',()=>finish(true)); backend.once('aborted',()=>finish(true))
524
+ backend.once('end',()=>finish(false,backend)); backend.once('error',e=>{transport=e.code;finish(true)}); backend.once('aborted',()=>{transport='BACKEND_ABORTED';finish(true)})
506
525
  })
507
526
  owned.add(outgoing)
508
527
  timer = setTimeout(()=>{failure = new HttpError(504,'Backend request timed out');outgoing.destroy();finish(true)},declared || url.pathname === '/_brain/uploads' || url.pathname.startsWith('/_brain/uploads/') ? 35000 : 10000)
509
- outgoing.once('error',()=>finish(true)); req.once('aborted',cancel); res.once('close',cancel)
528
+ outgoing.once('error',e=>{transport=e.code;finish(true)}); req.once('aborted',cancel); res.once('close',cancel)
510
529
  req.on('data',data); req.once('end',end)
511
530
  if (req.readableEnded) end()
512
531
  } catch { outgoing?.destroy(); finish(true) }
@@ -527,17 +546,20 @@ export function nativeAuthBridge(raw, refused, serviceKey, report) {
527
546
  try { bytes = Buffer.from(JSON.stringify(body)) } catch { throw new HttpError(400, 'Native resource values must be JSON serializable') }
528
547
  if (bytes.length > 65536) throw new HttpError(413, 'Native resource values exceed their byte budget')
529
548
  }
530
- const req = Readable.from(bytes.length ? [bytes] : [])
531
- req.method = method
532
- req.$brainForwardedFor = forwardedFor(incoming)
533
- // A service call acts as the key's owner, never as the visitor: it sends no session.
534
- req.headers = { ...(serviceKey ? { authorization: 'Bearer ' + serviceKey } : { cookie: cookies(incoming, false) }), accept: 'application/json', 'content-type':'application/json', 'content-length':String(bytes.length) }
535
- if (method !== 'GET') {
536
- req.headers.origin = bridgeOrigin(incoming, rid, 'Native writes require a same-origin server request')
549
+ const outgoing = () => {
550
+ const req = Readable.from(bytes.length ? [bytes] : [])
551
+ req.method = method
552
+ req.$brainForwardedFor = forwardedFor(incoming)
553
+ // A service call acts as the key's owner, never as the visitor: it sends no session.
554
+ req.headers = { ...(serviceKey ? { authorization: 'Bearer ' + serviceKey } : ownSession ? {} : { cookie: cookies(incoming, false) }), accept: 'application/json', 'content-type':'application/json', 'content-length':String(bytes.length) }
555
+ if (method !== 'GET') {
556
+ req.headers.origin = bridgeOrigin(incoming, rid, 'Native writes require a same-origin server request')
557
+ }
558
+ return req
537
559
  }
560
+ let req = outgoing()
538
561
  let value
539
- try {
540
- await proxy(req, response, new URL(path, target), rid, (_res, status, data, headers) => {
562
+ const answered = (_res, status, data, headers) => {
541
563
  if (status === 422) {
542
564
  // the backend's own field messages (never driver text): a server function that rethrows answers 422 {errors}
543
565
  let fields
@@ -557,7 +579,29 @@ export function nativeAuthBridge(raw, refused, serviceKey, report) {
557
579
  if (status === 204) { value = null; return }
558
580
  if (!String(headers['content-type'] || '').startsWith('application/json')) throw new HttpError(502, 'Native resource response must be JSON')
559
581
  try { value = JSON.parse(data.toString('utf8')) } catch { throw new HttpError(502, 'Native resource response is invalid') }
560
- }, 2 * 1024 * 1024)
582
+ }
583
+ try {
584
+ // A restarting backend refuses connections for a few seconds: a read waits for it instead of failing the page.
585
+ for (let wait = 250; ; wait *= 2) {
586
+ try {
587
+ await proxy(req, response, new URL(path, target), rid, answered, 2 * 1024 * 1024)
588
+ break
589
+ } catch (e) {
590
+ if (method !== 'GET' || wait > 2000 || stopped || response.destroyed || !['ECONNREFUSED', 'ECONNRESET'].includes(e?.transport)) throw e
591
+ req.destroy()
592
+ await new Promise(done => setTimeout(done, wait))
593
+ req = outgoing()
594
+ }
595
+ }
596
+ } catch (e) {
597
+ // A read that failed: `load` may catch it and render anyway, so the page answer says what happened (ssr below).
598
+ if (method === 'GET' && !(e?.status >= 400 && e.status < 500)) {
599
+ const seen = incoming.$brainFailedReads || (incoming.$brainFailedReads = { reads: 0, backendCode: undefined, transport: undefined })
600
+ seen.reads++
601
+ seen.backendCode ??= e?.code
602
+ seen.transport ??= e?.transport
603
+ }
604
+ throw e
561
605
  } finally { req.destroy() }
562
606
  if (response.destroyed || response.writableEnded) throw new HttpError(409, 'The server request has ended')
563
607
  return value
@@ -796,18 +840,22 @@ export function createServer(o = {}) {
796
840
  // ctx.env: what the supervisor passed, read-only; the service key stays with ctx.backend.service
797
841
  const { BRAIN_SERVICE_API_KEY: serviceKey, BRAIN_JOBS_API_KEY: _jobs, BRAIN_JOBS_SYSTEM_KEY: _jobsSystem, BRAIN_ROUTES_API_KEY: _routes, BRAIN_ROUTES_SYSTEM_KEY: _routesSystem, ...visibleEnv } = o.env || ENV
798
842
  const env = Object.freeze(visibleEnv)
799
- const native = nativeAuthBridge(o.backendURL ?? env.BRAIN_BACKEND_URL, (...why) => originRefused(...why), serviceKey, (...entry) => log(...entry))
800
- // Using the native backend does not have to mean it owns the visitor's session: `app` keeps the signed cookie.
801
843
  const backendSession = o.backendSession ?? env.BRAIN_BACKEND_SESSION
802
844
  if (backendSession && backendSession !== 'app') throw Error("BRAIN_BACKEND_SESSION must be app, or unset for the backend's own session")
845
+ const native = nativeAuthBridge(o.backendURL ?? env.BRAIN_BACKEND_URL, (...why) => originRefused(...why), serviceKey, (...entry) => log(...entry), backendSession === 'app')
846
+ // Using the native backend does not have to mean it owns the visitor's session: `app` keeps the signed cookie.
803
847
  const nativeSession = native && backendSession !== 'app'
804
848
  if (o.nativeSockets !== undefined && typeof o.nativeSockets !== 'boolean') throw Error('nativeSockets must be an explicit boolean')
805
849
  const SITEMAP_BYTES = +(o.sitemapBytes ?? 50 * 1024 * 1024) // protocol: 50 MB uncompressed per sitemap file
806
850
  const secret = o.secret ?? env.BRAIN_SECRET ?? ''
807
851
  const COMPRESS = o.compress !== false && env.BRAIN_COMPRESS !== 'off' // a proxy that already compresses can opt out
808
852
  const sink = o.log || ((level, msg, extra) => console[level === 'error' ? 'error' : 'log'](json({ t: new Date().toISOString(), level, msg, ...extra })))
853
+ // Requests in flight by rid: a 5xx whose cause reached no log line gets one when the response closes (dispatch below).
854
+ const liveRequests = new Map()
855
+ let statusPageWindow = 0, statusPageLogs = 0
809
856
  const log = (level, msg, extra = {}) => {
810
857
  const safe = { ...extra }
858
+ if (level !== 'info' && extra.rid) { const live = liveRequests.get(extra.rid); if (live) live.logged = true }
811
859
  // Error messages may contain credentials or request data. Retain only bounded V8 source frames.
812
860
  if (typeof safe.stack === 'string') {
813
861
  const kind = /^(TypeError|ReferenceError|RangeError|SyntaxError|AggregateError|Error):/.exec(safe.stack)?.[1] || 'Error'
@@ -843,6 +891,9 @@ export function createServer(o = {}) {
843
891
  // log fields for a thrown value: its stack (sanitized by log) plus, for B09xx budget failures, the typed fields only
844
892
  const trace = e => {
845
893
  const out = { stack: String((e && e.stack) || e) }
894
+ if (e instanceof HttpError && OWN_CAUSES.has(e.message)) out.cause = e.message
895
+ if (e && CODE_SHAPE.test(e.transport || '')) out.transport = e.transport
896
+ if (e && typeof e.code === 'string' && CODE_SHAPE.test(e.code)) out.backendCode = e.code
846
897
  if (e && e.kind === 'budget' && /^B09\d\d$/.test(e.code) && typeof e.budget === 'string' && /^[a-zA-Z]{1,16}$/.test(e.budget)) {
847
898
  const str = (v, n) => typeof v === 'string' ? v.slice(0, n) : null, pos = v => Number.isSafeInteger(v) && v > 0 ? v : null
848
899
  out.budgetFailure = { code: e.code, budget: e.budget, limit: Number.isFinite(e.limit) ? e.limit : null, file: str(e.file, 256), component: str(e.component, 80), line: pos(e.line), column: pos(e.column), fix: str(e.fix, 200) || 'Bound the work or move it to an owned worker.' }
@@ -936,7 +987,7 @@ export function createServer(o = {}) {
936
987
  res.req.$nativeLogout.then(() => send(res, status, body, headers, configuredExternal), () => send(res, 503, '', {}, false))
937
988
  return
938
989
  }
939
- if (native && res.req.$nativeLogoutError) { status = 503; body = ''; headers = {}; res.req.$nativeLogoutError = false }
990
+ if (native && res.req.$nativeLogoutError) { status = 503; body = ''; headers = {}; res.req.$nativeLogoutError = false; res.$brainCause = 'native sign-out failed' }
940
991
  if (native && res.req.$nativeLogoutHeaders) {
941
992
  const relay = res.req.$nativeLogoutHeaders, own = headers['set-cookie']
942
993
  headers = { ...headers, ...relay, 'set-cookie': [...(own ? Array.isArray(own) ? own : [own] : []), ...relay['set-cookie']] }
@@ -974,6 +1025,7 @@ export function createServer(o = {}) {
974
1025
  delete headers['content-length']
975
1026
  }
976
1027
  }
1028
+ if (headers.vary) headers = { ...headers, vary: varyOnce(headers.vary) }
977
1029
  res.writeHead(status, { ...SECURITY, ...(server.draining ? { connection: 'close' } : {}), ...headers })
978
1030
  res.end(res.req.method === 'HEAD' || status === 204 || status === 205 || status === 304 ? undefined : body)
979
1031
  }
@@ -995,8 +1047,8 @@ export function createServer(o = {}) {
995
1047
  const notFound = (req, res) => failPage(res, 404, res.getHeader('x-request-id'))
996
1048
  // `gone()` from load (§33): 410 tells crawlers the URL was removed for good; views/410.brain, else the 404 page
997
1049
  const missing = (req, res, x) => (x && x.gone === true ? failPage(res, 410, res.getHeader('x-request-id')) : notFound(req, res))
998
- const failPage = (res, status, rid) => {
999
- if (!wantsHtml(res.req)) return send(res, status, json({ schemaVersion: 1, status, rid, error: STATUS_CODES[status] || 'Request failed' }), { 'content-type': MIME['.json'] })
1050
+ const failPage = (res, status, rid, detail) => {
1051
+ if (!wantsHtml(res.req)) return send(res, status, json({ schemaVersion: 1, status, rid, error: detail || STATUS_CODES[status] || 'Request failed' }), { 'content-type': MIME['.json'] })
1000
1052
  const custom = (status === 404 && mf.notFound) || (mf.errors && mf.errors[status]) || (status === 410 && !file('410.html') ? mf.notFound : `${status}.html`)
1001
1053
  const path = typeof custom === 'string' && file(custom)
1002
1054
  return html(res, status, path ? readFileSync(path, 'utf8') : defaultError(status, rid, BASE))
@@ -1049,16 +1101,20 @@ export function createServer(o = {}) {
1049
1101
  const SSRC = new Map(), SSRC_MAX = 500, SSRC_BYTES = 32 * 1024 * 1024, SSRC_ENTRY = 1024 * 1024
1050
1102
  let ssrBytes = 0
1051
1103
  async function cached(req, res, route, params, url, rid) {
1052
- const n = +route.cache, ms = n * 1000, key = (route.locale || '') + url.pathname + url.search
1053
- const fill = () =>
1054
- new Promise(ok => {
1104
+ const n = +route.cache, ms = n * 1000, key = (route.locale || '') + url.pathname + cacheSearch(url)
1105
+ const fill = () => {
1106
+ let failed = false
1107
+ return new Promise(ok => {
1055
1108
  const cap = { req, $capture: (out, h, st) => {
1056
1109
  const headers = Object.fromEntries(Object.entries(h).map(([k, v]) => [k.toLowerCase(), v]))
1057
1110
  const privateResponse = headers['set-cookie'] || headers['vary'] || /private|no-store|no-cache/i.test(headers['cache-control'] || '')
1111
+ if (st >= 500) failed = true
1058
1112
  ok(st === 200 ? { out, h, cacheable: !privateResponse, etag: 'W/"' + tag(out) + '"', t: Date.now(), bytes: Buffer.byteLength(out) } : null)
1059
- }, writeHead() {}, end: () => ok(null), setHeader() {}, getHeader() {} }
1113
+ }, writeHead(st) { if (st >= 500) failed = true }, end: () => ok(null), setHeader() {}, getHeader: name => (name === 'x-request-id' ? rid : undefined), once() {}, removeListener() {} }
1060
1114
  ssr(req, cap, route, params, url, rid, null, 200, null, true).catch(() => ok(null))
1061
1115
  }).then(c => {
1116
+ // A failed revalidation keeps the copy until 2N, which is what `stale-if-error` promises downstream.
1117
+ if ((!c || !c.cacheable) && (failed || req.$brainFailedReads) && SSRC.has(key)) return c
1062
1118
  if (!c || !c.cacheable) {
1063
1119
  ssrBytes -= SSRC.get(key)?.bytes || 0
1064
1120
  SSRC.delete(key)
@@ -1076,7 +1132,8 @@ export function createServer(o = {}) {
1076
1132
  }
1077
1133
  return c
1078
1134
  })
1079
- const serve = c => html(res, 200, c.out, { ...c.h, 'cache-control': c.cacheable ? `public, s-maxage=${n}, stale-while-revalidate=${n}` : 'no-store' }, c.etag)
1135
+ }
1136
+ const serve = c => html(res, 200, c.out, { ...c.h, 'cache-control': c.cacheable ? `public, s-maxage=${n}, stale-while-revalidate=${n}, stale-if-error=${n}` : 'no-store' }, c.etag)
1080
1137
  let c = SSRC.get(key)
1081
1138
  const age = c ? Date.now() - c.t : Infinity
1082
1139
  if (c && age < 2 * ms) {
@@ -1153,6 +1210,10 @@ export function createServer(o = {}) {
1153
1210
  return loadOutcome(e)
1154
1211
  }
1155
1212
  if (props && (props.redirect || props.notFound === true)) return loadOutcome(props) // `return redirect()` / `return notFound()`
1213
+ if (req.$brainFailedReads && !fixed) {
1214
+ softFailure(req, rid, url, status)
1215
+ extra = { ...extra, 'cache-control': 'no-store' } // what it rendered instead is not worth keeping
1216
+ }
1156
1217
  let body, seo, failed
1157
1218
  try {
1158
1219
  const at = { path: url.pathname, params: params || {}, query: Object.fromEntries(url.searchParams) } // route() (§41.5)
@@ -1181,6 +1242,11 @@ export function createServer(o = {}) {
1181
1242
  // (head, CSS, layout shell) is sent, the view when load() ends. The status and headers are out by then, so a late
1182
1243
  // redirect becomes location.replace and a late failure an inline notice with the request id (both logged); load()
1183
1244
  // cannot change the session on a streamed route.
1245
+ // `load` caught a failed backend read and rendered: the status it asked for stands, the log says what is behind it.
1246
+ const softFailure = (req, rid, url, status) => {
1247
+ const { reads, backendCode, transport } = req.$brainFailedReads
1248
+ log('warn', 'page rendered after a failed backend read', { rid, path: url.pathname, status, reads, backendCode, transport, fix: 'load caught the failure and returned other props; let it throw for a 503, or show that the data is missing' })
1249
+ }
1184
1250
  async function streamed(req, res, route, params, url, rid, ctx, extra, outcome) {
1185
1251
  const [top, bottom = ''] = readFileSync(file(route.page), 'utf8').split('<!--brain:ssr-->')
1186
1252
  const sm = await load(route.server)
@@ -1250,6 +1316,7 @@ export function createServer(o = {}) {
1250
1316
  log('error', 'load failed (streamed)', { rid, path: url.pathname, status: 200, ...trace(e) }) // headers already out
1251
1317
  return fail()
1252
1318
  }
1319
+ if (req.$brainFailedReads) softFailure(req, rid, url, 200)
1253
1320
  try {
1254
1321
  const { body, failed } = await (queue = queue.then(() => render(route, props, null, at), () => render(route, props, null, at)))
1255
1322
  for (const f of failed || []) log('error', 'component failed during SSR', { rid, path: url.pathname, comp: f.comp, where: f.w, ...trace(f.e) })
@@ -1301,6 +1368,7 @@ export function createServer(o = {}) {
1301
1368
  }
1302
1369
  if (route.i18n) await load(route.i18n) // the route's dictionary chunk: add(locale, {…}) before anything renders
1303
1370
  globalThis.__brainForms = forms // form() reads it while the view is built (renders are serialized)
1371
+ globalThis.__brainIds = (globalThis.__brainIds || 0) + 1 // each page numbers its instance ids from the start, as the client will
1304
1372
  globalThis.__brainLocale = route.locale || null // t(), fmt and locale() of this page (§37)
1305
1373
  globalThis.__brainAlternates = route.alternates || null // <LangSwitch> links, in the HTML before any JS
1306
1374
  globalThis.__brainRoute = at // route(): app path, params, query of this request
@@ -1417,8 +1485,8 @@ export function createServer(o = {}) {
1417
1485
  // pages, plus an optional `lastmod`, at request time. /sitemap.xml is the build's file with those URLs added (a sitemap
1418
1486
  // index gets one more child, /sitemap-dynamic.xml). A file holds at most 50 000 URLs (sitemaps.org): when the build's
1419
1487
  // URLs plus the dynamic ones pass that, /sitemap.xml becomes an index of /sitemap-static.xml (the build's file) and
1420
- // /sitemap-dynamic.xml; dynamic URLs past 50 000 are dropped with a warning. Absolute URLs take the origin of the
1421
- // build's sitemap (brain.json `site`); a build without a sitemap serves none. The list is kept 5 min (one sitemap() run
1488
+ // /sitemap-dynamic.xml; dynamic URLs past 50 000 are dropped with a warning. Absolute URLs take the site origin from
1489
+ // the manifest (an older manifest: from the build's sitemap); a build without a sitemap serves none. The list is kept 5 min (one sitemap() run
1422
1490
  // at a time when it expires) and the response says max-age=300; a sitemap() that throws is logged and skipped.
1423
1491
  const DYN = routes.filter(r => r.sitemap && r.server)
1424
1492
  let dynList = null
@@ -1434,14 +1502,23 @@ export function createServer(o = {}) {
1434
1502
  const day = v => (v instanceof Date ? v.toISOString().slice(0, 10) : typeof v === 'string' && /^\d{4}-\d{2}-\d{2}/.test(v) ? v : null)
1435
1503
  const MAX_URLS = 50000
1436
1504
  let dynRun = null
1505
+ const dynGood = new Map()
1437
1506
  async function collect(origin) {
1438
1507
  const urls = []
1439
1508
  let dropped = 0
1440
- for (const r of DYN) {
1509
+ // Views answer at the same time, so one slow view costs one timeout; a view that fails keeps its last good list.
1510
+ const lists = await Promise.all(DYN.map(async r => {
1441
1511
  try {
1442
1512
  const sm = await load(r.server)
1443
1513
  const list = typeof sm.sitemap === 'function' ? await withTimeout(Promise.resolve().then(() => sm.sitemap({ env })), TIMEOUT) : []
1444
- for (const p of Array.isArray(list) ? list : []) {
1514
+ dynGood.set(r, Array.isArray(list) ? list : [])
1515
+ } catch (e) {
1516
+ log('error', 'sitemap() failed', { view: r.view, ...trace(e) })
1517
+ }
1518
+ return dynGood.get(r) || []
1519
+ }))
1520
+ DYN.forEach((r, n) => {
1521
+ for (const p of lists[n]) {
1445
1522
  const path = fill(r.path, p)
1446
1523
  if (!path) continue
1447
1524
  const loc = origin + withBase(path)
@@ -1449,19 +1526,17 @@ export function createServer(o = {}) {
1449
1526
  else if (urls.length < MAX_URLS) urls.push({ loc, lastmod: day(p.lastmod) })
1450
1527
  else dropped++
1451
1528
  }
1452
- } catch (e) {
1453
- log('error', 'sitemap() failed', { view: r.view, ...trace(e) })
1454
- }
1455
- }
1529
+ })
1456
1530
  if (dropped) log('warn', 'sitemap(): more than 50000 URLs, the rest are not listed', { dropped })
1531
+ if (!urls.length) log('warn', 'sitemap(): no view returned a URL, so /sitemap.xml lists only the pages of the build', { views: DYN.map(r => r.view) })
1457
1532
  dynList = { at: Date.now(), urls }
1458
1533
  return urls
1459
1534
  }
1460
1535
  async function dynamicUrls(origin) {
1461
1536
  if (dynList && Date.now() - dynList.at < 300000) return dynList.urls
1462
- // concurrent requests after expiry share one run of every sitemap()
1537
+ // concurrent requests after expiry share one run of every sitemap(); a crawler never waits while a list exists
1463
1538
  if (!dynRun) dynRun = collect(origin).finally(() => (dynRun = null))
1464
- return dynRun
1539
+ return dynList ? dynList.urls : dynRun
1465
1540
  }
1466
1541
  // §33 S01: every sitemap file holds at most 50 000 URLs and SITEMAP_BYTES bytes of UTF-8 XML; the dynamic URLs fill
1467
1542
  // /sitemap-dynamic.xml, /sitemap-dynamic-2.xml, … in sitemap() order, so the split is deterministic
@@ -1484,7 +1559,7 @@ export function createServer(o = {}) {
1484
1559
  const f = file('/sitemap.xml')
1485
1560
  if (!f) return false
1486
1561
  const text = readFileSync(f, 'utf8')
1487
- const origin = [...text.matchAll(/<loc>(https?:\/\/[^<]+)<\/loc>/g)].map(m => URL.canParse(m[1]) && new URL(m[1]).origin).find(Boolean)
1562
+ const origin = mf.site || [...text.matchAll(/<loc>(https?:\/\/[^<]+)<\/loc>/g)].map(m => URL.canParse(m[1]) && new URL(m[1]).origin).find(Boolean)
1488
1563
  if (!origin) return false
1489
1564
  const index = text.includes('<sitemapindex')
1490
1565
  const sets = pages(await dynamicUrls(origin))
@@ -1527,9 +1602,19 @@ export function createServer(o = {}) {
1527
1602
  if (path === null) return false
1528
1603
  const md = file(mdPath(path))
1529
1604
  if (!md) return false
1530
- send(res, 200, req.method === 'HEAD' ? '' : readFileSync(md), { 'content-type': MIME['.md'], 'cache-control': 'public, max-age=3600', vary: 'accept' })
1605
+ send(res, 200, req.method === 'HEAD' ? '' : readFileSync(md), { 'content-type': MIME['.md'], 'cache-control': 'public, max-age=3600', vary: 'accept', ...twinCanonical(path) })
1531
1606
  return true
1532
1607
  }
1608
+ // A Markdown copy repeats its page: the header names the canonical the page itself declares. A page without one (no
1609
+ // site configured) or with a per-request pattern gets no header, so this never invents a URL.
1610
+ function twinCanonical(path) {
1611
+ const stem = path.replace(/\.md$/, '').replace(/\/+$/, '')
1612
+ const page = file(stem ? stem + '.html' : 'index.html') || file(stem + '/index.html')
1613
+ if (!page) return {}
1614
+ const tag = /<link\b[^>]*\brel=["']canonical["'][^>]*>/i.exec(readFileSync(page, 'utf8'))?.[0]
1615
+ const href = tag && /\bhref="(https?:\/\/[^"<>\s]+)"/.exec(tag)?.[1].replaceAll('&amp;', '&')
1616
+ return href && URL.canParse(href) && !/\/[:*]/.test(new URL(href).pathname) ? { link: `<${href}>; rel="canonical"` } : {}
1617
+ }
1533
1618
  function serveStatic(req, res, url) {
1534
1619
  const p = publicStaticPath(url.pathname)
1535
1620
  if (p === null) return false
@@ -1547,6 +1632,7 @@ export function createServer(o = {}) {
1547
1632
  return html(res, 200, page, md ? { vary: 'accept', 'x-brain-static': '1' } : { 'x-brain-static': '1' }, '"' + (mf.build || tag(page)) + '"'), true
1548
1633
  }
1549
1634
  const headers = {
1635
+ ...(ext === '.md' ? twinCanonical(p) : {}),
1550
1636
  'content-type': type,
1551
1637
  // the worker script must be revalidated on every check, or a CDN could keep an old worker for an hour (§35.7)
1552
1638
  'cache-control': p.startsWith('/_b/') ? 'public, max-age=31536000, immutable' : p === '/sw.js' ? 'no-cache' : 'public, max-age=3600',
@@ -1577,7 +1663,43 @@ export function createServer(o = {}) {
1577
1663
  if (req.method !== 'GET' && req.method !== 'HEAD') return notFound(req, res)
1578
1664
  if (!native) return send(res, 200, json({ ok: true }), headers)
1579
1665
  try { await native.proxy(req, res, new URL(url.pathname, url), rid, send) }
1580
- catch { if (!res.headersSent) send(res, 503, json({ ok: false }), headers) }
1666
+ catch (e) {
1667
+ if (!res.headersSent) {
1668
+ log('warn', 'health probe could not reach the native backend', { rid, path: url.pathname, status: 503, ...trace(e) })
1669
+ send(res, 503, json({ ok: false }), headers)
1670
+ }
1671
+ }
1672
+ }
1673
+
1674
+ const urlVariant = req => {
1675
+ if (req.method !== 'GET' && req.method !== 'HEAD') return null
1676
+ const q = req.url.indexOf('?'), raw = q < 0 ? req.url : req.url.slice(0, q)
1677
+ if (!raw.includes('//') && !raw.endsWith('/index.html')) return null
1678
+ let path = raw.replace(/\/{2,}/g, '/')
1679
+ const app = '/' + (path.startsWith(BASE) ? path.slice(BASE.length) : path.slice(1))
1680
+ if (/^\/(?:api|_brain|_r|s)(?:\/|$)/.test(app) && !raw.startsWith('//')) return null
1681
+ if (path.endsWith('/index.html')) path = path.slice(0, -'index.html'.length)
1682
+ if (path !== BASE && path !== '/' && (path.endsWith('/') && SLASH !== 'always')) path = path.replace(/\/+$/, '')
1683
+ const location = path + (q < 0 ? '' : req.url.slice(q))
1684
+ try { safeLocation(location) } catch { return null }
1685
+ return location
1686
+ }
1687
+ // `<meta seo query="page">`: `page` is a number from 1 and page 1 is the URL without it. A view decides past its last
1688
+ // page with notFound(). → null (nothing to do) · 404 · the URL to redirect to.
1689
+ const pagedRoutes = new Map()
1690
+ const pageParameter = (route, url) => {
1691
+ if (!url.searchParams.has('page') || !route.page) return null
1692
+ if (!pagedRoutes.has(route.page)) {
1693
+ const keys = /<meta name="brain-query" content="([^"]*)">/.exec(readFileSync(file(route.page), 'utf8'))?.[1]
1694
+ pagedRoutes.set(route.page, !!keys && keys.split(/[\s,]+/).includes('page'))
1695
+ }
1696
+ if (!pagedRoutes.get(route.page)) return null
1697
+ const values = url.searchParams.getAll('page')
1698
+ if (values.length > 1 || !/^[1-9]\d{0,8}$/.test(values[0])) return 404
1699
+ if (values[0] !== '1') return null
1700
+ const rest = new URLSearchParams(url.search)
1701
+ rest.delete('page')
1702
+ return url.pathname + (rest.size ? '?' + rest : '')
1581
1703
  }
1582
1704
 
1583
1705
  // ---------- dispatch ----------
@@ -1594,13 +1716,23 @@ export function createServer(o = {}) {
1594
1716
  }
1595
1717
  const rid = req.headers['x-request-id'] && /^[\w-]{1,64}$/.test(req.headers['x-request-id']) ? req.headers['x-request-id'] : randomUUID()
1596
1718
  res.setHeader('x-request-id', rid)
1719
+ const live = { logged: false }
1720
+ liveRequests.set(rid, live)
1721
+ res.once('close', () => {
1722
+ if (liveRequests.get(rid) === live) liveRequests.delete(rid)
1723
+ // The net under every path: a 5xx that was answered always leaves a line with its request id.
1724
+ if (res.statusCode >= 500 && !live.logged && !res.$brainMuted) log(res.$brainCause === STATUS_PAGE ? 'info' : 'error', 'server error answered', { rid, path: req.$brainPath || String(req.url || '').split('?')[0], status: res.statusCode, cause: res.$brainCause || 'no cause recorded on this path', ...(res.$brainCause === STATUS_PAGE ? {} : { fix: 'Search this rid in the native backend log; its failed requests carry the same id' }) })
1725
+ })
1597
1726
  const t0 = performance.now()
1598
1727
  res.on('finish', () => {
1599
1728
  const ms = performance.now() - t0
1600
1729
  if (ms > 1000) log('warn', 'slow request', { rid, path: req.$brainPath || String(req.url || '').split('?')[0], status: res.statusCode, ms: Math.round(ms) })
1601
1730
  })
1602
1731
  try {
1603
- // `//` makes new URL throw and `//host/x` would be read as another host
1732
+ // One URL per page: `//route`, `/a//b` and `/route/index.html` answer one 301 on this origin. The path is rewritten
1733
+ // as text, before URL parsing: `//host/x` would be read as another host. Backend paths are left to the backend.
1734
+ const variant = urlVariant(req)
1735
+ if (variant) return send(res, 301, '', { location: variant, 'cache-control': 'public, max-age=3600' })
1604
1736
  if (req.url.startsWith('//')) return notFound(req, res)
1605
1737
  const full = new URL(req.url, 'http://' + (req.headers.host || 'localhost'))
1606
1738
  // /_health stays at the root: the supervisor calls it there whatever the base
@@ -1629,13 +1761,23 @@ export function createServer(o = {}) {
1629
1761
  // a status page opened directly answers its own status: `/403` is never a 200 that a crawler or a probe can trust
1630
1762
  const reserved = post ? 0 : reservedStatus(url.pathname)
1631
1763
  if (reserved === 403) return forbidden(req, res, url, rid, '')
1632
- if (reserved) return failPage(res, reserved, rid)
1764
+ if (reserved) {
1765
+ // Anyone can open it, so the note about it is bounded and is not an error.
1766
+ const minute = Math.floor(Date.now() / 60000)
1767
+ if (statusPageWindow !== minute) { statusPageWindow = minute; statusPageLogs = 0 }
1768
+ res.$brainCause = STATUS_PAGE
1769
+ res.$brainMuted = statusPageLogs++ >= 16
1770
+ return failPage(res, reserved, rid)
1771
+ }
1633
1772
  for (const route of routes) {
1634
1773
  const m = route.rx.exec(url.pathname)
1635
1774
  if (!m) continue
1636
1775
  // a form page without load is prerendered: GET serves that file (build ETag); only its POST needs the server
1637
1776
  if (!post && route.forms && serveStatic(req, res, url)) return
1638
1777
  if (!post && serveTwin(req, res, url)) return
1778
+ const paged = post ? null : pageParameter(route, url)
1779
+ if (paged === 404) return notFound(req, res)
1780
+ if (paged) return send(res, 301, '', { location: withBase(paged), 'cache-control': 'public, max-age=3600' })
1639
1781
  return await (post ? formPost : ssr)(req, res, route, m.groups ? { ...m.groups } : {}, url, rid)
1640
1782
  }
1641
1783
  if (post) return send(res, 405, 'Method not allowed', { allow: 'GET, HEAD' })
@@ -1646,9 +1788,19 @@ export function createServer(o = {}) {
1646
1788
  if (isProbe(url.pathname)) return await answerProbe(req, res, url, rid)
1647
1789
  notFound(req, res)
1648
1790
  } catch (e) {
1649
- log('error', 'unhandled', { rid, path: req.url, ...trace(e) })
1650
- if (!res.headersSent) failPage(res, intendedStatus(e) || 500, rid)
1651
- else res.end()
1791
+ const busy = e instanceof HttpError && e.code === 'BRIDGE_BUSY'
1792
+ if (busy) log('warn', 'backend bridge busy', { rid, path: req.$brainPath || String(req.url || '').split('?')[0], fix: e.fix })
1793
+ else log('error', 'unhandled', { rid, path: req.url, ...trace(e) })
1794
+ try {
1795
+ if (busy && !res.headersSent) { res.setHeader('retry-after', String(e.retryAfter)); failPage(res, 503, rid, { code: e.code, message: 'Service Unavailable', fix: e.fix }) }
1796
+ else if (!res.headersSent) failPage(res, intendedStatus(e) || 500, rid)
1797
+ else res.end()
1798
+ } catch (again) {
1799
+ // the error page itself failed: still an answer, never an unhandled rejection that takes the process down
1800
+ log('error', 'error page failed', { rid, path: req.url, ...trace(again) })
1801
+ if (!res.headersSent) res.writeHead(500, SECURITY)
1802
+ res.end()
1803
+ }
1652
1804
  }
1653
1805
  })
1654
1806
  if (native) server.once('close', () => native.close())
@@ -1660,6 +1812,7 @@ export function createServer(o = {}) {
1660
1812
  }
1661
1813
 
1662
1814
  // ---------- session (signed cookie; shared with the dev server) ----------
1815
+ const STATUS_PAGE = 'status page opened directly'
1663
1816
  const COOKIE = '__Host-session', SECRET_COOKIE = '__Host-session-secret', SECRET_MAX = 3500, DEFAULT_SESSION_SECONDS = 7 * 24 * 3600
1664
1817
  // Keys that still verify and decrypt during a rotation; new cookies always use the current key.
1665
1818
  const previousKeys = () => (process.env.BRAIN_SECRET_PREVIOUS || '').split(',').map(k => k.trim()).filter(Boolean)
@@ -2202,6 +2355,8 @@ function real(p) {
2202
2355
  }
2203
2356
  if (process.argv[1] && real(process.argv[1]) === real(fileURLToPath(import.meta.url))) {
2204
2357
  process.env.NODE_ENV = 'production'
2358
+ // A closed log pipe is the operator's matter: requests do not depend on it.
2359
+ for (const stream of [process.stdout, process.stderr]) stream.on('error', error => { if (error.code !== 'EPIPE') throw error })
2205
2360
  const s = createServer({ dist: fileURLToPath(new URL('.', import.meta.url)) })
2206
2361
  const fd = ENV.BRAIN_LISTEN_FD
2207
2362
  const where = fd ? { fd: Number(fd) } : { port: +(ENV.PORT || 3000), host: ENV.HOST || '127.0.0.1' }