@christianirack/brainllm-runtime 0.8.2 → 0.8.4

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
@@ -264,6 +264,9 @@ const fin = G.FinalizationRegistry ? new FinalizationRegistry(w => (w.dead = 1))
264
264
  // Where a pending request went, without origin or query: evidence for a fix, never a value.
265
265
  const pendingUrls = () => [...inflight.values()].slice(0, 3).map(r => `${r.m} ${r.url.replace(/^[a-z]+:\/\/[^/]+/i, '').split(/[?#]/)[0]}`)
266
266
  const SETTLE_MS = 60000
267
+ // While a suite holds the verdict, an instance only ages from its release: a promise or a script load that begins after
268
+ // the unmount is invisible to the network watch and keeps the instance alive for a bounded time.
269
+ let held_verdict = false
267
270
  function watch(d) {
268
271
  if (!fin) return
269
272
  // 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 +282,13 @@ function checkLeaks() {
279
282
  for (let j = watched.length - 1; j >= 0; j--) {
280
283
  const w = watched[j]
281
284
  if (w.dead) watched.splice(j, 1)
285
+ else if (held_verdict) w.at = gcs
282
286
  // A request from before the unmount may be what holds it: judge after it settles (bounded, for a long poll).
283
287
  else if (w.net.some(id => inflight.has(id)) && now() - w.since < SETTLE_MS) w.at = gcs
284
288
  else if (gcs - w.at >= 2) {
285
289
  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; ` : ''
290
+ const finished = !w.net.some(id => inflight.has(id))
291
+ 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
292
  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
293
  }
289
294
  }
@@ -708,7 +713,7 @@ export function inspect(o) {
708
713
  G.document.addEventListener.call(G.document, 'keydown', e => e.altKey && e.shiftKey && e.code === 'KeyB' && toggle())
709
714
  return api
710
715
  }
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() } }
716
+ 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
717
 
713
718
  // ---------- mini panel (Alt+Shift+B): heap, leaks and link to the full debugger (§9.1) ----------
714
719
  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.4",
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) }
@@ -531,7 +550,7 @@ export function nativeAuthBridge(raw, refused, serviceKey, report) {
531
550
  req.method = method
532
551
  req.$brainForwardedFor = forwardedFor(incoming)
533
552
  // 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) }
553
+ req.headers = { ...(serviceKey ? { authorization: 'Bearer ' + serviceKey } : ownSession ? {} : { cookie: cookies(incoming, false) }), accept: 'application/json', 'content-type':'application/json', 'content-length':String(bytes.length) }
535
554
  if (method !== 'GET') {
536
555
  req.headers.origin = bridgeOrigin(incoming, rid, 'Native writes require a same-origin server request')
537
556
  }
@@ -558,6 +577,15 @@ export function nativeAuthBridge(raw, refused, serviceKey, report) {
558
577
  if (!String(headers['content-type'] || '').startsWith('application/json')) throw new HttpError(502, 'Native resource response must be JSON')
559
578
  try { value = JSON.parse(data.toString('utf8')) } catch { throw new HttpError(502, 'Native resource response is invalid') }
560
579
  }, 2 * 1024 * 1024)
580
+ } catch (e) {
581
+ // A read that failed: `load` may catch it and render anyway, so the page answer says what happened (ssr below).
582
+ if (method === 'GET' && !(e?.status >= 400 && e.status < 500)) {
583
+ const seen = incoming.$brainFailedReads || (incoming.$brainFailedReads = { reads: 0, backendCode: undefined, transport: undefined })
584
+ seen.reads++
585
+ seen.backendCode ??= e?.code
586
+ seen.transport ??= e?.transport
587
+ }
588
+ throw e
561
589
  } finally { req.destroy() }
562
590
  if (response.destroyed || response.writableEnded) throw new HttpError(409, 'The server request has ended')
563
591
  return value
@@ -796,18 +824,22 @@ export function createServer(o = {}) {
796
824
  // ctx.env: what the supervisor passed, read-only; the service key stays with ctx.backend.service
797
825
  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
826
  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
827
  const backendSession = o.backendSession ?? env.BRAIN_BACKEND_SESSION
802
828
  if (backendSession && backendSession !== 'app') throw Error("BRAIN_BACKEND_SESSION must be app, or unset for the backend's own session")
829
+ const native = nativeAuthBridge(o.backendURL ?? env.BRAIN_BACKEND_URL, (...why) => originRefused(...why), serviceKey, (...entry) => log(...entry), backendSession === 'app')
830
+ // Using the native backend does not have to mean it owns the visitor's session: `app` keeps the signed cookie.
803
831
  const nativeSession = native && backendSession !== 'app'
804
832
  if (o.nativeSockets !== undefined && typeof o.nativeSockets !== 'boolean') throw Error('nativeSockets must be an explicit boolean')
805
833
  const SITEMAP_BYTES = +(o.sitemapBytes ?? 50 * 1024 * 1024) // protocol: 50 MB uncompressed per sitemap file
806
834
  const secret = o.secret ?? env.BRAIN_SECRET ?? ''
807
835
  const COMPRESS = o.compress !== false && env.BRAIN_COMPRESS !== 'off' // a proxy that already compresses can opt out
808
836
  const sink = o.log || ((level, msg, extra) => console[level === 'error' ? 'error' : 'log'](json({ t: new Date().toISOString(), level, msg, ...extra })))
837
+ // Requests in flight by rid: a 5xx whose cause reached no log line gets one when the response closes (dispatch below).
838
+ const liveRequests = new Map()
839
+ let statusPageWindow = 0, statusPageLogs = 0
809
840
  const log = (level, msg, extra = {}) => {
810
841
  const safe = { ...extra }
842
+ if (level !== 'info' && extra.rid) { const live = liveRequests.get(extra.rid); if (live) live.logged = true }
811
843
  // Error messages may contain credentials or request data. Retain only bounded V8 source frames.
812
844
  if (typeof safe.stack === 'string') {
813
845
  const kind = /^(TypeError|ReferenceError|RangeError|SyntaxError|AggregateError|Error):/.exec(safe.stack)?.[1] || 'Error'
@@ -843,6 +875,9 @@ export function createServer(o = {}) {
843
875
  // log fields for a thrown value: its stack (sanitized by log) plus, for B09xx budget failures, the typed fields only
844
876
  const trace = e => {
845
877
  const out = { stack: String((e && e.stack) || e) }
878
+ if (e instanceof HttpError && OWN_CAUSES.has(e.message)) out.cause = e.message
879
+ if (e && CODE_SHAPE.test(e.transport || '')) out.transport = e.transport
880
+ if (e && typeof e.code === 'string' && CODE_SHAPE.test(e.code)) out.backendCode = e.code
846
881
  if (e && e.kind === 'budget' && /^B09\d\d$/.test(e.code) && typeof e.budget === 'string' && /^[a-zA-Z]{1,16}$/.test(e.budget)) {
847
882
  const str = (v, n) => typeof v === 'string' ? v.slice(0, n) : null, pos = v => Number.isSafeInteger(v) && v > 0 ? v : null
848
883
  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 +971,7 @@ export function createServer(o = {}) {
936
971
  res.req.$nativeLogout.then(() => send(res, status, body, headers, configuredExternal), () => send(res, 503, '', {}, false))
937
972
  return
938
973
  }
939
- if (native && res.req.$nativeLogoutError) { status = 503; body = ''; headers = {}; res.req.$nativeLogoutError = false }
974
+ if (native && res.req.$nativeLogoutError) { status = 503; body = ''; headers = {}; res.req.$nativeLogoutError = false; res.$brainCause = 'native sign-out failed' }
940
975
  if (native && res.req.$nativeLogoutHeaders) {
941
976
  const relay = res.req.$nativeLogoutHeaders, own = headers['set-cookie']
942
977
  headers = { ...headers, ...relay, 'set-cookie': [...(own ? Array.isArray(own) ? own : [own] : []), ...relay['set-cookie']] }
@@ -974,6 +1009,7 @@ export function createServer(o = {}) {
974
1009
  delete headers['content-length']
975
1010
  }
976
1011
  }
1012
+ if (headers.vary) headers = { ...headers, vary: varyOnce(headers.vary) }
977
1013
  res.writeHead(status, { ...SECURITY, ...(server.draining ? { connection: 'close' } : {}), ...headers })
978
1014
  res.end(res.req.method === 'HEAD' || status === 204 || status === 205 || status === 304 ? undefined : body)
979
1015
  }
@@ -995,8 +1031,8 @@ export function createServer(o = {}) {
995
1031
  const notFound = (req, res) => failPage(res, 404, res.getHeader('x-request-id'))
996
1032
  // `gone()` from load (§33): 410 tells crawlers the URL was removed for good; views/410.brain, else the 404 page
997
1033
  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'] })
1034
+ const failPage = (res, status, rid, detail) => {
1035
+ 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
1036
  const custom = (status === 404 && mf.notFound) || (mf.errors && mf.errors[status]) || (status === 410 && !file('410.html') ? mf.notFound : `${status}.html`)
1001
1037
  const path = typeof custom === 'string' && file(custom)
1002
1038
  return html(res, status, path ? readFileSync(path, 'utf8') : defaultError(status, rid, BASE))
@@ -1049,16 +1085,20 @@ export function createServer(o = {}) {
1049
1085
  const SSRC = new Map(), SSRC_MAX = 500, SSRC_BYTES = 32 * 1024 * 1024, SSRC_ENTRY = 1024 * 1024
1050
1086
  let ssrBytes = 0
1051
1087
  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 => {
1088
+ const n = +route.cache, ms = n * 1000, key = (route.locale || '') + url.pathname + cacheSearch(url)
1089
+ const fill = () => {
1090
+ let failed = false
1091
+ return new Promise(ok => {
1055
1092
  const cap = { req, $capture: (out, h, st) => {
1056
1093
  const headers = Object.fromEntries(Object.entries(h).map(([k, v]) => [k.toLowerCase(), v]))
1057
1094
  const privateResponse = headers['set-cookie'] || headers['vary'] || /private|no-store|no-cache/i.test(headers['cache-control'] || '')
1095
+ if (st >= 500) failed = true
1058
1096
  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() {} }
1097
+ }, writeHead(st) { if (st >= 500) failed = true }, end: () => ok(null), setHeader() {}, getHeader: name => (name === 'x-request-id' ? rid : undefined), once() {}, removeListener() {} }
1060
1098
  ssr(req, cap, route, params, url, rid, null, 200, null, true).catch(() => ok(null))
1061
1099
  }).then(c => {
1100
+ // A failed revalidation keeps the copy until 2N, which is what `stale-if-error` promises downstream.
1101
+ if ((!c || !c.cacheable) && (failed || req.$brainFailedReads) && SSRC.has(key)) return c
1062
1102
  if (!c || !c.cacheable) {
1063
1103
  ssrBytes -= SSRC.get(key)?.bytes || 0
1064
1104
  SSRC.delete(key)
@@ -1076,7 +1116,8 @@ export function createServer(o = {}) {
1076
1116
  }
1077
1117
  return c
1078
1118
  })
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)
1119
+ }
1120
+ 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
1121
  let c = SSRC.get(key)
1081
1122
  const age = c ? Date.now() - c.t : Infinity
1082
1123
  if (c && age < 2 * ms) {
@@ -1153,6 +1194,10 @@ export function createServer(o = {}) {
1153
1194
  return loadOutcome(e)
1154
1195
  }
1155
1196
  if (props && (props.redirect || props.notFound === true)) return loadOutcome(props) // `return redirect()` / `return notFound()`
1197
+ if (req.$brainFailedReads && !fixed) {
1198
+ softFailure(req, rid, url, status)
1199
+ extra = { ...extra, 'cache-control': 'no-store' } // what it rendered instead is not worth keeping
1200
+ }
1156
1201
  let body, seo, failed
1157
1202
  try {
1158
1203
  const at = { path: url.pathname, params: params || {}, query: Object.fromEntries(url.searchParams) } // route() (§41.5)
@@ -1181,6 +1226,11 @@ export function createServer(o = {}) {
1181
1226
  // (head, CSS, layout shell) is sent, the view when load() ends. The status and headers are out by then, so a late
1182
1227
  // redirect becomes location.replace and a late failure an inline notice with the request id (both logged); load()
1183
1228
  // cannot change the session on a streamed route.
1229
+ // `load` caught a failed backend read and rendered: the status it asked for stands, the log says what is behind it.
1230
+ const softFailure = (req, rid, url, status) => {
1231
+ const { reads, backendCode, transport } = req.$brainFailedReads
1232
+ 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' })
1233
+ }
1184
1234
  async function streamed(req, res, route, params, url, rid, ctx, extra, outcome) {
1185
1235
  const [top, bottom = ''] = readFileSync(file(route.page), 'utf8').split('<!--brain:ssr-->')
1186
1236
  const sm = await load(route.server)
@@ -1250,6 +1300,7 @@ export function createServer(o = {}) {
1250
1300
  log('error', 'load failed (streamed)', { rid, path: url.pathname, status: 200, ...trace(e) }) // headers already out
1251
1301
  return fail()
1252
1302
  }
1303
+ if (req.$brainFailedReads) softFailure(req, rid, url, 200)
1253
1304
  try {
1254
1305
  const { body, failed } = await (queue = queue.then(() => render(route, props, null, at), () => render(route, props, null, at)))
1255
1306
  for (const f of failed || []) log('error', 'component failed during SSR', { rid, path: url.pathname, comp: f.comp, where: f.w, ...trace(f.e) })
@@ -1527,9 +1578,19 @@ export function createServer(o = {}) {
1527
1578
  if (path === null) return false
1528
1579
  const md = file(mdPath(path))
1529
1580
  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' })
1581
+ send(res, 200, req.method === 'HEAD' ? '' : readFileSync(md), { 'content-type': MIME['.md'], 'cache-control': 'public, max-age=3600', vary: 'accept', ...twinCanonical(path) })
1531
1582
  return true
1532
1583
  }
1584
+ // A Markdown copy repeats its page: the header names the canonical the page itself declares. A page without one (no
1585
+ // site configured) or with a per-request pattern gets no header, so this never invents a URL.
1586
+ function twinCanonical(path) {
1587
+ const stem = path.replace(/\.md$/, '').replace(/\/+$/, '')
1588
+ const page = file(stem ? stem + '.html' : 'index.html') || file(stem + '/index.html')
1589
+ if (!page) return {}
1590
+ const tag = /<link\b[^>]*\brel=["']canonical["'][^>]*>/i.exec(readFileSync(page, 'utf8'))?.[0]
1591
+ const href = tag && /\bhref="(https?:\/\/[^"<>\s]+)"/.exec(tag)?.[1].replaceAll('&amp;', '&')
1592
+ return href && URL.canParse(href) && !/\/[:*]/.test(new URL(href).pathname) ? { link: `<${href}>; rel="canonical"` } : {}
1593
+ }
1533
1594
  function serveStatic(req, res, url) {
1534
1595
  const p = publicStaticPath(url.pathname)
1535
1596
  if (p === null) return false
@@ -1547,6 +1608,7 @@ export function createServer(o = {}) {
1547
1608
  return html(res, 200, page, md ? { vary: 'accept', 'x-brain-static': '1' } : { 'x-brain-static': '1' }, '"' + (mf.build || tag(page)) + '"'), true
1548
1609
  }
1549
1610
  const headers = {
1611
+ ...(ext === '.md' ? twinCanonical(p) : {}),
1550
1612
  'content-type': type,
1551
1613
  // the worker script must be revalidated on every check, or a CDN could keep an old worker for an hour (§35.7)
1552
1614
  'cache-control': p.startsWith('/_b/') ? 'public, max-age=31536000, immutable' : p === '/sw.js' ? 'no-cache' : 'public, max-age=3600',
@@ -1577,7 +1639,43 @@ export function createServer(o = {}) {
1577
1639
  if (req.method !== 'GET' && req.method !== 'HEAD') return notFound(req, res)
1578
1640
  if (!native) return send(res, 200, json({ ok: true }), headers)
1579
1641
  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) }
1642
+ catch (e) {
1643
+ if (!res.headersSent) {
1644
+ log('warn', 'health probe could not reach the native backend', { rid, path: url.pathname, status: 503, ...trace(e) })
1645
+ send(res, 503, json({ ok: false }), headers)
1646
+ }
1647
+ }
1648
+ }
1649
+
1650
+ const urlVariant = req => {
1651
+ if (req.method !== 'GET' && req.method !== 'HEAD') return null
1652
+ const q = req.url.indexOf('?'), raw = q < 0 ? req.url : req.url.slice(0, q)
1653
+ if (!raw.includes('//') && !raw.endsWith('/index.html')) return null
1654
+ let path = raw.replace(/\/{2,}/g, '/')
1655
+ const app = '/' + (path.startsWith(BASE) ? path.slice(BASE.length) : path.slice(1))
1656
+ if (/^\/(?:api|_brain|_r|s)(?:\/|$)/.test(app) && !raw.startsWith('//')) return null
1657
+ if (path.endsWith('/index.html')) path = path.slice(0, -'index.html'.length)
1658
+ if (path !== BASE && path !== '/' && (path.endsWith('/') && SLASH !== 'always')) path = path.replace(/\/+$/, '')
1659
+ const location = path + (q < 0 ? '' : req.url.slice(q))
1660
+ try { safeLocation(location) } catch { return null }
1661
+ return location
1662
+ }
1663
+ // `<meta seo query="page">`: `page` is a number from 1 and page 1 is the URL without it. A view decides past its last
1664
+ // page with notFound(). → null (nothing to do) · 404 · the URL to redirect to.
1665
+ const pagedRoutes = new Map()
1666
+ const pageParameter = (route, url) => {
1667
+ if (!url.searchParams.has('page') || !route.page) return null
1668
+ if (!pagedRoutes.has(route.page)) {
1669
+ const keys = /<meta name="brain-query" content="([^"]*)">/.exec(readFileSync(file(route.page), 'utf8'))?.[1]
1670
+ pagedRoutes.set(route.page, !!keys && keys.split(/[\s,]+/).includes('page'))
1671
+ }
1672
+ if (!pagedRoutes.get(route.page)) return null
1673
+ const values = url.searchParams.getAll('page')
1674
+ if (values.length > 1 || !/^[1-9]\d{0,8}$/.test(values[0])) return 404
1675
+ if (values[0] !== '1') return null
1676
+ const rest = new URLSearchParams(url.search)
1677
+ rest.delete('page')
1678
+ return url.pathname + (rest.size ? '?' + rest : '')
1581
1679
  }
1582
1680
 
1583
1681
  // ---------- dispatch ----------
@@ -1594,13 +1692,23 @@ export function createServer(o = {}) {
1594
1692
  }
1595
1693
  const rid = req.headers['x-request-id'] && /^[\w-]{1,64}$/.test(req.headers['x-request-id']) ? req.headers['x-request-id'] : randomUUID()
1596
1694
  res.setHeader('x-request-id', rid)
1695
+ const live = { logged: false }
1696
+ liveRequests.set(rid, live)
1697
+ res.once('close', () => {
1698
+ if (liveRequests.get(rid) === live) liveRequests.delete(rid)
1699
+ // The net under every path: a 5xx that was answered always leaves a line with its request id.
1700
+ 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' }) })
1701
+ })
1597
1702
  const t0 = performance.now()
1598
1703
  res.on('finish', () => {
1599
1704
  const ms = performance.now() - t0
1600
1705
  if (ms > 1000) log('warn', 'slow request', { rid, path: req.$brainPath || String(req.url || '').split('?')[0], status: res.statusCode, ms: Math.round(ms) })
1601
1706
  })
1602
1707
  try {
1603
- // `//` makes new URL throw and `//host/x` would be read as another host
1708
+ // One URL per page: `//route`, `/a//b` and `/route/index.html` answer one 301 on this origin. The path is rewritten
1709
+ // as text, before URL parsing: `//host/x` would be read as another host. Backend paths are left to the backend.
1710
+ const variant = urlVariant(req)
1711
+ if (variant) return send(res, 301, '', { location: variant, 'cache-control': 'public, max-age=3600' })
1604
1712
  if (req.url.startsWith('//')) return notFound(req, res)
1605
1713
  const full = new URL(req.url, 'http://' + (req.headers.host || 'localhost'))
1606
1714
  // /_health stays at the root: the supervisor calls it there whatever the base
@@ -1629,13 +1737,23 @@ export function createServer(o = {}) {
1629
1737
  // a status page opened directly answers its own status: `/403` is never a 200 that a crawler or a probe can trust
1630
1738
  const reserved = post ? 0 : reservedStatus(url.pathname)
1631
1739
  if (reserved === 403) return forbidden(req, res, url, rid, '')
1632
- if (reserved) return failPage(res, reserved, rid)
1740
+ if (reserved) {
1741
+ // Anyone can open it, so the note about it is bounded and is not an error.
1742
+ const minute = Math.floor(Date.now() / 60000)
1743
+ if (statusPageWindow !== minute) { statusPageWindow = minute; statusPageLogs = 0 }
1744
+ res.$brainCause = STATUS_PAGE
1745
+ res.$brainMuted = statusPageLogs++ >= 16
1746
+ return failPage(res, reserved, rid)
1747
+ }
1633
1748
  for (const route of routes) {
1634
1749
  const m = route.rx.exec(url.pathname)
1635
1750
  if (!m) continue
1636
1751
  // a form page without load is prerendered: GET serves that file (build ETag); only its POST needs the server
1637
1752
  if (!post && route.forms && serveStatic(req, res, url)) return
1638
1753
  if (!post && serveTwin(req, res, url)) return
1754
+ const paged = post ? null : pageParameter(route, url)
1755
+ if (paged === 404) return notFound(req, res)
1756
+ if (paged) return send(res, 301, '', { location: withBase(paged), 'cache-control': 'public, max-age=3600' })
1639
1757
  return await (post ? formPost : ssr)(req, res, route, m.groups ? { ...m.groups } : {}, url, rid)
1640
1758
  }
1641
1759
  if (post) return send(res, 405, 'Method not allowed', { allow: 'GET, HEAD' })
@@ -1646,9 +1764,19 @@ export function createServer(o = {}) {
1646
1764
  if (isProbe(url.pathname)) return await answerProbe(req, res, url, rid)
1647
1765
  notFound(req, res)
1648
1766
  } 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()
1767
+ const busy = e instanceof HttpError && e.code === 'BRIDGE_BUSY'
1768
+ if (busy) log('warn', 'backend bridge busy', { rid, path: req.$brainPath || String(req.url || '').split('?')[0], fix: e.fix })
1769
+ else log('error', 'unhandled', { rid, path: req.url, ...trace(e) })
1770
+ try {
1771
+ if (busy && !res.headersSent) { res.setHeader('retry-after', String(e.retryAfter)); failPage(res, 503, rid, { code: e.code, message: 'Service Unavailable', fix: e.fix }) }
1772
+ else if (!res.headersSent) failPage(res, intendedStatus(e) || 500, rid)
1773
+ else res.end()
1774
+ } catch (again) {
1775
+ // the error page itself failed: still an answer, never an unhandled rejection that takes the process down
1776
+ log('error', 'error page failed', { rid, path: req.url, ...trace(again) })
1777
+ if (!res.headersSent) res.writeHead(500, SECURITY)
1778
+ res.end()
1779
+ }
1652
1780
  }
1653
1781
  })
1654
1782
  if (native) server.once('close', () => native.close())
@@ -1660,6 +1788,7 @@ export function createServer(o = {}) {
1660
1788
  }
1661
1789
 
1662
1790
  // ---------- session (signed cookie; shared with the dev server) ----------
1791
+ const STATUS_PAGE = 'status page opened directly'
1663
1792
  const COOKIE = '__Host-session', SECRET_COOKIE = '__Host-session-secret', SECRET_MAX = 3500, DEFAULT_SESSION_SECONDS = 7 * 24 * 3600
1664
1793
  // Keys that still verify and decrypt during a rotation; new cookies always use the current key.
1665
1794
  const previousKeys = () => (process.env.BRAIN_SECRET_PREVIOUS || '').split(',').map(k => k.trim()).filter(Boolean)
@@ -2202,6 +2331,8 @@ function real(p) {
2202
2331
  }
2203
2332
  if (process.argv[1] && real(process.argv[1]) === real(fileURLToPath(import.meta.url))) {
2204
2333
  process.env.NODE_ENV = 'production'
2334
+ // A closed log pipe is the operator's matter: requests do not depend on it.
2335
+ for (const stream of [process.stdout, process.stderr]) stream.on('error', error => { if (error.code !== 'EPIPE') throw error })
2205
2336
  const s = createServer({ dist: fileURLToPath(new URL('.', import.meta.url)) })
2206
2337
  const fd = ENV.BRAIN_LISTEN_FD
2207
2338
  const where = fd ? { fd: Number(fd) } : { port: +(ENV.PORT || 3000), host: ENV.HOST || '127.0.0.1' }