@christianirack/brainllm-runtime 0.8.12 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/server/server.mjs CHANGED
@@ -24,27 +24,33 @@ import { AsyncLocalStorage } from 'node:async_hooks'
24
24
  import net from 'node:net'
25
25
  import { installNativeSockets } from './native-sockets.mjs'
26
26
  import { llmClient } from './llm.mjs'
27
+ import { createRedisCache, publicCacheRequest } from './redis-cache.mjs'
27
28
  import { BlockList, isIP } from 'node:net'
28
29
  import { Readable } from 'node:stream'
30
+ import { pipeline } from 'node:stream/promises'
29
31
  import zlib from 'node:zlib'
30
32
  import { createCipheriv, createDecipheriv, createHash, createHmac, hkdfSync, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'
31
- import { closeSync, existsSync, openSync, readFileSync, readSync, realpathSync, statSync, lstatSync } from 'node:fs'
33
+ import { constants as FS, existsSync, readFileSync, realpathSync, statSync, lstatSync } from 'node:fs'
34
+ import { open } from 'node:fs/promises'
32
35
  import * as Module from 'node:module'
33
36
  import { basename, dirname, extname, join, normalize, resolve, sep } from 'node:path'
34
37
  import { fileURLToPath, pathToFileURL } from 'node:url'
35
38
 
36
39
  const MIME = {
37
40
  '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8',
41
+ '.brain': 'text/html; charset=utf-8', '.jsbrain': 'text/javascript; charset=utf-8', '.cssbrain': 'text/css; charset=utf-8',
38
42
  '.css': 'text/css; charset=utf-8', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png',
39
43
  '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.avif': 'image/avif', '.gif': 'image/gif',
40
44
  '.ico': 'image/x-icon', '.woff2': 'font/woff2', '.woff': 'font/woff', '.txt': 'text/plain; charset=utf-8',
41
45
  '.xml': 'application/xml', '.webmanifest': 'application/manifest+json', '.map': 'application/json',
42
46
  '.mp4': 'video/mp4', '.webm': 'video/webm', '.pdf': 'application/pdf', '.wasm': 'application/wasm',
47
+ '.m3u8': 'application/vnd.apple.mpegurl', '.m3u': 'application/vnd.apple.mpegurl', '.m4s': 'video/iso.segment',
48
+ '.mpd': 'application/dash+xml', '.ts': 'video/mp2t', '.vtt': 'text/vtt; charset=utf-8', '.mp3': 'audio/mpeg',
43
49
  '.md': 'text/markdown; charset=utf-8', // llms.txt copies of the pages (§33), as brain start's static server sends them
44
50
  }
45
51
  // feeds are .xml files with their own type (SEO, §32)
46
52
  const FEEDS = { 'rss.xml': 'application/rss+xml; charset=utf-8', 'atom.xml': 'application/atom+xml; charset=utf-8' }
47
- const SECURITY = {
53
+ const DEFAULT_SECURITY = {
48
54
  'strict-transport-security': 'max-age=63072000; includeSubDomains',
49
55
  'x-content-type-options': 'nosniff',
50
56
  'referrer-policy': 'strict-origin-when-cross-origin',
@@ -82,6 +88,8 @@ export class HttpError extends Error {
82
88
  Object.assign(this, extra)
83
89
  }
84
90
  }
91
+ // Static local input fields remain diagnostic metadata; their existing HTTP status is unchanged.
92
+ const nativeInput = (message, fields = { input: message }) => new HttpError(400, message, { code: 'INVALID_INPUT', fields })
85
93
  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' })
86
94
  // Plain deliberate 4xx signals are portable from server-only application modules; exception messages stay private.
87
95
  function intendedStatus(error) {
@@ -114,8 +122,10 @@ function safeLocation(value) {
114
122
  function linkTo(route, path) {
115
123
  const t = route && route.hrefs
116
124
  if (!t || typeof path !== 'string' || path[0] !== '/' || path[1] === '/') return path
117
- const m = /^[^?#]*/.exec(path)[0]
118
- const n = t.s[m] || (new RegExp(t.re).test(m) && (m === '/' ? t.p : t.p + m))
125
+ const m = /^[^?#]*/.exec(path)[0], key = m.replace(/\/+$/, '') || '/'
126
+ if (key === t.p || key.startsWith(t.p + '/')) return path
127
+ let n = t.s[key] || (new RegExp(t.re).test(key) && (key === '/' ? t.p : t.p + key))
128
+ if (n && key !== '/' && m.endsWith('/') && !n.endsWith('/')) n += '/'
119
129
  return n ? n + path.slice(m.length) : path
120
130
  }
121
131
  function localizeLinks(route, html) {
@@ -155,9 +165,19 @@ function wantsHtml(req) {
155
165
  const quality = mediaQuality(accept, 'text/html')
156
166
  return quality > 0 && quality >= mediaQuality(accept, 'application/json')
157
167
  }
158
- function defaultError(status, rid, home = '/') {
159
- const title = STATUS_CODES[status] || 'Request failed'
160
- return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex,nofollow"><title>${status} — ${esc(title)}</title></head><body><main><h1>${status} — ${esc(title)}</h1><p>The request could not be completed. Try again or contact support with this reference.</p><p>Reference: <code>${esc(rid)}</code></p><a href="${esc(home)}">Return home</a></main></body></html>`
168
+ const ERROR_CSS = ':root{color-scheme:light dark}body{margin:0;padding:clamp(1rem,5vw,4rem);font:1rem/1.6 system-ui,sans-serif;background:Canvas;color:CanvasText}main{max-width:42rem;margin:8vh auto;padding:clamp(1rem,4vw,2rem);border:1px solid GrayText;border-radius:.75rem}h1{font-size:clamp(1.4rem,4vw,2rem);line-height:1.2}code{overflow-wrap:anywhere}a{color:LinkText;text-underline-offset:.2em}a:focus-visible{outline:2px solid Highlight;outline-offset:4px}@media print{body{padding:0}main{margin:0;border:0}}@media(forced-colors:active){main{border-color:CanvasText}}'
169
+ function errorWords(locale = 'en', dictionary = {}) {
170
+ const selected = key => typeof dictionary[key] === 'string' && dictionary[key].length > 0 && dictionary[key].length <= 2048 ? dictionary[key] : null
171
+ return (key, fallback) => ({ text: selected('http.error.' + key) || fallback, lang: selected('http.error.' + key) || locale === 'en' ? '' : ' lang="en"' })
172
+ }
173
+ function defaultError(status, rid, home = '/', options = {}) {
174
+ const locale = typeof options.locale === 'string' && /^[a-z0-9]+(?:-[a-z0-9]+)*$/i.test(options.locale) && options.locale.length <= 35 ? options.locale : 'en'
175
+ const word = errorWords(locale, options.dictionary), title = word('title', STATUS_CODES[status] || 'Request failed')
176
+ const message = word('message', 'The request could not be completed. Try again or contact support with this reference.')
177
+ const reference = word('reference', 'Reference'), back = word('home', 'Return home')
178
+ const branding = options.branding === false ? '' : '<!-- by BRAINLLM https://chrisifm.github.io/BRAINLLM/ -->'
179
+ const direction = /^(?:ar|he|fa|ur)(?:-|$)/i.test(locale) ? ' dir="rtl"' : ''
180
+ return `<!doctype html><html lang="${esc(locale)}"${direction}><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex,nofollow"><title${title.lang}>${status} — ${esc(title.text)}</title><link rel="stylesheet" href="${esc(home)}_brain/error.css">${branding}</head><body><main><h1${title.lang}>${status} — ${esc(title.text)}</h1><p${message.lang}>${esc(message.text)}</p><p><span${reference.lang}>${esc(reference.text)}:</span> <code>${esc(rid)}</code></p><a${back.lang} href="${esc(home)}">${esc(back.text)}</a></main></body></html>`
161
181
  }
162
182
 
163
183
  // Coding weights are transport preferences, not substring matches (br;q=0 explicitly forbids Brotli). Ties keep the
@@ -194,7 +214,9 @@ const cacheSearch = url => {
194
214
  return kept.size ? '?' + kept : ''
195
215
  }
196
216
  // Messages this file writes itself, safe to log as the cause; anything else in an error message may hold private data.
197
- const OWN_CAUSES = new Set(['Backend request unavailable', 'Backend request timed out', 'Backend response exceeds its byte budget', 'Backend request capacity is unavailable', 'Backend stream capacity is unavailable', 'Native file request failed', 'Native file response is invalid', 'Native resource response must be JSON', 'Native resource response is invalid', 'Native resource request failed', 'Invalid final response status', 'Invalid configured redirect location', 'timeout'])
217
+ const AUTH_UNAVAILABLE_CAUSES = new Set(['the backend did not answer', 'the server is shutting down', '8 session checks are already in flight', 'the backend took more than 2 s'].map(why => `Native authentication is unavailable: ${why}`))
218
+ const BACKEND_UNAVAILABLE_CAUSES = new Set(['Backend request unavailable', 'Backend request timed out', 'Backend response exceeds its byte budget', 'Native backend is unavailable', 'Native file request failed', 'Native file response is invalid', 'Native resource response must be JSON', 'Native resource response is invalid', 'Native resource request failed', 'Native authentication response is unavailable', ...AUTH_UNAVAILABLE_CAUSES])
219
+ const OWN_CAUSES = new Set([...BACKEND_UNAVAILABLE_CAUSES, 'Backend request capacity is unavailable', 'Backend stream capacity is unavailable', 'Invalid same-site redirect location', 'Invalid redirect status', 'Invalid final response status', 'Invalid configured redirect location', 'Static file validators are busy; retry shortly', 'Static file validator deadline exceeded; retry shortly', 'Static file changed; retry shortly', 'Static file changed while computing its validator', 'timeout'])
198
220
  const CODE_SHAPE = /^[A-Z][A-Z0-9_]{0,63}$/
199
221
  const varyEncoding = v => (/accept-encoding/i.test(v || '') ? v : [v, 'Accept-Encoding'].filter(Boolean).join(', '))
200
222
  function bodyEncoding(req, status, headers) {
@@ -239,11 +261,19 @@ function googleSignInPage(page) {
239
261
 
240
262
  // One-use provider cookies (Google nonce, Apple nonce, LinkedIn state): relayed to the backend and cleared at logout.
241
263
  const PROVIDER_COOKIES = ['__Host-brain-google', '__Host-brain-apple', '__Host-brain-linkedin']
264
+ // The native backend owns state, nonce and provider configuration. Only this fixed protocol destination is external.
265
+ function providerStartRedirect(method, path, status, location) {
266
+ if (!['GET', 'HEAD'].includes(method) || path !== '/_brain/auth/linkedin/start' || status !== 303 || typeof location !== 'string' || /[\\\x00-\x20\x7f]/.test(location)) return false
267
+ try {
268
+ const target = new URL(location)
269
+ return target.origin === 'https://www.linkedin.com' && target.pathname === '/oauth/v2/authorization' && !target.username && !target.password && !target.hash
270
+ } catch { return false }
271
+ }
242
272
  // One configured loopback authority. No user-controlled target, proxy, redirect, auth cache or pending queue.
243
273
  // ctx.backend.files(bucket): the same file calls as job and route handlers (docs/backend/files.md).
244
274
  function bucketClient(target, key, bucket, readBudgetMs = () => 0) {
245
275
  const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i
246
- if (typeof bucket !== 'string' || !/^[a-z][a-z0-9_]{0,47}$/.test(bucket)) throw new HttpError(400, 'Invalid native bucket name')
276
+ if (typeof bucket !== 'string' || !/^[a-z][a-z0-9_]{0,47}$/.test(bucket)) throw nativeInput('Invalid native bucket name', { bucket: 'Invalid native bucket name' })
247
277
  const base = '/_brain/files/' + bucket
248
278
  const idPath = id => { if (typeof id !== 'string' || !UUID.test(id)) throw new HttpError(404, 'No file has this id; file ids are UUIDs'); return base + '/' + id }
249
279
  // A read waits for a draining or restarting backend like ctx.backend reads do (nativeAuthBridge); a write never repeats.
@@ -270,15 +300,15 @@ function bucketClient(target, key, bucket, readBudgetMs = () => 0) {
270
300
  const json = async response => { try { return await response.json() } catch { throw new HttpError(502, 'Native file response is invalid') } }
271
301
  return Object.freeze({
272
302
  async list(options = {}) {
273
- if (!options || typeof options !== 'object' || Array.isArray(options) || Object.keys(options).some(k => !['limit', 'offset', 'record'].includes(k))) throw new HttpError(400, 'Invalid native file list options')
303
+ if (!options || typeof options !== 'object' || Array.isArray(options) || Object.keys(options).some(k => !['limit', 'offset', 'record'].includes(k))) throw nativeInput('Invalid native file list options', { options: 'Invalid native file list options' })
274
304
  const { limit, offset, record } = options
275
- if ((limit !== undefined && (!Number.isInteger(limit) || limit < 1 || limit > 100)) || (offset !== undefined && (!Number.isInteger(offset) || offset < 0 || offset > 10000)) || (record !== undefined && !UUID.test(String(record)))) throw new HttpError(400, 'Invalid native file list options')
305
+ if ((limit !== undefined && (!Number.isInteger(limit) || limit < 1 || limit > 100)) || (offset !== undefined && (!Number.isInteger(offset) || offset < 0 || offset > 10000)) || (record !== undefined && !UUID.test(String(record)))) throw nativeInput('Invalid native file list options', { options: 'Invalid native file list options' })
276
306
  const q = new URLSearchParams(Object.entries({ limit, offset, record }).filter(([, v]) => v !== undefined))
277
307
  return json(await send('GET', base + (q.size ? '?' + q : ''), undefined, 10000))
278
308
  },
279
309
  async meta(id) { return json(await send('GET', idPath(id) + '/meta', undefined, 10000)) },
280
310
  async read(id, { maxBytes = 16 * 1024 * 1024 } = {}) {
281
- if (!Number.isInteger(maxBytes) || maxBytes < 1 || maxBytes > 64 * 1024 * 1024) throw new HttpError(400, 'Invalid native file read limit')
311
+ if (!Number.isInteger(maxBytes) || maxBytes < 1 || maxBytes > 64 * 1024 * 1024) throw nativeInput('Invalid native file read limit', { maxBytes: 'Invalid native file read limit' })
282
312
  const response = await send('GET', idPath(id), undefined, 30000)
283
313
  if (Number(response.headers.get('content-length')) > maxBytes) { await response.body?.cancel(); throw new HttpError(413, 'The file exceeds the read limit; raise maxBytes up to 64 MiB') }
284
314
  const bytes = Buffer.from(await response.arrayBuffer())
@@ -286,8 +316,8 @@ function bucketClient(target, key, bucket, readBudgetMs = () => 0) {
286
316
  return { bytes, contentType: response.headers.get('content-type'), etag: response.headers.get('etag') }
287
317
  },
288
318
  async upload({ filename, contentType = 'application/octet-stream', data, record } = {}) {
289
- if (typeof filename !== 'string' || !filename || Buffer.byteLength(filename) > 255 || typeof contentType !== 'string' || (record !== undefined && !UUID.test(String(record)))) throw new HttpError(400, 'Invalid native file upload')
290
- if (typeof data !== 'string' && !(data instanceof Uint8Array)) throw new HttpError(400, 'File data must be a string, Buffer or Uint8Array')
319
+ if (typeof filename !== 'string' || !filename || Buffer.byteLength(filename) > 255 || typeof contentType !== 'string' || (record !== undefined && !UUID.test(String(record)))) throw nativeInput('Invalid native file upload', { file: 'Invalid native file upload' })
320
+ if (typeof data !== 'string' && !(data instanceof Uint8Array)) throw nativeInput('File data must be a string, Buffer or Uint8Array', { data: 'File data must be a string, Buffer or Uint8Array' })
291
321
  const form = new FormData()
292
322
  form.set('file', new Blob([data], { type: contentType }), filename)
293
323
  return json(await send('POST', base + (record === undefined ? '' : '?record=' + record), form, 60000))
@@ -341,7 +371,15 @@ export function nativeAuthBridge(raw, refused, serviceKey, report, ownSession, r
341
371
  }
342
372
  const safeUser = user => {
343
373
  if (!user || typeof user.id !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(user.id) || typeof user.email !== 'string' || user.email.length > 254 || typeof user.role !== 'string' || !/^[a-z][a-z0-9_]{0,47}$/.test(user.role) || ['public','owner'].includes(user.role) || typeof user.verified !== 'boolean') throw new HttpError(503,'Native authentication response is unavailable')
344
- return Object.freeze({id:user.id,email:user.email,role:user.role,verified:user.verified})
374
+ const clean = {id:user.id,email:user.email,role:user.role,verified:user.verified}
375
+ if (typeof user.name === 'string' && user.name.trim() && user.name.length <= 400 && [...user.name].length <= 200 && !/[\u0000-\u001f\u007f-\u009f]/.test(user.name)) clean.name = user.name.trim()
376
+ if (typeof user.picture === 'string' && Buffer.byteLength(user.picture) <= 2048 && !/[\u0000-\u001f\u007f-\u009f]/.test(user.picture)) {
377
+ try {
378
+ const url = new URL(user.picture)
379
+ if (url.protocol === 'https:' && url.hostname && !url.username && !url.password) clean.picture = user.picture
380
+ } catch {}
381
+ }
382
+ return Object.freeze(clean)
345
383
  }
346
384
  const unavailable = (why = 'the backend did not answer') => new HttpError(503, `Native authentication is unavailable: ${why}`)
347
385
  const call = (incoming, response, path, method, rid, origin) => {
@@ -445,6 +483,16 @@ export function nativeAuthBridge(raw, refused, serviceKey, report, ownSession, r
445
483
  return typeof prior === 'string' && prior.length <= 4096 ? prior + ', ' + peer : peer
446
484
  }
447
485
  const requestHeaders = (req, rid, extra = []) => {
486
+ // Node combines or discards raw duplicates. Single-value verification and credential fields must stay unambiguous.
487
+ // Explicit verification headers are single-valued even when the ordinary header has list syntax.
488
+ const single = new Set(['content-type','authorization','x-api-key',...extra.map(name => String(name).toLowerCase())]), seen = new Set()
489
+ const rawHeaders = Array.isArray(req.rawHeaders) ? req.rawHeaders : []
490
+ for (let at = 0; at < rawHeaders.length; at += 2) {
491
+ const name = rawHeaders[at].toLowerCase()
492
+ if (!single.has(name)) continue
493
+ if (seen.has(name)) throw new HttpError(400, 'Send each declared or single-value credential header once')
494
+ seen.add(name)
495
+ }
448
496
  const headers = { 'x-request-id':rid }
449
497
  for (const key of [...REQUEST_HEADERS, ...extra]) {
450
498
  const value = req.headers[key]
@@ -512,20 +560,32 @@ export function nativeAuthBridge(raw, refused, serviceKey, report, ownSession, r
512
560
  })
513
561
  }
514
562
  function proxy(req, res, url, rid, reply, responseLimit = 20 * 1024 * 1024, declared) {
563
+ if (req.$brainWarmupSignal?.aborted) throw new HttpError(503, 'Backend request unavailable')
515
564
  if (stopped || active >= 8) throw bridgeBusy('Backend request capacity is unavailable')
516
565
  if (!['GET','HEAD','POST','PUT','PATCH','DELETE','OPTIONS'].includes(req.method)) throw new HttpError(405, 'Method not allowed')
517
566
  const post = req.method === 'POST'
518
- const limit = url.pathname === '/_brain/uploads' && post ? 5 * 1024 * 1024 + 65536 : declared || post && /^\/api\/[^/]+\/batch$/.test(url.pathname) ? 1024 * 1024 : 65536
567
+ const vitals = url.pathname === '/_brain/vitals'
568
+ const limit = vitals ? 4096 : url.pathname === '/_brain/uploads' && post ? 5 * 1024 * 1024 + 65536 : declared || post && /^\/api\/[^/]+\/batch$/.test(url.pathname) ? 1024 * 1024 : 65536
519
569
  if (Number(req.headers['content-length'] || 0) > limit) throw new HttpError(413, 'Request body is too large')
570
+ // Anonymous performance never carries credentials, identity, referrer or arbitrary headers.
571
+ const headers = vitals ? (() => {
572
+ const result = {}
573
+ for (const name of ['origin','content-type','content-length','dnt','sec-gpc']) {
574
+ if ((req.rawHeaders || []).filter((_, at) => at % 2 === 0 && req.rawHeaders[at].toLowerCase() === name).length > 1) throw new HttpError(400, 'Send each collector header once')
575
+ const value = req.headers[name]
576
+ if (typeof value === 'string' && value.length <= 8192) result[name] = value
577
+ }
578
+ return result
579
+ })() : requestHeaders(req, rid, declared?.headers)
520
580
  active++
521
581
  return new Promise((resolve, reject) => {
522
582
  let outgoing, timer, done = false, bytes = 0, requestBytes = 0, failure, transport
523
583
  const chunks = []
524
- const headers = requestHeaders(req, rid, declared?.headers)
525
584
  const finish = (error, backend) => {
526
585
  if (done) return
527
586
  done = true; clearTimeout(timer); active--; proxyBytes -= bytes; owned.delete(outgoing)
528
587
  req.removeListener('aborted',cancel); req.removeListener('data',data); req.removeListener('end',end)
588
+ req.$brainWarmupSignal?.removeEventListener('abort',cancel)
529
589
  res.removeListener('close',cancel)
530
590
  if (error) { if (!res.destroyed) reject(failure || new HttpError(503,'Backend request unavailable',{transport})); else resolve(); return }
531
591
  const safe = { 'cache-control':'no-store' }
@@ -537,10 +597,10 @@ export function nativeAuthBridge(raw, refused, serviceKey, report, ownSession, r
537
597
  if (declared) for (const [key, value] of Object.entries(backend.headers)) {
538
598
  if ((key === 'link' || key === 'content-language' || key.startsWith('x-') && key !== 'x-request-id') && typeof value === 'string' && value.length <= 8192) safe[key] = value
539
599
  }
540
- const cookies = nativeCookies(backend.headers['set-cookie'])
600
+ const cookies = vitals ? [] : nativeCookies(backend.headers['set-cookie'])
541
601
  if (cookies.length) safe['set-cookie'] = cookies
542
602
  if (backend.statusCode >= 500 && !res.$capture) res.$brainCause = 'answered by the native backend'
543
- try { reply(res,backend.statusCode || 502,req.method === 'HEAD' ? '' : Buffer.concat(chunks,bytes),safe); resolve() } catch (e) { reject(e) }
603
+ try { reply(res,backend.statusCode || 502,req.method === 'HEAD' ? '' : Buffer.concat(chunks,bytes),safe,providerStartRedirect(req.method,url.pathname,backend.statusCode,safe.location)); resolve() } catch (e) { reject(e) }
544
604
  }
545
605
  const cancel = () => { outgoing?.destroy(); finish(true) }
546
606
  const data = chunk => {
@@ -559,8 +619,9 @@ export function nativeAuthBridge(raw, refused, serviceKey, report, ownSession, r
559
619
  backend.once('end',()=>finish(false,backend)); backend.once('error',e=>{transport=e.code;finish(true)}); backend.once('aborted',()=>{transport='BACKEND_ABORTED';finish(true)})
560
620
  })
561
621
  owned.add(outgoing)
562
- 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)
622
+ timer = setTimeout(()=>{failure = new HttpError(504,'Backend request timed out');outgoing.destroy();finish(true)},vitals ? 5000 : declared || url.pathname === '/_brain/uploads' || url.pathname.startsWith('/_brain/uploads/') ? 35000 : 10000)
563
623
  outgoing.once('error',e=>{transport=e.code;finish(true)}); req.once('aborted',cancel); res.once('close',cancel)
624
+ req.$brainWarmupSignal?.addEventListener('abort',cancel,{once:true})
564
625
  req.on('data',data); req.once('end',end)
565
626
  if (req.readableEnded) end()
566
627
  } catch { outgoing?.destroy(); finish(true) }
@@ -583,7 +644,7 @@ export function nativeAuthBridge(raw, refused, serviceKey, report, ownSession, r
583
644
  // Reaches the request helper of a resource client without adding a method anyone can see on it.
584
645
  const OPERATION_CALL = Symbol('operation')
585
646
  const resource = (incoming, response, rid, name, serviceKey) => {
586
- if (typeof name !== 'string' || !/^[a-z][a-z0-9_]{0,47}$/.test(name)) throw new HttpError(400, 'Invalid native resource name')
647
+ if (typeof name !== 'string' || !/^[a-z][a-z0-9_]{0,47}$/.test(name)) throw nativeInput('Invalid native resource name', { resource: 'Invalid native resource name' })
587
648
  const idPath = id => {
588
649
  if (typeof id !== 'string' || !/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(id)) throw new HttpError(404, 'No record has this id; record ids are UUIDs')
589
650
  return '/api/' + name + '/' + id
@@ -593,13 +654,14 @@ export function nativeAuthBridge(raw, refused, serviceKey, report, ownSession, r
593
654
  if (method !== 'GET' && incoming.method !== 'POST') throw new HttpError(405, 'Native writes require a server function called through POST')
594
655
  let bytes = Buffer.alloc(0)
595
656
  if (body !== undefined) {
596
- if (!body || typeof body !== 'object' || Array.isArray(body)) throw new HttpError(400, 'Native resource values must be an object')
597
- try { bytes = Buffer.from(JSON.stringify(body)) } catch { throw new HttpError(400, 'Native resource values must be JSON serializable') }
657
+ if (!body || typeof body !== 'object' || Array.isArray(body)) throw nativeInput('Native resource values must be an object', { body: 'Native resource values must be an object' })
658
+ try { bytes = Buffer.from(JSON.stringify(body)) } catch { throw nativeInput('Native resource values must be JSON serializable', { body: 'Native resource values must be JSON serializable' }) }
598
659
  if (bytes.length > 65536) throw new HttpError(413, 'Native resource values exceed their byte budget')
599
660
  }
600
661
  const outgoing = () => {
601
662
  const req = Readable.from(bytes.length ? [bytes] : [])
602
663
  req.method = method
664
+ req.$brainWarmupSignal = incoming.$brainWarmupSignal
603
665
  req.$brainForwardedFor = forwardedFor(incoming)
604
666
  // A service call acts as the key's owner, never as the visitor: it sends no session.
605
667
  req.headers = { ...(serviceKey ? { authorization: 'Bearer ' + serviceKey } : ownSession ? {} : { cookie: cookies(incoming, false) }), accept: 'application/json', 'content-type':'application/json', 'content-length':String(bytes.length) }
@@ -660,7 +722,7 @@ export function nativeAuthBridge(raw, refused, serviceKey, report, ownSession, r
660
722
  } catch (e) {
661
723
  const transient = ['ECONNREFUSED', 'ECONNRESET'].includes(e?.transport) || askAgain.has(e)
662
724
  const pause = Math.max(wait, (e?.retryAfter || 0) * 1000)
663
- if (method !== 'GET' || !transient || stopped || response.destroyed || Date.now() + pause > end) throw e
725
+ if (method !== 'GET' || !transient || stopped || incoming.$brainWarmupSignal || response.destroyed || Date.now() + pause > end) throw e
664
726
  req.destroy()
665
727
  await new Promise(done => setTimeout(done, pause))
666
728
  if (stopped || response.destroyed) throw e
@@ -686,7 +748,7 @@ export function nativeAuthBridge(raw, refused, serviceKey, report, ownSession, r
686
748
  const names = (value, label) => {
687
749
  const list = typeof value === 'string' ? value.split(',') : value
688
750
  const shape = label === 'include' ? /^[a-z][a-z0-9_]{0,47}(?:\.[a-z][a-z0-9_]{0,47}){0,2}$/ : /^[a-z][a-z0-9_]{0,47}$/
689
- if (!Array.isArray(list) || list.length > 16 || list.some(item => typeof item !== 'string' || !shape.test(item))) throw new HttpError(400, `Invalid native ${label} names`)
751
+ if (!Array.isArray(list) || list.length > 16 || list.some(item => typeof item !== 'string' || !shape.test(item))) throw nativeInput(`Invalid native ${label} names`, { [label]: `Use at most 16 valid ${label} names` })
690
752
  return list.join(',')
691
753
  }
692
754
  return Object.freeze({
@@ -694,30 +756,34 @@ export function nativeAuthBridge(raw, refused, serviceKey, report, ownSession, r
694
756
  // the HTTP list contract (docs/backend/filters.md, sorting.md, pagination.md): the backend validates declared
695
757
  // filters and sorts and answers 422 {errors}; this only rejects shapes the query string cannot express
696
758
  list(options = {}) {
697
- if (!options || typeof options !== 'object' || Array.isArray(options) || Object.keys(options).some(key => !LIST_OPTIONS.includes(key))) throw new HttpError(400, 'Invalid native list options')
759
+ if (!options || typeof options !== 'object' || Array.isArray(options) || Object.keys(options).some(key => !LIST_OPTIONS.includes(key))) throw nativeInput('Invalid native list options', { options: 'Invalid native list options' })
698
760
  const {search, limit, offset, filter, sort, cursor, after, include, aggregate, count, fields} = options
699
- if ((search !== undefined && (typeof search !== 'string' || Buffer.byteLength(search) > 256)) || (limit !== undefined && (!Number.isInteger(limit) || limit < 1 || limit > 100)) || (offset !== undefined && (!Number.isInteger(offset) || offset < 0 || offset > 10000))) throw new HttpError(400, 'Invalid native list pagination or search')
761
+ const pagination = {}
762
+ if (search !== undefined && (typeof search !== 'string' || Buffer.byteLength(search) > 256)) pagination.search = 'Use text of at most 256 bytes'
763
+ if (limit !== undefined && (!Number.isInteger(limit) || limit < 1 || limit > 100)) pagination.limit = 'Use a whole number from 1 to 100'
764
+ if (offset !== undefined && (!Number.isInteger(offset) || offset < 0 || offset > 10000)) pagination.offset = 'Use a whole number from 0 to 10000'
765
+ if (Object.keys(pagination).length) throw nativeInput('Invalid native list pagination or search', pagination)
700
766
  const q = new URLSearchParams()
701
767
  if (filter !== undefined) {
702
- if (!filter || typeof filter !== 'object' || Array.isArray(filter) || Object.keys(filter).length > 32) throw new HttpError(400, 'Invalid native list filter')
768
+ if (!filter || typeof filter !== 'object' || Array.isArray(filter) || Object.keys(filter).length > 32) throw nativeInput('Invalid native list filter', { filter: 'Invalid native list filter' })
703
769
  const scalar = value => {
704
770
  if (typeof value === 'number' && Number.isFinite(value)) return String(value)
705
771
  if (typeof value === 'boolean') return String(value)
706
772
  if (typeof value === 'string' && Buffer.byteLength(value) <= 256) return value
707
- throw new HttpError(400, 'Invalid native filter value')
773
+ throw nativeInput('Invalid native filter value', { filter: 'Invalid native filter value' })
708
774
  }
709
775
  for (const [field, condition] of Object.entries(filter)) {
710
- if (!/^[a-z][a-z0-9_]{0,47}(?:\.[a-z][a-z0-9_]{0,47})?$/.test(field)) throw new HttpError(400, 'Invalid native list filter field')
776
+ if (!/^[a-z][a-z0-9_]{0,47}(?:\.[a-z][a-z0-9_]{0,47})?$/.test(field)) throw nativeInput('Invalid native list filter field', { filter: 'Invalid native list filter field' })
711
777
  const ops = condition && typeof condition === 'object' && !Array.isArray(condition) ? Object.entries(condition) : [['eq', condition]]
712
- if (!ops.length) throw new HttpError(400, 'Invalid native filter value')
778
+ if (!ops.length) throw nativeInput('Invalid native filter value', { filter: 'Invalid native filter value' })
713
779
  for (const [op, value] of ops) {
714
- if (!FILTER_OPS.includes(op)) throw new HttpError(400, 'Invalid native filter operator')
780
+ if (!FILTER_OPS.includes(op)) throw nativeInput('Invalid native filter operator', { filter: 'Invalid native filter operator' })
715
781
  let raw
716
782
  if (op === 'in' || op === 'between' || op === 'has') {
717
783
  const items = Array.isArray(value) ? value : [value]
718
- if (!items.length || items.length > 50 || (op === 'between' && items.length !== 2)) throw new HttpError(400, 'Invalid native filter value')
784
+ if (!items.length || items.length > 50 || (op === 'between' && items.length !== 2)) throw nativeInput('Invalid native filter value', { filter: 'Invalid native filter value' })
719
785
  raw = items.map(scalar)
720
- if (raw.some(item => item.includes(','))) throw new HttpError(400, 'Invalid native filter value')
786
+ if (raw.some(item => item.includes(','))) throw nativeInput('Invalid native filter value', { filter: 'Invalid native filter value' })
721
787
  raw = raw.join(',')
722
788
  } else raw = scalar(value)
723
789
  q.append(op === 'eq' ? field : `${field}[${op}]`, raw)
@@ -729,24 +795,24 @@ export function nativeAuthBridge(raw, refused, serviceKey, report, ownSession, r
729
795
  if (offset !== undefined) q.set('offset', String(offset))
730
796
  if (sort !== undefined) {
731
797
  const keys = typeof sort === 'string' ? sort.split(',') : sort
732
- if (!Array.isArray(keys) || !keys.length || keys.length > 4 || keys.some(key => typeof key !== 'string' || !/^-?[a-z][a-z0-9_]{0,47}$/.test(key))) throw new HttpError(400, 'Invalid native list sort')
798
+ if (!Array.isArray(keys) || !keys.length || keys.length > 4 || keys.some(key => typeof key !== 'string' || !/^-?[a-z][a-z0-9_]{0,47}$/.test(key))) throw nativeInput('Invalid native list sort', { sort: 'Invalid native list sort' })
733
799
  q.set('sort', keys.join(','))
734
800
  }
735
- if (cursor !== undefined && after !== undefined) throw new HttpError(400, 'Use either cursor or after')
801
+ if (cursor !== undefined && after !== undefined) throw nativeInput('Use either cursor or after', { cursor: 'Use either cursor or after' })
736
802
  for (const [key, value] of [['cursor', cursor], ['after', after]]) if (value !== undefined) {
737
- if (typeof value !== 'string' || !/^[A-Za-z0-9_-]{1,2048}$/.test(value)) throw new HttpError(400, 'Invalid native list cursor')
803
+ if (typeof value !== 'string' || !/^[A-Za-z0-9_-]{1,2048}$/.test(value)) throw nativeInput('Invalid native list cursor', { cursor: 'Invalid native list cursor' })
738
804
  q.set(key, value)
739
805
  }
740
806
  for (const [key, value] of [['include', include], ['aggregate', aggregate], ['count', count], ['fields', fields]]) if (value !== undefined) q.set(key, names(value, key))
741
807
  if (options.owner !== undefined) {
742
- if (options.owner !== 'me') throw new HttpError(400, "Invalid native list owner; use 'me'")
808
+ if (options.owner !== 'me') throw nativeInput("Invalid native list owner; use 'me'", { owner: "Use me" })
743
809
  q.set('owner', 'me')
744
810
  }
745
811
  const query = q.toString()
746
812
  return request('GET', '/api/' + name + (query ? '?' + query : ''))
747
813
  },
748
814
  get(id, options = {}) {
749
- if (!options || typeof options !== 'object' || Array.isArray(options) || Object.keys(options).some(key => key !== 'include')) throw new HttpError(400, 'Invalid native get options')
815
+ if (!options || typeof options !== 'object' || Array.isArray(options) || Object.keys(options).some(key => key !== 'include')) throw nativeInput('Invalid native get options', { include: 'Invalid native get options' })
750
816
  const path = idPath(id)
751
817
  return request('GET', options.include === undefined ? path : path + '?' + new URLSearchParams({include: names(options.include, 'include')}))
752
818
  },
@@ -891,7 +957,7 @@ export function nativeAuthBridge(raw, refused, serviceKey, report, ownSession, r
891
957
  slugRedirect: (name, slug) => resource(incoming, response, rid, name).slugRedirect(slug),
892
958
  // A declared operation, as this visitor (docs/backend/transactions.md).
893
959
  operation: (name, input = {}) => {
894
- if (typeof name !== 'string' || !/^[a-z][a-z0-9_]{0,47}$/.test(name)) throw new HttpError(400, 'Invalid operation name')
960
+ if (typeof name !== 'string' || !/^[a-z][a-z0-9_]{0,47}$/.test(name)) throw nativeInput('Invalid operation name', { operation: 'Invalid operation name' })
895
961
  return resource(incoming, response, rid, 'operations')[OPERATION_CALL](name, input)
896
962
  },
897
963
  login: (credentials, options) => login(incoming, response, rid, credentials, options),
@@ -941,7 +1007,7 @@ export function nativeAuthBridge(raw, refused, serviceKey, report, ownSession, r
941
1007
  export function createServer(o = {}) {
942
1008
  const dist = resolve(o.dist || process.cwd())
943
1009
  // ctx.env: what the supervisor passed, read-only; the service key stays with ctx.backend.service
944
- 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
1010
+ const { BRAIN_SENTRY_DSN: sentryDSN, BRAIN_SENTRY_HOST: sentryHost, 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
945
1011
  const env = Object.freeze(visibleEnv)
946
1012
  const backendSession = o.backendSession ?? env.BRAIN_BACKEND_SESSION
947
1013
  if (backendSession && backendSession !== 'app') throw Error("BRAIN_BACKEND_SESSION must be app, or unset for the backend's own session")
@@ -999,7 +1065,7 @@ export function createServer(o = {}) {
999
1065
  // a thrown plain object, string or undefined has no stack: say what kind of value it was (never the value itself)
1000
1066
  if (!(e instanceof Error)) out.thrown = e === null ? 'null' : Array.isArray(e) ? 'array' : typeof e
1001
1067
  if (e && typeof e === 'object' && (e.temporary === true || e.status === 429 || e.status === 503)) out.temporary = true
1002
- if (e instanceof HttpError && (OWN_CAUSES.has(e.message) || e.message.startsWith('Native authentication is unavailable:'))) out.cause = e.message
1068
+ if (e instanceof HttpError && OWN_CAUSES.has(e.message)) out.cause = e.message
1003
1069
  if (e && e.code === 'ERR_MODULE_NOT_FOUND') out.fix = 'this process runs an older build whose files are gone; restart it after brain build'
1004
1070
  if (e && CODE_SHAPE.test(e.transport || '')) out.transport = e.transport
1005
1071
  if (e && typeof e.code === 'string' && CODE_SHAPE.test(e.code)) out.backendCode = e.code
@@ -1010,6 +1076,19 @@ export function createServer(o = {}) {
1010
1076
  return out
1011
1077
  }
1012
1078
  const mf = readManifest(dist)
1079
+ if (mf.sentry && (typeof sentryDSN !== 'string' || !sentryDSN)) throw Error('Sentry is enabled; set private BRAIN_SENTRY_DSN before starting the Node server')
1080
+ let sentryTunnel, sentryStopped = false
1081
+ const sentryReady = mf.sentry ? import('./sentry.mjs').then(({ createSentryTunnel }) => {
1082
+ sentryTunnel = createSentryTunnel({ dsn: sentryDSN, approvedHost: sentryHost, assets: mf.sentry.assets, publicOrigin: mf.site || env.PUBLIC_SITE_URL })
1083
+ if (sentryStopped) sentryTunnel.close()
1084
+ }) : null
1085
+ // A rejected startup remains observable through listen(), without an unhandled rejection before it is called.
1086
+ sentryReady?.catch(() => {})
1087
+ // The build policy controls attribution independently of CSP and other security headers.
1088
+ let compilerBranding=mf.compilerBranding!==false
1089
+ try {const path=join(dist,'brain-build.json'),meta=lstatSync(path);if(meta.isFile()&&!meta.isSymbolicLink()&&meta.size<=16384&&JSON.parse(readFileSync(path,'utf8')).compilerBranding===false)compilerBranding=false}catch{}
1090
+ const SECURITY={...DEFAULT_SECURITY}
1091
+ if(!compilerBranding)delete SECURITY['x-generated-by']
1013
1092
  // brain.json `server` (§11.1), written into the manifest by the build; createServer options and env still win
1014
1093
  const SL = mf.server || {}
1015
1094
  const TIMEOUT = +(o.timeout ?? env.BRAIN_TIMEOUT_MS ?? SL.timeoutMs ?? 10000)
@@ -1030,6 +1109,12 @@ export function createServer(o = {}) {
1030
1109
  if (nativeSockets && !native) throw Error('Native sockets require a configured native backend; fix: run `brain backend serve` and set BRAIN_BACKEND_URL to its loopback URL')
1031
1110
  // the build's single route table (#187): already in match order, each with its RegExp source (named groups = params)
1032
1111
  const routes = mf.routes.map(r => ({ ...r, rx: r.rx ? new RegExp(r.rx) : exact(r.path) }))
1112
+ const errorOptions = (req, route) => {
1113
+ route ||= routes.find(route => route.rx.test(req?.$brainPath || '/'))
1114
+ const locale = route?.locale || mf.errorFallback?.defaultLocale || 'en'
1115
+ return { locale, dictionary: mf.errorFallback?.locales?.[locale] || {}, branding: compilerBranding }
1116
+ }
1117
+ const errorDocument = (req, status, rid) => defaultError(status, rid, BASE, errorOptions(req))
1033
1118
  // The address RPC and form limits key on, ctx.req.ip and the logs: a client header counts only through a trusted proxy.
1034
1119
  const proxies = trustedProxies(o.trustProxy ?? (env.BRAIN_TRUST_PROXY ? env.BRAIN_TRUST_PROXY.split(',').map(entry => entry.trim()) : mf.trustProxy) ?? [])
1035
1120
  let proxyWarned = false
@@ -1094,6 +1179,9 @@ export function createServer(o = {}) {
1094
1179
  // Base path (§23 #148): the app lives under mf.base ("/app/"); routes, files and functions are matched on the app
1095
1180
  // path (base stripped); redirects the app gives as app paths get it back.
1096
1181
  const BASE = mf.base || '/'
1182
+ // Opt-in infrastructure credentials stay in the process environment, never in a build artifact.
1183
+ const redisCache = createRedisCache({ url: env.REDIS_URL, namespace: env.BRAIN_REDIS_NAMESPACE, build: mf.build, base: BASE })
1184
+ if (env.REDIS_URL && env.BRAIN_REDIS_NAMESPACE && !redisCache) log('warn', 'Redis cache is disabled', { code: 'REDIS_CACHE_CONFIG', fix: 'Use a valid redis/rediss URL, a 1..64 character alphanumeric namespace with colon, underscore or hyphen, and a nonempty build identity' })
1097
1185
  const analyticsTag = native && (o.analytics ?? env.BRAIN_ANALYTICS) !== 'off' && o.analytics !== false ? `<script type="module" async src="${BASE}_brain/analytics.js"></script>` : ''
1098
1186
  const RD = mf.redirects || {}, RDX = (RD.patterns || []).map(p => ({ re: new RegExp(p.re), to: p.to, status: p.status || 301 }))
1099
1187
  // `/old/` and `/old` are the same source (brain-seo normalizes the trailing slash)
@@ -1156,7 +1244,7 @@ export function createServer(o = {}) {
1156
1244
  if (!kind || kind.startsWith('text/plain')) {
1157
1245
  const rid = res.getHeader('x-request-id') || ''
1158
1246
  if (wantsHtml(res.req)) {
1159
- body = defaultError(status, rid, BASE)
1247
+ body = errorDocument(res.req, status, rid)
1160
1248
  headers['content-type'] = MIME['.html']
1161
1249
  headers['content-security-policy'] = mf.csp || CSP0
1162
1250
  } else {
@@ -1183,17 +1271,22 @@ export function createServer(o = {}) {
1183
1271
  // HTML: `no-cache` + ETag (strong for files, weak for SSR) → a repeat visit gets 304 with no body (§35); the ETag is
1184
1272
  // taken before the per-response nonce is injected, so it is stable
1185
1273
  const html = (res, status, page, headers = {}, etag) => {
1274
+ // A 304 updates the cached document's policy too; keep it identical to its 200 without minting a CSP nonce.
1275
+ headers = { 'cache-control': 'no-cache', ...(googleSignInPage(page) ? { 'cross-origin-opener-policy': 'same-origin-allow-popups' } : {}), ...(mf.build ? { 'x-brain-build': mf.build } : {}), ...headers }
1186
1276
  if (etag && status === 200) {
1187
1277
  headers = { etag, ...headers }
1188
1278
  const inm = res.req.headers['if-none-match']
1189
1279
  if (inm && inm.split(',').some(t => t.trim().replace(/^W\//, '') === etag.replace(/^W\//, ''))) return send(res, 304, '', headers)
1190
1280
  }
1281
+ // RFC 9111 section 4.3.5 lets HEAD update cached GET headers without replacing its body.
1282
+ // As with 304, omit a new body-bound policy so the cached script nonce remains authorized.
1283
+ if (res.req.method === 'HEAD') return send(res, status, '', { 'content-type': MIME['.html'], ...headers })
1191
1284
  const nonce = randomBytes(16).toString('base64')
1192
1285
  // behavior analytics (docs/backend/analytics.md): with a native backend every page loads its collector as an async
1193
1286
  // module served by that backend, outside the build and the route budgets; BRAIN_ANALYTICS=off skips the tag
1194
1287
  if (analyticsTag && !page.includes(analyticsTag)) page = page.includes('</head>') ? page.replace('</head>', analyticsTag + '</head>') : page + analyticsTag
1195
1288
  const body = page.replace(/<script(?![^>]*\bnonce=)/g, `<script nonce="${nonce}"`)
1196
- send(res, status, body, { 'content-type': MIME['.html'], 'content-security-policy': withNonce(mf.csp || CSP0, nonce), 'cache-control': 'no-cache', ...(googleSignInPage(page) ? { 'cross-origin-opener-policy': 'same-origin-allow-popups' } : {}), ...(mf.build ? { 'x-brain-build': mf.build } : {}), ...headers })
1289
+ send(res, status, body, { 'content-type': MIME['.html'], 'content-security-policy': withNonce(mf.csp || CSP0, nonce), ...headers })
1197
1290
  }
1198
1291
  const notFound = (req, res) => failPage(res, 404, res.getHeader('x-request-id'))
1199
1292
  // `gone()` from load (§33): 410 tells crawlers the URL was removed for good; views/410.brain, else the 404 page
@@ -1202,11 +1295,11 @@ export function createServer(o = {}) {
1202
1295
  if (!wantsHtml(res.req)) return send(res, status, json({ schemaVersion: 1, status, rid, error: detail || STATUS_CODES[status] || 'Request failed' }), { 'content-type': MIME['.json'] })
1203
1296
  const custom = (status === 404 && mf.notFound) || (mf.errors && mf.errors[status]) || (status === 410 && !file('410.html') ? mf.notFound : `${status}.html`)
1204
1297
  const path = typeof custom === 'string' && file(custom)
1205
- return html(res, status, path ? readFileSync(path, 'utf8') : defaultError(status, rid, BASE))
1298
+ return html(res, status, path ? readFileSync(path, 'utf8') : errorDocument(res.req, status, rid))
1206
1299
  }
1207
1300
  // `/403`, `/404.html`, `/503`…: the status when the app has that page, else 0 and the path is an ordinary one
1208
1301
  const reservedStatus = pathname => {
1209
- const m = /^\/(403|404|410|5\d\d)(?:\.html)?$/.exec(pathname)
1302
+ const m = /^\/(403|404|410|5\d\d)(?:\.(?:html|brain))?$/.exec(pathname)
1210
1303
  if (!m) return 0
1211
1304
  const n = +m[1]
1212
1305
  return file(`${n}.html`) || (mf.errors && mf.errors[n]) || (n === 403 && mf.forbidden) || (n === 404 && mf.notFound) ? n : 0
@@ -1233,7 +1326,7 @@ export function createServer(o = {}) {
1233
1326
  // runs it before `brain:ready`, so the first request after a (re)start finds them loaded instead of reading,
1234
1327
  // compiling and evaluating the app's module graph (and its packages) while the visitor waits. A module that throws
1235
1328
  // stays rejected in `mods`, exactly as the request that imports it would have found it; it is counted, never fatal.
1236
- const warm = async () => {
1329
+ const warm = async signal => {
1237
1330
  const want = new Set(['_server/dom.js', mf.core])
1238
1331
  const add = r => {
1239
1332
  if (!r || typeof r !== 'object') return
@@ -1248,6 +1341,7 @@ export function createServer(o = {}) {
1248
1341
  for (const m of ['_server/brain-hydrate.js', '_server/brain-form.js']) if (file(m)) want.add(m)
1249
1342
  let failed = 0
1250
1343
  for (const rel of want) {
1344
+ if (signal?.aborted) break
1251
1345
  try {
1252
1346
  await load(rel)
1253
1347
  } catch {
@@ -1260,13 +1354,16 @@ export function createServer(o = {}) {
1260
1354
  const context = (req, res, url, extra) => {
1261
1355
  const cookies = parseCookies(req.headers.cookie)
1262
1356
  const { user, session: ses } = nativeSession ? { user: req.$nativeUser ?? null, session: sessionSecrets(native.session(req, res, res.getHeader('x-request-id')), cookies, secret) } : sessionFrom(cookies, secret)
1263
- const signal = lifetime(res)
1357
+ const signal = req.$brainWarmupSignal || lifetime(res)
1358
+ const backend = native ? native.backend(req, res, res.getHeader('x-request-id')) : null
1359
+ const denyWarmWrite = () => { throw new HttpError(403, 'Startup warmup permits anonymous public reads only') }
1360
+ const readonlySession = req.$brainWarmupSignal ? Object.freeze({ ...ses, user:null, set:denyWarmWrite, clear:denyWarmWrite, setSecret:denyWarmWrite, clearSecret:denyWarmWrite }) : ses
1264
1361
  return {
1265
- url, query: Object.fromEntries(url.searchParams), cookies, user, session: ses, env,
1266
- ...(native ? { backend: native.backend(req, res, res.getHeader('x-request-id')) } : {}),
1362
+ url, query: Object.fromEntries(url.searchParams), cookies, user, session: readonlySession, env,
1363
+ ...(backend ? { backend: req.$brainWarmupSignal ? Object.freeze({ ...backend, service:denyWarmWrite, files:denyWarmWrite, operation:denyWarmWrite, login:denyWarmWrite, signup:denyWarmWrite, google:Object.freeze({challenge:denyWarmWrite,login:denyWarmWrite}) }) : backend } : {}),
1267
1364
  // `prefetch`: the router fetched this page ahead of a click (§19); a `load` with side effects can skip them
1268
1365
  prefetch: req.headers['x-brain-prefetch'] === '1' || /\bprefetch\b/.test(req.headers['sec-purpose'] ?? ''),
1269
- req: { method: req.method, headers: req.headers, ip: ipOf(req) }, signal, llm: llmClient(env, signal), ...extra,
1366
+ req: { method: req.method, headers: req.headers, ip: ipOf(req) }, signal, llm: req.$brainWarmupSignal ? denyWarmWrite : llmClient(env, signal), ...extra,
1270
1367
  }
1271
1368
  }
1272
1369
 
@@ -1280,8 +1377,20 @@ export function createServer(o = {}) {
1280
1377
  const FILLING = new Map()
1281
1378
  const SSRC = new Map(), SSRC_MAX = 500, SSRC_BYTES = 32 * 1024 * 1024, SSRC_ENTRY = 1024 * 1024
1282
1379
  let ssrBytes = 0
1380
+ const rememberSSR = (key, c) => {
1381
+ ssrBytes -= SSRC.get(key)?.bytes || 0
1382
+ SSRC.delete(key)
1383
+ SSRC.set(key, c)
1384
+ ssrBytes += c.bytes
1385
+ while (SSRC.size > SSRC_MAX || ssrBytes > SSRC_BYTES) {
1386
+ const oldest = SSRC.keys().next().value
1387
+ ssrBytes -= SSRC.get(oldest).bytes
1388
+ SSRC.delete(oldest)
1389
+ }
1390
+ }
1283
1391
  async function cached(req, res, route, params, url, rid) {
1284
- const n = +route.cache, ms = n * 1000, key = (route.locale || '') + url.pathname + cacheSearch(url)
1392
+ const n = +route.cache, ms = n * 1000, key = JSON.stringify([url.origin, route.locale || '', url.pathname, cacheSearch(url)])
1393
+ const redisKey = key
1285
1394
  // `kept.answer`: what this render answered when it was not a 200 (an error page, a redirect, a not-found), so the
1286
1395
  // request that started it replays that answer instead of running load() and its backend reads a second time
1287
1396
  const fill = (kept = {}) => {
@@ -1289,30 +1398,24 @@ export function createServer(o = {}) {
1289
1398
  const set = {}
1290
1399
  return new Promise(ok => {
1291
1400
  const cap = { req, $capture: (out, h, st) => {
1292
- const headers = Object.fromEntries(Object.entries(h).map(([k, v]) => [k.toLowerCase(), v]))
1293
- const privateResponse = headers['set-cookie'] || headers['vary'] || /private|no-store|no-cache/i.test(headers['cache-control'] || '')
1401
+ h = { ...set, ...h }
1402
+ const privateResponse = Object.entries(h).some(([key, value]) => ['set-cookie', 'vary'].includes(key.toLowerCase()) || key.toLowerCase() === 'cache-control' && /private|no-store|no-cache/i.test(value || ''))
1294
1403
  if (st >= 500) failed = true
1295
1404
  if (st !== 200) kept.answer = { page: out, h, st, set }
1296
1405
  ok(st === 200 ? { out, h, cacheable: !privateResponse, etag: 'W/"' + tag(out) + '"', t: Date.now(), bytes: Buffer.byteLength(out) } : null)
1297
1406
  }, writeHead(st, h) { if (st >= 500) failed = true; kept.answer = { st, h: h || {}, set } }, end: body => { if (kept.answer && kept.answer.page === undefined) kept.answer.body = body; ok(null) }, setHeader(k, v) { set[k] = v }, getHeader: name => (name === 'x-request-id' ? rid : set[name]), once() {}, removeListener() {} }
1298
1407
  ssr(req, cap, route, params, url, rid, null, 200, null, true).catch(() => ok(null))
1299
- }).then(c => {
1408
+ }).then(async c => {
1300
1409
  // A failed revalidation keeps the copy until 2N, which is what `stale-if-error` promises downstream.
1301
1410
  if ((!c || !c.cacheable) && (failed || req.$brainFailedReads) && SSRC.has(key)) return c
1302
1411
  if (!c || !c.cacheable) {
1303
1412
  ssrBytes -= SSRC.get(key)?.bytes || 0
1304
1413
  SSRC.delete(key)
1414
+ await redisCache?.delete(redisKey)
1305
1415
  }
1306
1416
  if (c && c.cacheable && c.bytes <= SSRC_ENTRY) {
1307
- ssrBytes -= SSRC.get(key)?.bytes || 0
1308
- SSRC.delete(key)
1309
- SSRC.set(key, c)
1310
- ssrBytes += c.bytes
1311
- while (SSRC.size > SSRC_MAX || ssrBytes > SSRC_BYTES) {
1312
- const oldest = SSRC.keys().next().value
1313
- ssrBytes -= SSRC.get(oldest).bytes
1314
- SSRC.delete(oldest)
1315
- }
1417
+ rememberSSR(key, c)
1418
+ await redisCache?.set(redisKey, c, n)
1316
1419
  }
1317
1420
  return c
1318
1421
  })
@@ -1329,13 +1432,27 @@ export function createServer(o = {}) {
1329
1432
  const running = FILLING.get(key)
1330
1433
  if (running) {
1331
1434
  c = await running
1435
+ if (req.aborted || res.destroyed) return
1332
1436
  if (c?.cacheable) return serve(c)
1333
1437
  return ssr(req, res, route, params, url, rid, null, 200, null, true)
1334
1438
  }
1335
1439
  const kept = {}
1336
- const filling = fill(kept).finally(() => FILLING.delete(key))
1440
+ const filling = (async () => {
1441
+ const shared = await redisCache?.get(redisKey, n)
1442
+ if (req.aborted || res.destroyed) return null
1443
+ if (shared) {
1444
+ rememberSSR(key, shared)
1445
+ if (Date.now() - shared.t >= ms) {
1446
+ shared.busy = true
1447
+ fill().finally(() => { shared.busy = false })
1448
+ }
1449
+ return shared
1450
+ }
1451
+ return fill(kept)
1452
+ })().finally(() => FILLING.delete(key))
1337
1453
  FILLING.set(key, filling)
1338
1454
  c = await filling
1455
+ if (req.aborted || res.destroyed) return
1339
1456
  if (c) return serve(c)
1340
1457
  // the first render already asked the backend and logged why; another would only spend more of a limit it just hit
1341
1458
  if (req.$brainFailedReads?.temporary) return comeBackPage(req, res, route, params, url, rid)
@@ -1355,18 +1472,32 @@ export function createServer(o = {}) {
1355
1472
  res.setHeader('cache-control', 'no-store')
1356
1473
  return errorPage(req, res, route, params, url, rid, 503)
1357
1474
  }
1358
- const anonymous = req => (req.method === 'GET' || req.method === 'HEAD') && !req.headers.authorization && !req.headers.cookie
1475
+ const anonymous = req => publicCacheRequest(req)
1359
1476
 
1477
+ const preloadHints = new WeakMap()
1360
1478
  async function ssr(req, res, route, params, url, rid, forms = null, status = 200, fixed = null, nocache = false) {
1361
1479
  // the route's preloads (the same Link values as _headers): on the response, where hosts turn them into early hints,
1362
1480
  // capped so a long list cannot trip a proxy's header buffer. A 103 (#232) only when opted in, and never to HTTP/1.0.
1363
1481
  if (route.links && route.links.length && !res.$capture && !res.$hinted) {
1364
1482
  res.$hinted = true
1365
- const link = route.links.join(', ')
1366
- if (link.length <= 2048 && !res.headersSent) res.setHeader('link', link)
1367
- if (EARLY_HINTS && res.writeEarlyHints && req.httpVersion !== '1.0') {
1483
+ let selected = preloadHints.get(route)
1484
+ if (!selected) {
1485
+ selected = []
1486
+ let bytes = 0
1487
+ // Preserve graph order within a priority; render-blocking styles and fonts precede script hints.
1488
+ const priority = value => /(?:^|;)\s*as=(?:style|font)(?:;|$)/.test(value) ? 0 : 1
1489
+ for (const value of [...route.links].sort((a, b) => priority(a) - priority(b))) {
1490
+ const size = Buffer.byteLength(value) + (selected.length ? 2 : 0)
1491
+ if (bytes + size > 2048) continue
1492
+ selected.push(value); bytes += size
1493
+ }
1494
+ preloadHints.set(route, selected)
1495
+ if (selected.length < route.links.length) log('warn', 'preload hints trimmed to 2048 bytes; fix: reduce the route preload graph', { route: route.path, kept: selected.length, dropped: route.links.length - selected.length })
1496
+ }
1497
+ if (selected.length && !res.headersSent) res.setHeader('link', selected.join(', '))
1498
+ if (selected.length && EARLY_HINTS && res.writeEarlyHints && req.httpVersion !== '1.0') {
1368
1499
  try {
1369
- res.writeEarlyHints({ link: route.links })
1500
+ res.writeEarlyHints({ link: selected })
1370
1501
  } catch {}
1371
1502
  }
1372
1503
  }
@@ -1445,7 +1576,7 @@ export function createServer(o = {}) {
1445
1576
  // a page behind a guard passes it only for its visitor: it stays out of the index even when it renders (§33)
1446
1577
  if (route.guards && route.guards.length) res.setHeader('x-robots-tag', NOINDEX)
1447
1578
  // with seo.trailingSlash "ignore" both forms answer: the canonical takes the form the sitemap lists (no slash)
1448
- let out = head(page, seo, SLASH === 'always' || url.pathname === '/' ? url.pathname : url.pathname.replace(/\/+$/, '') || '/', url.search)
1579
+ let out = head(page, seo, SLASH === 'always' || url.pathname === '/' ? url.pathname : url.pathname.replace(/\/+$/, '') || '/', url.search, route, BASE.slice(0, -1))
1449
1580
  // route params for route() in the browser (the client has no route table)
1450
1581
  if (params && Object.keys(params).length) out = out.replace('</head>', () => `<meta name="brain-params" content="${esc(JSON.stringify(params))}"></head>`)
1451
1582
  if (forms) out = out.replace('</body>', () => `<script type="application/json" id="brain-forms">${jsonInHtml(forms)}</script></body>`)
@@ -1483,9 +1614,18 @@ export function createServer(o = {}) {
1483
1614
  const seoF = sm.seo || (await load(route.view)).default.seo
1484
1615
  if (route.i18n) await load(route.i18n)
1485
1616
  const nonce = randomBytes(16).toString('base64'), n = h => h.replace(/<script(?![^>]*\bnonce=)/g, `<script nonce="${nonce}"`)
1617
+ globalThis.__brainLocale = route.locale || null
1618
+ let seo0 = null
1619
+ try {
1620
+ seo0 = typeof seoF === 'function' ? seoF({}) : null
1621
+ } finally {
1622
+ globalThis.__brainLocale = null
1623
+ }
1624
+ const initialHead = head(top, seo0, url.pathname, url.search, route, BASE.slice(0, -1))
1486
1625
  const enc = COMPRESS && req.method !== 'HEAD' ? acceptedEncodings(req.headers['accept-encoding'], DYNAMIC)[0] : null
1487
1626
  res.writeHead(200, {
1488
- ...SECURITY, 'content-type': MIME['.html'], 'content-security-policy': withNonce(mf.csp || CSP0, nonce), 'cache-control': 'no-cache',
1627
+ ...SECURITY, 'content-type': MIME['.html'], ...(req.method === 'HEAD' ? {} : { 'content-security-policy': withNonce(mf.csp || CSP0, nonce) }), 'cache-control': 'no-cache',
1628
+ ...(googleSignInPage(initialHead) ? { 'cross-origin-opener-policy': 'same-origin-allow-popups' } : {}),
1489
1629
  ...(mf.build ? { 'x-brain-build': mf.build } : {}), ...extra, ...ctx.session.headers(),
1490
1630
  ...(COMPRESS ? { vary: varyEncoding(extra.vary) } : {}), ...(enc ? { 'content-encoding': enc } : {}),
1491
1631
  })
@@ -1493,15 +1633,8 @@ export function createServer(o = {}) {
1493
1633
  const zs = enc ? encodeStream(enc) : null
1494
1634
  if (zs) zs.pipe(res)
1495
1635
  const write = s => (zs ? (zs.write(s), zs.flush()) : res.write(s))
1496
- globalThis.__brainLocale = route.locale || null
1497
- let seo0 = null
1498
- try {
1499
- seo0 = typeof seoF === 'function' ? seoF({}) : null
1500
- } finally {
1501
- globalThis.__brainLocale = null
1502
- }
1503
1636
  const at = { path: url.pathname, params: params || {}, query: Object.fromEntries(url.searchParams) }
1504
- write(n(head(top, seo0, url.pathname, url.search)))
1637
+ write(n(initialHead))
1505
1638
  // QA #253: the folder's _loading.brain goes in the region with the first chunk (SPEC: shows while load runs),
1506
1639
  // between two marker comments; the last chunk starts with a script that removes them and what is between
1507
1640
  let clear = ''
@@ -1555,7 +1688,10 @@ export function createServer(o = {}) {
1555
1688
  }
1556
1689
  // the head is already out: the folder's _error.brain (props { message, rid }) renders inside the region, else a notice
1557
1690
  async function failBody(route, url, rid) {
1558
- const notice = `<p role="alert" class="brain-fallback">Something went wrong. Reference: ${esc(rid)}</p>`
1691
+ const options = errorOptions(null, route), word = errorWords(options.locale, options.dictionary)
1692
+ const message = word('message', 'Something went wrong.'), reference = word('reference', 'Reference')
1693
+ const label = part => part.lang ? `<span${part.lang}>${esc(part.text)}</span>` : esc(part.text)
1694
+ const notice = `<p role="alert" class="brain-fallback">${label(message)} ${label(reference)}: ${esc(rid)}</p>`
1559
1695
  if (!route.error) return notice
1560
1696
  try {
1561
1697
  const props = { message: 'Something went wrong.', rid }, at = { path: url.pathname, params: {}, query: {} }
@@ -1759,7 +1895,7 @@ export function createServer(o = {}) {
1759
1895
  DYN.forEach((r, n) => {
1760
1896
  for (const p of lists[n]) {
1761
1897
  const path = fill(r.path, p)
1762
- if (!path) continue
1898
+ if (!path || !r.rx.test(path)) continue
1763
1899
  const loc = origin + withBase(path)
1764
1900
  if (loc.length > 2048) log('warn', 'sitemap(): URL longer than 2048 characters left out; fix: shorten the slug', { view: r.view, loc: loc.slice(0, 120) })
1765
1901
  else if (urls.length < MAX_URLS) urls.push({ loc, lastmod: day(p.lastmod) })
@@ -1779,7 +1915,9 @@ export function createServer(o = {}) {
1779
1915
  }
1780
1916
  // §33 S01: every sitemap file holds at most 50 000 URLs and SITEMAP_BYTES bytes of UTF-8 XML; the dynamic URLs fill
1781
1917
  // /sitemap-dynamic.xml, /sitemap-dynamic-2.xml, … in sitemap() order, so the split is deterministic
1782
- const SET_HEAD = '<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
1918
+ const XML_SIGNATURE = compilerBranding ? '<!-- by BRAINLLM https://chrisifm.github.io/BRAINLLM/ -->' : ''
1919
+ const XML_HEADER = `<?xml version="1.0" encoding="UTF-8"?>\n${XML_SIGNATURE}\n`
1920
+ const SET_HEAD = XML_HEADER + '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
1783
1921
  const SET_FOOT = '</urlset>\n'
1784
1922
  const bytes = t => Buffer.byteLength(t)
1785
1923
  function pages(urls) {
@@ -1797,7 +1935,9 @@ export function createServer(o = {}) {
1797
1935
  async function serveSitemap(req, res, url) {
1798
1936
  const f = file('/sitemap.xml')
1799
1937
  if (!f) return false
1800
- const text = readFileSync(f, 'utf8')
1938
+ const original = readFileSync(f, 'utf8')
1939
+ const declaration = original.startsWith('<?xml') ? original.indexOf('?>') + 2 : 0
1940
+ const text = !XML_SIGNATURE || original.includes(XML_SIGNATURE) ? original : declaration > 1 ? original.slice(0, declaration) + '\n' + XML_SIGNATURE + original.slice(declaration) : XML_SIGNATURE + '\n' + original
1801
1941
  const origin = mf.site || [...text.matchAll(/<loc>(https?:\/\/[^<]+)<\/loc>/g)].map(m => URL.canParse(m[1]) && new URL(m[1]).origin).find(Boolean)
1802
1942
  if (!origin) return false
1803
1943
  const index = text.includes('<sitemapindex')
@@ -1817,13 +1957,47 @@ export function createServer(o = {}) {
1817
1957
  index
1818
1958
  ? text.replace('</sitemapindex>', `${kids}</sitemapindex>`)
1819
1959
  : split
1820
- ? `<?xml version="1.0" encoding="UTF-8"?>\n<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${child('/sitemap-static.xml')}${kids}</sitemapindex>\n`
1960
+ ? `${XML_HEADER}<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${child('/sitemap-static.xml')}${kids}</sitemapindex>\n`
1821
1961
  : text.replace('</urlset>', sets[0] + '</urlset>'),
1822
1962
  )
1823
1963
  return true
1824
1964
  }
1825
1965
 
1826
1966
  // ---------- static ----------
1967
+ // Content validators use bounded buffers and a bounded metadata cache; a full media file never enters the heap.
1968
+ const fileTags = new Map(), hashingFiles = new Map()
1969
+ async function fileTag(handle, stat) {
1970
+ const key = `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}`
1971
+ if (fileTags.has(key)) return fileTags.get(key)
1972
+ if (hashingFiles.has(key)) return hashingFiles.get(key)
1973
+ if (hashingFiles.size >= 8) throw new HttpError(503, 'Static file validators are busy; retry shortly')
1974
+ const task = (async () => {
1975
+ const hash = createHash('sha256'), buf = Buffer.alloc(65536), until = performance.now() + 30000
1976
+ let offset = 0
1977
+ while (offset < stat.size) {
1978
+ if (performance.now() > until) throw new HttpError(503, 'Static file validator deadline exceeded; retry shortly')
1979
+ const { bytesRead } = await handle.read(buf, 0, Math.min(buf.length, stat.size - offset), offset)
1980
+ if (!bytesRead) throw new HttpError(503, 'Static file changed while computing its validator')
1981
+ hash.update(buf.subarray(0, bytesRead)); offset += bytesRead
1982
+ }
1983
+ const after = await handle.stat()
1984
+ if (after.size !== stat.size || after.mtimeMs !== stat.mtimeMs || after.ctimeMs !== stat.ctimeMs) throw new HttpError(503, 'Static file changed; retry shortly')
1985
+ const etag = '"' + hash.digest('base64url') + '"'
1986
+ fileTags.set(key, etag)
1987
+ if (fileTags.size > 256) fileTags.delete(fileTags.keys().next().value)
1988
+ return etag
1989
+ })()
1990
+ hashingFiles.set(key, task)
1991
+ try { return await task } finally { hashingFiles.delete(key) }
1992
+ }
1993
+ async function streamFile(req, res, handle, status, headers, span) {
1994
+ if (req.method === 'HEAD') return send(res, status, '', headers)
1995
+ res.writeHead(status, { ...SECURITY, ...(server.draining ? { connection: 'close' } : {}), ...headers })
1996
+ // The descriptor stays pinned from stat and hashing through transmission, including atomic deployments.
1997
+ const length = Number(headers['content-length'])
1998
+ if (!length) return res.end()
1999
+ await pipeline(handle.createReadStream({ start: 0, end: length - 1, ...span, autoClose: false }), res)
2000
+ }
1827
2001
  // Never served (brain-pack private.rs holds the same list for the Rust handler and every generated host config).
1828
2002
  const PRIVATE_PREFIXES = ['_server', '_brain', 'server.mjs']
1829
2003
  const PRIVATE_FILES = ['_headers', '_redirects']
@@ -1851,7 +2025,7 @@ export function createServer(o = {}) {
1851
2025
  // site configured) or with a per-request pattern gets no header, so this never invents a URL.
1852
2026
  function twinCanonical(path) {
1853
2027
  const stem = path.replace(/\.md$/, '').replace(/\/+$/, '')
1854
- const page = file(stem ? stem + '.html' : 'index.html') || file(stem + '/index.html')
2028
+ const page = file(stem ? stem + '.html' : 'index.html') || file(stem + '/index.html') || file(stem ? stem + '.brain' : 'index.brain') || file(stem + '/index.brain')
1855
2029
  if (!page) return {}
1856
2030
  const tag = /<link\b[^>]*\brel=["']canonical["'][^>]*>/i.exec(readFileSync(page, 'utf8'))?.[0]
1857
2031
  const href = tag && /\bhref="(https?:\/\/[^"<>\s]+)"/.exec(tag)?.[1].replaceAll('&amp;', '&')
@@ -1860,21 +2034,21 @@ export function createServer(o = {}) {
1860
2034
  // What exists whatever the routes say: generated sitemaps, built files and the icon browsers ask for by habit.
1861
2035
  async function serveFixed(req, res, url) {
1862
2036
  if (DYN.length && /^\/sitemap(-static|-dynamic(-\d+)?)?\.xml$/.test(url.pathname) && (await serveSitemap(req, res, url))) return true
1863
- if (serveStatic(req, res, url)) return true
2037
+ if (await serveStatic(req, res, url)) return true
1864
2038
  if (url.pathname !== '/favicon.ico' || !file('favicon.svg')) return false
1865
2039
  send(res, 301, '', { location: withBase('/favicon.svg'), 'cache-control': 'public, max-age=86400' })
1866
2040
  return true
1867
2041
  }
1868
2042
 
1869
- function serveStatic(req, res, url) {
2043
+ async function serveStatic(req, res, url) {
1870
2044
  const p = publicStaticPath(url.pathname)
1871
2045
  if (p === null) return false
1872
- const candidates = p.endsWith('/') ? [p + 'index.html', ...(p.length > 1 ? [p.slice(0, -1) + '.html'] : [])] : [p, p + '.html', p + '/index.html']
2046
+ const candidates = p.endsWith('/') ? [p + 'index.html', p + 'index.brain', ...(p.length > 1 ? [p.slice(0, -1) + '.html', p.slice(0, -1) + '.brain'] : [])] : [p, p + '.html', p + '/index.html', p + '.brain', p + '/index.brain']
1873
2047
  for (const c of candidates) {
1874
2048
  const f = file(c)
1875
2049
  if (!f) continue
1876
2050
  const ext = extname(f), type = FEEDS[basename(f)] || MIME[ext] || 'application/octet-stream'
1877
- if (ext === '.html') {
2051
+ if (ext === '.html' || ext === '.brain') {
1878
2052
  // §33: the page's Markdown twin (`/pricing` → pricing.md) when the client prefers text/markdown
1879
2053
  if (serveTwin(req, res, url)) return true
1880
2054
  const md = file(mdPath(p))
@@ -1886,34 +2060,39 @@ export function createServer(o = {}) {
1886
2060
  ...(ext === '.md' ? twinCanonical(p) : {}),
1887
2061
  'content-type': type,
1888
2062
  // the worker script must be revalidated on every check, or a CDN could keep an old worker for an hour (§35.7)
1889
- 'cache-control': p.startsWith('/_b/') ? 'public, max-age=31536000, immutable' : p === '/sw.js' ? 'no-cache' : 'public, max-age=3600',
2063
+ 'cache-control': p.startsWith('/_b/') ? 'public, max-age=31536000, immutable' : p === '/sw.js' || /\.(m3u8?|mpd)$/i.test(p) ? 'no-cache' : 'public, max-age=3600',
1890
2064
  vary: 'accept-encoding',
1891
2065
  }
1892
- // Range: media players ask for byte spans (Safari plays no video without them). The span is of the plain file, so
1893
- // a ranged request skips the precompressed siblings; If-Range has no validator to match here, so it gets it all.
1894
- const span = req.headers.range && !req.headers['if-range'] ? byteRange(req.headers.range, statSync(f).size) : null
1895
- if (span === false) return send(res, 416, '', { 'content-range': `bytes */${statSync(f).size}` }), true
1896
- if (span) {
1897
- const len = span.end - span.start + 1, buf = Buffer.alloc(req.method === 'HEAD' ? 0 : len)
1898
- if (buf.length) {
1899
- const fd = openSync(f, 'r')
1900
- try { readSync(fd, buf, 0, len, span.start) } finally { closeSync(fd) }
1901
- }
1902
- send(res, 206, buf, { ...headers, 'accept-ranges': 'bytes', 'content-range': `bytes ${span.start}-${span.end}/${statSync(f).size}`, 'content-length': String(len) })
1903
- return true
1904
- }
1905
2066
  // the smallest precompressed sibling the client accepts (the build keeps only those smaller than the file)
1906
2067
  let best = null
1907
- for (const enc of acceptedEncodings(req.headers['accept-encoding'])) {
2068
+ for (const enc of req.headers.range ? [] : acceptedEncodings(req.headers['accept-encoding'])) {
1908
2069
  const encoded = file(c + (enc === 'br' ? '.br' : enc === 'zstd' ? '.zst' : '.gz'))
1909
2070
  const size = encoded ? statSync(encoded).size : Infinity
1910
2071
  if (encoded && (!best || size < best.size)) best = { enc, encoded, size }
1911
2072
  }
1912
- if (best) {
1913
- send(res, 200, req.method === 'HEAD' ? '' : readFileSync(best.encoded), { ...headers, 'content-encoding': best.enc })
1914
- return true
2073
+ const selected = best ? best.encoded : f
2074
+ const handle = await open(selected, FS.O_RDONLY | (FS.O_NOFOLLOW || 0) | (FS.O_NONBLOCK || 0))
2075
+ try {
2076
+ const stat = await handle.stat()
2077
+ if (!stat.isFile()) throw new Error('Static output must be a regular file')
2078
+ headers.etag = await fileTag(handle, stat)
2079
+ if (res.destroyed) return true
2080
+ if (!best) headers['accept-ranges'] = 'bytes'
2081
+ const inm = req.headers['if-none-match']
2082
+ if (inm && inm.split(',').some(t => t.trim() === '*' || t.trim().replace(/^W\//, '') === headers.etag)) return send(res, 304, '', headers), true
2083
+ const span = req.headers.range && (!req.headers['if-range'] || req.headers['if-range'] === headers.etag) ? byteRange(req.headers.range, stat.size) : null
2084
+ if (span === false) return send(res, 416, '', { ...headers, 'content-range': `bytes */${stat.size}` }), true
2085
+ if (span) {
2086
+ headers['content-range'] = `bytes ${span.start}-${span.end}/${stat.size}`
2087
+ headers['content-length'] = String(span.end - span.start + 1)
2088
+ } else {
2089
+ headers['content-length'] = String(stat.size)
2090
+ if (best) headers['content-encoding'] = best.enc
2091
+ }
2092
+ await streamFile(req, res, handle, span ? 206 : 200, headers, span)
2093
+ } finally {
2094
+ await handle.close()
1915
2095
  }
1916
- send(res, 200, req.method === 'HEAD' ? '' : readFileSync(f), { ...headers, 'accept-ranges': 'bytes' })
1917
2096
  return true
1918
2097
  }
1919
2098
  return false
@@ -1996,6 +2175,14 @@ export function createServer(o = {}) {
1996
2175
  if (ms > 1000) log('warn', 'slow request', { rid, path: req.$brainPath || String(req.url || '').split('?')[0], status: res.statusCode, ms: Math.round(ms) })
1997
2176
  })
1998
2177
  try {
2178
+ // The URL parser erases literal/encoded dot segments and treats backslashes as separators.
2179
+ // Constrained routing promises original segment matching, so reject those aliases before any
2180
+ // canonical redirect or parser can turn them into a restricted candidate. Legacy dispatch stays unchanged.
2181
+ if (mf.rawRouting === true) {
2182
+ const target = req.url
2183
+ const path = typeof target === 'string' ? target.split('?')[0] : ''
2184
+ if (!path.startsWith('/') || path.startsWith('//') || /[\\#]/.test(path) || path.split('/').some(segment => /^(?:\.|%2e){1,2}$/i.test(segment))) return send(res, 403, 'Forbidden')
2185
+ }
1999
2186
  // One URL per page: `//route`, `/a//b` and `/route/index.html` answer one 301 on this origin. The path is rewritten
2000
2187
  // as text, before URL parsing: `//host/x` would be read as another host. Backend paths are left to the backend.
2001
2188
  const variant = urlVariant(req)
@@ -2021,6 +2208,15 @@ export function createServer(o = {}) {
2021
2208
  url = new URL('/' + full.pathname.slice(BASE.length) + full.search, full) // the app path
2022
2209
  }
2023
2210
  req.$brainPath = url.pathname
2211
+ if (url.pathname === '/_brain/sentry') {
2212
+ if (!sentryReady || url.search) return notFound(req, res)
2213
+ await sentryReady
2214
+ return await sentryTunnel.handle(req, res, ipOf(req))
2215
+ }
2216
+ if (url.pathname === '/_brain/error.css') {
2217
+ if (req.method !== 'GET' && req.method !== 'HEAD') return send(res, 405, '', { allow: 'GET, HEAD', 'content-type': MIME['.css'] })
2218
+ return send(res, 200, ERROR_CSS + (compilerBranding ? '\n/* by BRAINLLM https://chrisifm.github.io/BRAINLLM/ */' : ''), { 'content-type': MIME['.css'], 'cache-control': 'public, max-age=300' })
2219
+ }
2024
2220
  const probe = brainProbe(url.pathname)
2025
2221
  if (probe) return await answerProbe(req, res, new URL(probe, url), rid)
2026
2222
  if (native && /^\/_brain\/files(?:\/|$)/.test(url.pathname)) return await native.fileStream(req,res,url,rid)
@@ -2049,7 +2245,7 @@ export function createServer(o = {}) {
2049
2245
  const post = req.method === 'POST'
2050
2246
  if (!post && req.method !== 'GET' && req.method !== 'HEAD') return send(res, 405, 'Method not allowed', { allow: 'GET, HEAD, POST' })
2051
2247
  // a status page opened directly answers its own status: `/403` is never a 200 that a crawler or a probe can trust
2052
- const reserved = post ? 0 : reservedStatus(url.pathname)
2248
+ const reserved = post ? 0 : reservedStatus(publicStaticPath(url.pathname) ?? url.pathname)
2053
2249
  if (reserved === 403) return forbidden(req, res, url, rid, '')
2054
2250
  if (reserved) {
2055
2251
  // Anyone can open it, so the note about it is bounded and is not an error.
@@ -2062,11 +2258,12 @@ export function createServer(o = {}) {
2062
2258
  for (const route of routes) {
2063
2259
  const m = route.rx.exec(url.pathname)
2064
2260
  if (!m) continue
2261
+ const protectedRoute = route.guards?.length || route.middleware?.length
2065
2262
  // a form page without load is prerendered: GET serves that file (build ETag); only its POST needs the server
2066
- if (!post && route.forms && serveStatic(req, res, url)) return
2263
+ if (!post && !protectedRoute && route.forms && (await serveStatic(req, res, url))) return
2067
2264
  // a parameter never hides a real file: `/[code]` must not swallow /favicon.svg, /sitemap.xml or a prerendered /login
2068
- if (!post && m.groups && (await serveFixed(req, res, url))) return
2069
- if (!post && serveTwin(req, res, url)) return
2265
+ if (!post && !protectedRoute && m.groups && (await serveFixed(req, res, url))) return
2266
+ if (!post && !protectedRoute && serveTwin(req, res, url)) return
2070
2267
  const paged = post ? null : pageParameter(route, url)
2071
2268
  if (paged === 404) return notFound(req, res)
2072
2269
  if (paged) return send(res, 301, '', { location: withBase(paged), 'cache-control': 'public, max-age=3600' })
@@ -2080,7 +2277,7 @@ export function createServer(o = {}) {
2080
2277
  } catch (e) {
2081
2278
  const busy = e instanceof HttpError && e.code === 'BRIDGE_BUSY'
2082
2279
  if (busy) log('warn', 'backend bridge busy', { rid, path: req.$brainPath || String(req.url || '').split('?')[0], fix: e.fix })
2083
- else if (e instanceof HttpError && OWN_CAUSES.has(e.message)) log('warn', 'backend unavailable', { rid, path: req.$brainPath || String(req.url || '').split('?')[0], status: e.status, cause: e.message, ...(CODE_SHAPE.test(e.transport || '') ? { transport: e.transport } : {}) })
2280
+ else if (e instanceof HttpError && e.status >= 500 && BACKEND_UNAVAILABLE_CAUSES.has(e.message)) log('warn', 'backend unavailable', { rid, path: req.$brainPath || String(req.url || '').split('?')[0], status: e.status, cause: e.message, ...(CODE_SHAPE.test(e.transport || '') ? { transport: e.transport } : {}) })
2084
2281
  else log('error', 'unhandled', { rid, path: req.url, ...trace(e) })
2085
2282
  try {
2086
2283
  if (busy && !res.headersSent) { res.setHeader('retry-after', String(e.retryAfter)); failPage(res, 503, rid, { code: e.code, message: 'Service Unavailable', fix: e.fix }) }
@@ -2094,13 +2291,49 @@ export function createServer(o = {}) {
2094
2291
  }
2095
2292
  }
2096
2293
  })
2294
+ if (sentryReady) {
2295
+ const listen = server.listen
2296
+ server.listen = function (...args) { sentryReady.then(() => { if (!sentryStopped) listen.apply(server, args) }, () => server.emit('error', Error('Sentry startup failed; verify private BRAIN_SENTRY_DSN and BRAIN_SENTRY_HOST'))); return server }
2297
+ server.$brainSentryReady = sentryReady
2298
+ const close = server.close, closeAll = server.closeAllConnections
2299
+ const stop = () => { sentryStopped = true; sentryTunnel?.close() }
2300
+ server.close = function (...args) { stop(); return close.apply(this, args) }
2301
+ server.closeAllConnections = function (...args) { stop(); return closeAll.apply(this, args) }
2302
+ server.once('close', stop)
2303
+ }
2097
2304
  if (native) server.once('close', () => native.close())
2305
+ if (redisCache) {
2306
+ server.$brainCacheStop = () => redisCache.close()
2307
+ const close = server.close, closeAll = server.closeAllConnections
2308
+ server.close = function (...args) { redisCache.close(); return close.apply(this, args) }
2309
+ server.closeAllConnections = function (...args) { redisCache.close(); return closeAll.apply(this, args) }
2310
+ server.once('close', () => redisCache.close())
2311
+ }
2098
2312
  if (native && nativeSockets) server.$brainSocketStop = installNativeSockets(server,{target:o.backendURL ?? env.BRAIN_BACKEND_URL,base:BASE,cookie:native._socketCookie,client:native._forwardedFor,log})
2099
2313
  server.headersTimeout = 15000
2100
2314
  // With a native backend the file upload deadline is enforced per request (above); 30 s stays for every other body.
2101
2315
  server.requestTimeout = native ? 320000 : 30000
2102
2316
  // `warm: true` (`node server.mjs`): import the app's modules now; requests that arrive meanwhile share the same imports
2103
- if (o.warm) server.$brainWarm = warm()
2317
+ if (o.warm && SL.warmup === undefined) {
2318
+ server.$brainWarm = sentryReady ? sentryReady.then(() => warm()) : warm()
2319
+ server.$brainWarm.catch(() => {})
2320
+ }
2321
+ if (o.warm && SL.warmup !== undefined) {
2322
+ let started
2323
+ server.$brainDataWarmup = () => started ||= (async () => {
2324
+ await sentryReady
2325
+ const began = process.hrtime.bigint()
2326
+ const {warmupSettings,runWarmup} = await import('./warmup.mjs')
2327
+ const settings = warmupSettings(SL.warmup, routes, BASE, mf.site || env.PUBLIC_SITE_URL, mf.redirects)
2328
+ const stats = await runWarmup({server,settings,warm,log,started:began,prepare(req, controller, state) {
2329
+ req.$brainWarmupSignal = controller.signal
2330
+ aborts.set(controller.signal, controller)
2331
+ warmSignals.set(controller.signal, state)
2332
+ }})
2333
+ server.$brainWarm = Promise.resolve(stats)
2334
+ return stats
2335
+ })()
2336
+ }
2104
2337
  return server
2105
2338
  }
2106
2339
 
@@ -2338,6 +2571,7 @@ Object.defineProperty(globalThis, '__brainLocale', { configurable: true, get: ()
2338
2571
  // ctx.signal: aborted when the client disconnects before the answer ends or the function passes its deadline, so the
2339
2572
  // work it started (fetch, a query) stops with it.
2340
2573
  const aborts = new WeakMap()
2574
+ const warmSignals = new WeakMap()
2341
2575
  const newSignal = () => {
2342
2576
  const c = new AbortController()
2343
2577
  aborts.set(c.signal, c)
@@ -2349,6 +2583,12 @@ const lifetime = res => {
2349
2583
  return signal
2350
2584
  }
2351
2585
  const withTimeout = (p, ms, ctx) => {
2586
+ const startup = warmSignals.get(ctx?.signal)
2587
+ if (startup) {
2588
+ startup.pending.add(p)
2589
+ Promise.resolve(p).then(() => startup.pending.delete(p), () => startup.pending.delete(p))
2590
+ ms = Math.min(ms, Math.max(0, Number(startup.deadline - process.hrtime.bigint()) / 1e6))
2591
+ }
2352
2592
  let t
2353
2593
  return Promise.race([p, new Promise((_, rej) => (t = setTimeout(() => {
2354
2594
  aborts.get(ctx?.signal)?.abort()
@@ -2674,10 +2914,23 @@ export function prefersMarkdown(accept) {
2674
2914
  if (html < 0) (html = any), (htmlAt = Infinity) // an explicit type beats a wildcard of the same weight
2675
2915
  return md > 0 && (md > html || (md === html && mdAt < htmlAt))
2676
2916
  }
2917
+ // Root-relative image metadata follows the app base; bare references follow the canonical document URL.
2918
+ export function metadataImage(root, canonical, value) {
2919
+ const encode = s => encodeURI(s.toWellFormed()).replaceAll('%25', '%')
2920
+ value = value.trim()
2921
+ if (!value) return ''
2922
+ if (/^[A-Za-z][A-Za-z0-9+.-]*:/.test(value) || !root) return encode(value)
2923
+ try {
2924
+ const home = new URL(root.endsWith('/') ? root : root + '/')
2925
+ const prefix = home.pathname.replace(/\/$/, '')
2926
+ if (/^\/(?!\/)/.test(value)) value = new URL(value === prefix || value.startsWith(prefix + '/') ? value : prefix + value, home.origin).href
2927
+ return new URL(value, canonical || home).href
2928
+ } catch { return encode(value) }
2929
+ }
2677
2930
  // SEO head from View.seo(props): title, description, canonical, og:*/twitter:*, JSON-LD and the view's <head> block
2678
2931
  // F5: the build writes an SSR route's canonical/og:url from its pattern (/posts/:slug); the request has the real path.
2679
2932
  // A view's own <head> tags replace the generated ones with the same name/property (one tag per key, SPEC «SEO»).
2680
- function head(page, seo, path, search = '') {
2933
+ function head(page, seo, path, search = '', route, base = '') {
2681
2934
  let h = page
2682
2935
  // `<meta seo query="page">`: these parameters make a different page; any other (a dialog, a tracking tag) does not
2683
2936
  let identity = '', query = ''
@@ -2689,11 +2942,13 @@ function head(page, seo, path, search = '') {
2689
2942
  })
2690
2943
  if (path) {
2691
2944
  // the attribute is already escaped HTML: only the request path that replaces a pattern is escaped
2692
- const real = v => v.replace(/^(https?:\/\/[^/]+)?(\/[^"]*)$/, (m, o = '', p) => (/\/[:*]/.test(p) ? o + esc(path) : m)) + identity
2945
+ const real = v => v.replace(/^(https?:\/\/[^/]+)?(\/[^"]*)$/, (m, o = '', p) => (/\/[:*]/.test(p) ? o + esc(base + path) : m)) + identity
2693
2946
  h = h.replace(/(<link[^>]*rel=["']canonical["'][^>]*href=")([^"]*)(")/i, (m, a, v, b) => a + real(v) + b)
2694
2947
  h = h.replace(/(<meta[^>]*property=["']og:url["'][^>]*content=")([^"]*)(")/i, (m, a, v, b) => a + real(v) + b)
2695
2948
  }
2696
- if (!seo) return graph(h, path, query)
2949
+ if (!seo) return graph(h, path ? base + path : path, query)
2950
+ // Match prerendering: blank optional fields are absent; zero and false remain distinct values.
2951
+ seo = Object.fromEntries(Object.entries(seo).filter(([, v]) => v != null && !(typeof v === 'string' && !v.trim())))
2697
2952
  const meta = (attr, key, val, escaped) => {
2698
2953
  const tag = `<meta ${attr}="${esc(key)}" content="${escaped ? val.replace(/"/g, '&quot;') : esc(val)}">`
2699
2954
  const rx = new RegExp(`<meta[^>]*\\b${attr}=["']${key.replace(/[.*+?^${}()|[\]\\:]/g, '\\$&')}["'][^>]*>`, 'i')
@@ -2706,20 +2961,27 @@ function head(page, seo, path, search = '') {
2706
2961
  else if (k === 'canonical') {
2707
2962
  // a root-relative canonical takes the origin the build wrote into the template (PUBLIC_SITE_URL), as it does on
2708
2963
  // a prerendered page: a relative canonical is ignored by search engines
2709
- const built = /^\/(?!\/)/.test(v) && /<link[^>]*rel=["']canonical["'][^>]*href="(https?:\/\/[^/"]+)/i.exec(h)?.[1]
2710
- const url = built ? built + v : v
2964
+ const relative = base && (v + '/').startsWith(base + '/') ? v.slice(base.length) || '/' : v
2965
+ const translated = linkTo(route, relative)
2966
+ const local = /^\/(?!\/)/.test(translated) ? base + translated : translated
2967
+ const built = /^\/(?!\/)/.test(local) && /<link[^>]*rel=["']canonical["'][^>]*href="(https?:\/\/[^/"]+)/i.exec(h)?.[1]
2968
+ const url = built ? built + local : local
2711
2969
  const tag = `<link rel="canonical" href="${esc(url)}">`
2712
2970
  h = /<link[^>]*rel=["']canonical["'][^>]*>/i.test(h) ? h.replace(/<link[^>]*rel=["']canonical["'][^>]*>/i, () => tag) : h.replace('</head>', () => tag + '</head>')
2713
2971
  if (seo['og:url'] == null) meta('property', 'og:url', url) // og:url follows the canonical unless the view sets its own
2714
2972
  } else if (k === 'jsonld') h = h.replace('</head>', () => `<script type="application/ld+json">${jsonInHtml(v)}</script></head>`)
2715
- else if (/^(og|twitter|article):/.test(k)) meta('property', k, v)
2973
+ else if (/^(og|twitter|article):/.test(k)) meta(k.startsWith('twitter:') ? 'name' : 'property', k, v)
2716
2974
  else if (typeof v === 'string') meta('name', k, v)
2717
2975
  }
2718
2976
  // What the build writes for a prerendered page and can only be known here: the social card of this request's values
2719
2977
  // (brain-seo social::head, prerender seo_head). The view's own value always wins.
2720
2978
  const shared = /<title>([\s\S]*?)<\/title>/i.exec(h)?.[1]
2721
2979
  const origin = /<link[^>]*rel=["']canonical["'][^>]*href="(https?:\/\/[^/"]+)/i.exec(h)?.[1]
2722
- if (typeof seo['og:image'] === 'string' && origin && /^\/(?!\/)/.test(seo['og:image'])) meta('property', 'og:image', encodeURI(origin + seo['og:image']))
2980
+ const canonical = /<link[^>]*rel=["']canonical["'][^>]*href="([^"]+)"/i.exec(h)?.[1].replaceAll('&amp;', '&')
2981
+ for (const key of ['og:image', 'og:image:url', 'og:image:secure_url', 'twitter:image', 'twitter:image:src']) if (typeof seo[key] === 'string') {
2982
+ seo[key] = metadataImage(origin ? origin + base : null, canonical, seo[key])
2983
+ meta(key.startsWith('twitter:') ? 'name' : 'property', key, seo[key])
2984
+ }
2723
2985
  if (seo.title != null && seo['og:title'] == null) meta('property', 'og:title', shared, true)
2724
2986
  if (seo.description != null && seo['og:description'] == null) meta('property', 'og:description', seo.description)
2725
2987
  if (seo['og:image'] != null) {
@@ -2735,19 +2997,21 @@ function head(page, seo, path, search = '') {
2735
2997
  if (seo.title != null) node[/^(Article|BlogPosting|NewsArticle|TechArticle|Report)$/.test(T) ? 'headline' : 'name'] = String(seo.title).slice(0, 110)
2736
2998
  if (seo.description != null) node.description = String(seo.description)
2737
2999
  if (canon) node.url = canon[1].replace(/&amp;/g, '&')
2738
- if (seo['og:image'] != null) node.image = String(seo['og:image']).replace(/^\/(?!\/)/, () => (canon && /^https?:\/\/[^/]+/.exec(node.url) || [''])[0] + '/')
3000
+ if (seo['og:image'] != null) node.image = seo['og:image']
2739
3001
  if (seo.lastmod != null) node.dateModified = String(seo.lastmod)
2740
3002
  const own = new RegExp(`<script[^>]*type=["']application/ld\\+json["'][^>]*>\\s*\\{"@context":"https://schema\\.org","@type":"${T}"[\\s\\S]*?</script>`, 'i')
2741
3003
  const tag = `<script type="application/ld+json">${jsonInHtml(node)}</script>`
2742
3004
  h = own.test(h) ? h.replace(own, () => tag) : h.replace('</head>', () => tag + '</head>')
2743
3005
  }
2744
3006
  if (seo.$head) {
2745
- for (const [, attr, key] of seo.$head.matchAll(/<meta[^>]*\b(property|name)=["']([^"']+)["'][^>]*>/gi))
2746
- h = h.replace(new RegExp(`<meta[^>]*\\b${attr}=["']${key.replace(/[.*+?^${}()|[\]\\:]/g, '\\$&')}["'][^>]*>`, 'gi'), '')
3007
+ for (const [, attr, key] of seo.$head.matchAll(/<meta[^>]*\b(property|name)=["']([^"']+)["'][^>]*>/gi)) {
3008
+ const attributes = key.startsWith('twitter:') ? '(?:property|name)' : attr
3009
+ h = h.replace(new RegExp(`<meta[^>]*\\b${attributes}=["']${key.replace(/[.*+?^${}()|[\]\\:]/g, '\\$&')}["'][^>]*>`, 'gi'), '')
3010
+ }
2747
3011
  if (/<link[^>]*rel=["']canonical["']/i.test(seo.$head)) h = h.replace(/<link[^>]*rel=["']canonical["'][^>]*>/gi, '')
2748
3012
  h = h.replace('</head>', () => seo.$head + '</head>')
2749
3013
  }
2750
- return graph(h, path, query, seo)
3014
+ return graph(h, path ? base + path : path, query, seo)
2751
3015
  }
2752
3016
  // The WebSite + WebPage graph the build wrote into the template (brain-seo ServerHead::apply): its pattern URLs become
2753
3017
  // this request's, like the canonical, and the WebPage takes this request's title and description.
@@ -2763,27 +3027,32 @@ function graph(h, path, query, seo) {
2763
3027
  if ([node['@type']].flat().includes('WebPage')) {
2764
3028
  if (title && node.name == null) node.name = title.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&amp;/g, '&')
2765
3029
  if (seo?.description != null && node.description == null) node.description = String(seo.description)
3030
+ if (typeof seo?.['og:image'] === 'string' && seo.jsonld == null) {
3031
+ const image = node.primaryImageOfPage
3032
+ if (!image || (image['@type'] === 'ImageObject' && !image['@id'])) node.primaryImageOfPage = { '@type': 'ImageObject', ...image, url: seo['og:image'] }
3033
+ }
2766
3034
  }
2767
3035
  }
2768
3036
  return a + jsonInHtml(g) + b
2769
3037
  })
2770
3038
  }
2771
- // `/_b/<hash>.js` imports inside the chunks resolve to files in dist/ (same hook as the build's prerender)
3039
+ // Only compiler-owned chunks receive the own-extension module format; ordinary Node imports keep their loader.
3040
+ function resolveChunk(s, c, next, base, app) {
3041
+ if (app !== '/' && s.startsWith(app + '_b/')) s = s.slice(app.length - 1)
3042
+ const mapped = s.startsWith('/_b/')
3043
+ const url = mapped ? new URL('.' + s, base) : s.startsWith('file:') ? new URL(s) : s.startsWith('.') && c.parentURL?.startsWith('file:') ? new URL(s, c.parentURL) : null
3044
+ const owned = new URL('_b/', base).href
3045
+ if (url?.href.startsWith(owned) && url.pathname.endsWith('.jsbrain')) return { url: url.href, format: 'module', shortCircuit: true }
3046
+ if (mapped) return { url: url.href, shortCircuit: true }
3047
+ return next(s, c)
3048
+ }
2772
3049
  let hooked = false
2773
3050
  function hook(base, app = '/') {
2774
3051
  if (hooked) return
2775
3052
  hooked = true
2776
- // chunks import '/_b/x.js', or '/app/_b/x.js' under a base path: both are dist/_b/x.js
2777
- const own = s => {
2778
- if (app !== '/' && s.startsWith(app + '_b/')) s = s.slice(app.length - 1)
2779
- return s.startsWith('/_b/') && { url: new URL('.' + s, base).href, shortCircuit: true }
2780
- }
2781
- // registerHooks (Node 22.15+, in-thread) replaces the deprecated register (DEP0205)
2782
- if (Module.registerHooks) return void Module.registerHooks({ resolve: (s, c, next) => own(s) || next(s, c) })
2783
- Module.register(
2784
- 'data:text/javascript,' +
2785
- encodeURIComponent(`export async function resolve(s, c, n) { const a = ${json(app)}; if (a !== '/' && s.startsWith(a + '_b/')) s = s.slice(a.length - 1); return s.startsWith('/_b/') ? { url: new URL('.' + s, ${json(base)}).href, shortCircuit: true } : n(s, c) }`)
2786
- )
3053
+ // registerHooks (Node 22.15+, in-thread) and the compatibility loader share exactly the same resolver.
3054
+ if (Module.registerHooks) return void Module.registerHooks({ resolve: (s, c, next) => resolveChunk(s, c, next, base, app) })
3055
+ Module.register('data:text/javascript,' + encodeURIComponent(`${resolveChunk.toString()};export async function resolve(s,c,n){return resolveChunk(s,c,n,${json(base)},${json(app)})}`))
2787
3056
  }
2788
3057
 
2789
3058
  const LISTEN_WAIT_MS = 10000
@@ -2824,13 +3093,32 @@ if (process.argv[1] && real(process.argv[1]) === real(fileURLToPath(import.meta.
2824
3093
  console.error(json({ t: new Date().toISOString(), level: 'error', msg: `cannot listen on ${where.host}:${port}: ${cause}`, fix }))
2825
3094
  process.exit(1)
2826
3095
  })
2827
- s.listen(where)
3096
+ const beginListen = () => {
3097
+ if (s.$brainDataWarmup) {
3098
+ s.$brainDataWarmup().then(() => s.listen(where), async error => {
3099
+ console.error(json({level:'error',event:'startup-warmup-failed',code:['WARMUP_CONFIG_INVALID','WARMUP_FAILED','WARMUP_TIMEOUT','WARMUP_DEADLINE'].includes(error?.code)?error.code:'WARMUP_FAILED',private:true,valuesCaptured:false,fix:'Configure exact public cached SSR paths and a valid site origin; inspect bounded startup warmup diagnostics'}))
3100
+ s.$brainSocketStop?.(); s.$brainCacheStop?.()
3101
+ s.closeAllConnections()
3102
+ s.close()
3103
+ process.exit(1)
3104
+ })
3105
+ } else s.listen(where)
3106
+ }
3107
+ if (s.$brainSentryReady) {
3108
+ s.$brainSentryReady.then(beginListen, () => {
3109
+ console.error(json({level:'error',event:'startup-configuration-failed',code:'SENTRY_CONFIG_INVALID',private:true,valuesCaptured:false,fix:'Configure private BRAIN_SENTRY_DSN and BRAIN_SENTRY_HOST before startup'}))
3110
+ s.$brainSocketStop?.(); s.$brainCacheStop?.()
3111
+ s.closeAllConnections(); s.close()
3112
+ process.exit(1)
3113
+ })
3114
+ } else beginListen()
2828
3115
  // SIGTERM/SIGINT: stop accepting, answer what is in flight or arrives on open connections (each then closes), for up
2829
3116
  // to BRAIN_DRAIN_MS (10 s), then cut whatever is left; exit 0 either way
2830
3117
  const drain = () => {
2831
3118
  if (s.draining) return
2832
3119
  s.draining = true
2833
3120
  s.$brainSocketStop?.()
3121
+ s.$brainCacheStop?.()
2834
3122
  const ms = +(ENV.BRAIN_DRAIN_MS || 10000)
2835
3123
  const force = setTimeout(() => {
2836
3124
  s.closeAllConnections()