@christianirack/brainllm-runtime 0.9.0 → 0.10.2

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,6 +24,7 @@ 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'
29
30
  import { pipeline } from 'node:stream/promises'
@@ -87,6 +88,8 @@ export class HttpError extends Error {
87
88
  Object.assign(this, extra)
88
89
  }
89
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 })
90
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' })
91
94
  // Plain deliberate 4xx signals are portable from server-only application modules; exception messages stay private.
92
95
  function intendedStatus(error) {
@@ -162,9 +165,19 @@ function wantsHtml(req) {
162
165
  const quality = mediaQuality(accept, 'text/html')
163
166
  return quality > 0 && quality >= mediaQuality(accept, 'application/json')
164
167
  }
165
- function defaultError(status, rid, home = '/') {
166
- const title = STATUS_CODES[status] || 'Request failed'
167
- 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>`
168
181
  }
169
182
 
170
183
  // Coding weights are transport preferences, not substring matches (br;q=0 explicitly forbids Brotli). Ties keep the
@@ -260,7 +273,7 @@ function providerStartRedirect(method, path, status, location) {
260
273
  // ctx.backend.files(bucket): the same file calls as job and route handlers (docs/backend/files.md).
261
274
  function bucketClient(target, key, bucket, readBudgetMs = () => 0) {
262
275
  const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i
263
- 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' })
264
277
  const base = '/_brain/files/' + bucket
265
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 }
266
279
  // A read waits for a draining or restarting backend like ctx.backend reads do (nativeAuthBridge); a write never repeats.
@@ -287,15 +300,15 @@ function bucketClient(target, key, bucket, readBudgetMs = () => 0) {
287
300
  const json = async response => { try { return await response.json() } catch { throw new HttpError(502, 'Native file response is invalid') } }
288
301
  return Object.freeze({
289
302
  async list(options = {}) {
290
- 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' })
291
304
  const { limit, offset, record } = options
292
- 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' })
293
306
  const q = new URLSearchParams(Object.entries({ limit, offset, record }).filter(([, v]) => v !== undefined))
294
307
  return json(await send('GET', base + (q.size ? '?' + q : ''), undefined, 10000))
295
308
  },
296
309
  async meta(id) { return json(await send('GET', idPath(id) + '/meta', undefined, 10000)) },
297
310
  async read(id, { maxBytes = 16 * 1024 * 1024 } = {}) {
298
- 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' })
299
312
  const response = await send('GET', idPath(id), undefined, 30000)
300
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') }
301
314
  const bytes = Buffer.from(await response.arrayBuffer())
@@ -303,8 +316,8 @@ function bucketClient(target, key, bucket, readBudgetMs = () => 0) {
303
316
  return { bytes, contentType: response.headers.get('content-type'), etag: response.headers.get('etag') }
304
317
  },
305
318
  async upload({ filename, contentType = 'application/octet-stream', data, record } = {}) {
306
- 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')
307
- 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' })
308
321
  const form = new FormData()
309
322
  form.set('file', new Blob([data], { type: contentType }), filename)
310
323
  return json(await send('POST', base + (record === undefined ? '' : '?record=' + record), form, 60000))
@@ -470,6 +483,16 @@ export function nativeAuthBridge(raw, refused, serviceKey, report, ownSession, r
470
483
  return typeof prior === 'string' && prior.length <= 4096 ? prior + ', ' + peer : peer
471
484
  }
472
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
+ }
473
496
  const headers = { 'x-request-id':rid }
474
497
  for (const key of [...REQUEST_HEADERS, ...extra]) {
475
498
  const value = req.headers[key]
@@ -537,20 +560,32 @@ export function nativeAuthBridge(raw, refused, serviceKey, report, ownSession, r
537
560
  })
538
561
  }
539
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')
540
564
  if (stopped || active >= 8) throw bridgeBusy('Backend request capacity is unavailable')
541
565
  if (!['GET','HEAD','POST','PUT','PATCH','DELETE','OPTIONS'].includes(req.method)) throw new HttpError(405, 'Method not allowed')
542
566
  const post = req.method === 'POST'
543
- 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
544
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)
545
580
  active++
546
581
  return new Promise((resolve, reject) => {
547
582
  let outgoing, timer, done = false, bytes = 0, requestBytes = 0, failure, transport
548
583
  const chunks = []
549
- const headers = requestHeaders(req, rid, declared?.headers)
550
584
  const finish = (error, backend) => {
551
585
  if (done) return
552
586
  done = true; clearTimeout(timer); active--; proxyBytes -= bytes; owned.delete(outgoing)
553
587
  req.removeListener('aborted',cancel); req.removeListener('data',data); req.removeListener('end',end)
588
+ req.$brainWarmupSignal?.removeEventListener('abort',cancel)
554
589
  res.removeListener('close',cancel)
555
590
  if (error) { if (!res.destroyed) reject(failure || new HttpError(503,'Backend request unavailable',{transport})); else resolve(); return }
556
591
  const safe = { 'cache-control':'no-store' }
@@ -562,7 +597,7 @@ export function nativeAuthBridge(raw, refused, serviceKey, report, ownSession, r
562
597
  if (declared) for (const [key, value] of Object.entries(backend.headers)) {
563
598
  if ((key === 'link' || key === 'content-language' || key.startsWith('x-') && key !== 'x-request-id') && typeof value === 'string' && value.length <= 8192) safe[key] = value
564
599
  }
565
- const cookies = nativeCookies(backend.headers['set-cookie'])
600
+ const cookies = vitals ? [] : nativeCookies(backend.headers['set-cookie'])
566
601
  if (cookies.length) safe['set-cookie'] = cookies
567
602
  if (backend.statusCode >= 500 && !res.$capture) res.$brainCause = 'answered by the native backend'
568
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) }
@@ -584,8 +619,9 @@ export function nativeAuthBridge(raw, refused, serviceKey, report, ownSession, r
584
619
  backend.once('end',()=>finish(false,backend)); backend.once('error',e=>{transport=e.code;finish(true)}); backend.once('aborted',()=>{transport='BACKEND_ABORTED';finish(true)})
585
620
  })
586
621
  owned.add(outgoing)
587
- 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)
588
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})
589
625
  req.on('data',data); req.once('end',end)
590
626
  if (req.readableEnded) end()
591
627
  } catch { outgoing?.destroy(); finish(true) }
@@ -608,7 +644,7 @@ export function nativeAuthBridge(raw, refused, serviceKey, report, ownSession, r
608
644
  // Reaches the request helper of a resource client without adding a method anyone can see on it.
609
645
  const OPERATION_CALL = Symbol('operation')
610
646
  const resource = (incoming, response, rid, name, serviceKey) => {
611
- 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' })
612
648
  const idPath = id => {
613
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')
614
650
  return '/api/' + name + '/' + id
@@ -618,13 +654,14 @@ export function nativeAuthBridge(raw, refused, serviceKey, report, ownSession, r
618
654
  if (method !== 'GET' && incoming.method !== 'POST') throw new HttpError(405, 'Native writes require a server function called through POST')
619
655
  let bytes = Buffer.alloc(0)
620
656
  if (body !== undefined) {
621
- if (!body || typeof body !== 'object' || Array.isArray(body)) throw new HttpError(400, 'Native resource values must be an object')
622
- 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' }) }
623
659
  if (bytes.length > 65536) throw new HttpError(413, 'Native resource values exceed their byte budget')
624
660
  }
625
661
  const outgoing = () => {
626
662
  const req = Readable.from(bytes.length ? [bytes] : [])
627
663
  req.method = method
664
+ req.$brainWarmupSignal = incoming.$brainWarmupSignal
628
665
  req.$brainForwardedFor = forwardedFor(incoming)
629
666
  // A service call acts as the key's owner, never as the visitor: it sends no session.
630
667
  req.headers = { ...(serviceKey ? { authorization: 'Bearer ' + serviceKey } : ownSession ? {} : { cookie: cookies(incoming, false) }), accept: 'application/json', 'content-type':'application/json', 'content-length':String(bytes.length) }
@@ -685,7 +722,7 @@ export function nativeAuthBridge(raw, refused, serviceKey, report, ownSession, r
685
722
  } catch (e) {
686
723
  const transient = ['ECONNREFUSED', 'ECONNRESET'].includes(e?.transport) || askAgain.has(e)
687
724
  const pause = Math.max(wait, (e?.retryAfter || 0) * 1000)
688
- 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
689
726
  req.destroy()
690
727
  await new Promise(done => setTimeout(done, pause))
691
728
  if (stopped || response.destroyed) throw e
@@ -711,7 +748,7 @@ export function nativeAuthBridge(raw, refused, serviceKey, report, ownSession, r
711
748
  const names = (value, label) => {
712
749
  const list = typeof value === 'string' ? value.split(',') : value
713
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}$/
714
- 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` })
715
752
  return list.join(',')
716
753
  }
717
754
  return Object.freeze({
@@ -719,30 +756,34 @@ export function nativeAuthBridge(raw, refused, serviceKey, report, ownSession, r
719
756
  // the HTTP list contract (docs/backend/filters.md, sorting.md, pagination.md): the backend validates declared
720
757
  // filters and sorts and answers 422 {errors}; this only rejects shapes the query string cannot express
721
758
  list(options = {}) {
722
- 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' })
723
760
  const {search, limit, offset, filter, sort, cursor, after, include, aggregate, count, fields} = options
724
- 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)
725
766
  const q = new URLSearchParams()
726
767
  if (filter !== undefined) {
727
- 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' })
728
769
  const scalar = value => {
729
770
  if (typeof value === 'number' && Number.isFinite(value)) return String(value)
730
771
  if (typeof value === 'boolean') return String(value)
731
772
  if (typeof value === 'string' && Buffer.byteLength(value) <= 256) return value
732
- throw new HttpError(400, 'Invalid native filter value')
773
+ throw nativeInput('Invalid native filter value', { filter: 'Invalid native filter value' })
733
774
  }
734
775
  for (const [field, condition] of Object.entries(filter)) {
735
- 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' })
736
777
  const ops = condition && typeof condition === 'object' && !Array.isArray(condition) ? Object.entries(condition) : [['eq', condition]]
737
- 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' })
738
779
  for (const [op, value] of ops) {
739
- 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' })
740
781
  let raw
741
782
  if (op === 'in' || op === 'between' || op === 'has') {
742
783
  const items = Array.isArray(value) ? value : [value]
743
- 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' })
744
785
  raw = items.map(scalar)
745
- 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' })
746
787
  raw = raw.join(',')
747
788
  } else raw = scalar(value)
748
789
  q.append(op === 'eq' ? field : `${field}[${op}]`, raw)
@@ -754,24 +795,24 @@ export function nativeAuthBridge(raw, refused, serviceKey, report, ownSession, r
754
795
  if (offset !== undefined) q.set('offset', String(offset))
755
796
  if (sort !== undefined) {
756
797
  const keys = typeof sort === 'string' ? sort.split(',') : sort
757
- 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' })
758
799
  q.set('sort', keys.join(','))
759
800
  }
760
- 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' })
761
802
  for (const [key, value] of [['cursor', cursor], ['after', after]]) if (value !== undefined) {
762
- 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' })
763
804
  q.set(key, value)
764
805
  }
765
806
  for (const [key, value] of [['include', include], ['aggregate', aggregate], ['count', count], ['fields', fields]]) if (value !== undefined) q.set(key, names(value, key))
766
807
  if (options.owner !== undefined) {
767
- 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" })
768
809
  q.set('owner', 'me')
769
810
  }
770
811
  const query = q.toString()
771
812
  return request('GET', '/api/' + name + (query ? '?' + query : ''))
772
813
  },
773
814
  get(id, options = {}) {
774
- 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' })
775
816
  const path = idPath(id)
776
817
  return request('GET', options.include === undefined ? path : path + '?' + new URLSearchParams({include: names(options.include, 'include')}))
777
818
  },
@@ -916,7 +957,7 @@ export function nativeAuthBridge(raw, refused, serviceKey, report, ownSession, r
916
957
  slugRedirect: (name, slug) => resource(incoming, response, rid, name).slugRedirect(slug),
917
958
  // A declared operation, as this visitor (docs/backend/transactions.md).
918
959
  operation: (name, input = {}) => {
919
- 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' })
920
961
  return resource(incoming, response, rid, 'operations')[OPERATION_CALL](name, input)
921
962
  },
922
963
  login: (credentials, options) => login(incoming, response, rid, credentials, options),
@@ -966,7 +1007,7 @@ export function nativeAuthBridge(raw, refused, serviceKey, report, ownSession, r
966
1007
  export function createServer(o = {}) {
967
1008
  const dist = resolve(o.dist || process.cwd())
968
1009
  // ctx.env: what the supervisor passed, read-only; the service key stays with ctx.backend.service
969
- 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
970
1011
  const env = Object.freeze(visibleEnv)
971
1012
  const backendSession = o.backendSession ?? env.BRAIN_BACKEND_SESSION
972
1013
  if (backendSession && backendSession !== 'app') throw Error("BRAIN_BACKEND_SESSION must be app, or unset for the backend's own session")
@@ -1035,6 +1076,14 @@ export function createServer(o = {}) {
1035
1076
  return out
1036
1077
  }
1037
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(() => {})
1038
1087
  // The build policy controls attribution independently of CSP and other security headers.
1039
1088
  let compilerBranding=mf.compilerBranding!==false
1040
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{}
@@ -1060,6 +1109,12 @@ export function createServer(o = {}) {
1060
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')
1061
1110
  // the build's single route table (#187): already in match order, each with its RegExp source (named groups = params)
1062
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))
1063
1118
  // The address RPC and form limits key on, ctx.req.ip and the logs: a client header counts only through a trusted proxy.
1064
1119
  const proxies = trustedProxies(o.trustProxy ?? (env.BRAIN_TRUST_PROXY ? env.BRAIN_TRUST_PROXY.split(',').map(entry => entry.trim()) : mf.trustProxy) ?? [])
1065
1120
  let proxyWarned = false
@@ -1124,6 +1179,9 @@ export function createServer(o = {}) {
1124
1179
  // Base path (§23 #148): the app lives under mf.base ("/app/"); routes, files and functions are matched on the app
1125
1180
  // path (base stripped); redirects the app gives as app paths get it back.
1126
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' })
1127
1185
  const analyticsTag = native && (o.analytics ?? env.BRAIN_ANALYTICS) !== 'off' && o.analytics !== false ? `<script type="module" async src="${BASE}_brain/analytics.js"></script>` : ''
1128
1186
  const RD = mf.redirects || {}, RDX = (RD.patterns || []).map(p => ({ re: new RegExp(p.re), to: p.to, status: p.status || 301 }))
1129
1187
  // `/old/` and `/old` are the same source (brain-seo normalizes the trailing slash)
@@ -1186,7 +1244,7 @@ export function createServer(o = {}) {
1186
1244
  if (!kind || kind.startsWith('text/plain')) {
1187
1245
  const rid = res.getHeader('x-request-id') || ''
1188
1246
  if (wantsHtml(res.req)) {
1189
- body = defaultError(status, rid, BASE)
1247
+ body = errorDocument(res.req, status, rid)
1190
1248
  headers['content-type'] = MIME['.html']
1191
1249
  headers['content-security-policy'] = mf.csp || CSP0
1192
1250
  } else {
@@ -1237,7 +1295,7 @@ export function createServer(o = {}) {
1237
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'] })
1238
1296
  const custom = (status === 404 && mf.notFound) || (mf.errors && mf.errors[status]) || (status === 410 && !file('410.html') ? mf.notFound : `${status}.html`)
1239
1297
  const path = typeof custom === 'string' && file(custom)
1240
- 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))
1241
1299
  }
1242
1300
  // `/403`, `/404.html`, `/503`…: the status when the app has that page, else 0 and the path is an ordinary one
1243
1301
  const reservedStatus = pathname => {
@@ -1268,7 +1326,7 @@ export function createServer(o = {}) {
1268
1326
  // runs it before `brain:ready`, so the first request after a (re)start finds them loaded instead of reading,
1269
1327
  // compiling and evaluating the app's module graph (and its packages) while the visitor waits. A module that throws
1270
1328
  // stays rejected in `mods`, exactly as the request that imports it would have found it; it is counted, never fatal.
1271
- const warm = async () => {
1329
+ const warm = async signal => {
1272
1330
  const want = new Set(['_server/dom.js', mf.core])
1273
1331
  const add = r => {
1274
1332
  if (!r || typeof r !== 'object') return
@@ -1283,6 +1341,7 @@ export function createServer(o = {}) {
1283
1341
  for (const m of ['_server/brain-hydrate.js', '_server/brain-form.js']) if (file(m)) want.add(m)
1284
1342
  let failed = 0
1285
1343
  for (const rel of want) {
1344
+ if (signal?.aborted) break
1286
1345
  try {
1287
1346
  await load(rel)
1288
1347
  } catch {
@@ -1295,13 +1354,16 @@ export function createServer(o = {}) {
1295
1354
  const context = (req, res, url, extra) => {
1296
1355
  const cookies = parseCookies(req.headers.cookie)
1297
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)
1298
- 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
1299
1361
  return {
1300
- url, query: Object.fromEntries(url.searchParams), cookies, user, session: ses, env,
1301
- ...(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 } : {}),
1302
1364
  // `prefetch`: the router fetched this page ahead of a click (§19); a `load` with side effects can skip them
1303
1365
  prefetch: req.headers['x-brain-prefetch'] === '1' || /\bprefetch\b/.test(req.headers['sec-purpose'] ?? ''),
1304
- 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,
1305
1367
  }
1306
1368
  }
1307
1369
 
@@ -1315,8 +1377,20 @@ export function createServer(o = {}) {
1315
1377
  const FILLING = new Map()
1316
1378
  const SSRC = new Map(), SSRC_MAX = 500, SSRC_BYTES = 32 * 1024 * 1024, SSRC_ENTRY = 1024 * 1024
1317
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
+ }
1318
1391
  async function cached(req, res, route, params, url, rid) {
1319
- 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
1320
1394
  // `kept.answer`: what this render answered when it was not a 200 (an error page, a redirect, a not-found), so the
1321
1395
  // request that started it replays that answer instead of running load() and its backend reads a second time
1322
1396
  const fill = (kept = {}) => {
@@ -1324,30 +1398,24 @@ export function createServer(o = {}) {
1324
1398
  const set = {}
1325
1399
  return new Promise(ok => {
1326
1400
  const cap = { req, $capture: (out, h, st) => {
1327
- const headers = Object.fromEntries(Object.entries(h).map(([k, v]) => [k.toLowerCase(), v]))
1328
- 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 || ''))
1329
1403
  if (st >= 500) failed = true
1330
1404
  if (st !== 200) kept.answer = { page: out, h, st, set }
1331
1405
  ok(st === 200 ? { out, h, cacheable: !privateResponse, etag: 'W/"' + tag(out) + '"', t: Date.now(), bytes: Buffer.byteLength(out) } : null)
1332
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() {} }
1333
1407
  ssr(req, cap, route, params, url, rid, null, 200, null, true).catch(() => ok(null))
1334
- }).then(c => {
1408
+ }).then(async c => {
1335
1409
  // A failed revalidation keeps the copy until 2N, which is what `stale-if-error` promises downstream.
1336
1410
  if ((!c || !c.cacheable) && (failed || req.$brainFailedReads) && SSRC.has(key)) return c
1337
1411
  if (!c || !c.cacheable) {
1338
1412
  ssrBytes -= SSRC.get(key)?.bytes || 0
1339
1413
  SSRC.delete(key)
1414
+ await redisCache?.delete(redisKey)
1340
1415
  }
1341
1416
  if (c && c.cacheable && c.bytes <= SSRC_ENTRY) {
1342
- ssrBytes -= SSRC.get(key)?.bytes || 0
1343
- SSRC.delete(key)
1344
- SSRC.set(key, c)
1345
- ssrBytes += c.bytes
1346
- while (SSRC.size > SSRC_MAX || ssrBytes > SSRC_BYTES) {
1347
- const oldest = SSRC.keys().next().value
1348
- ssrBytes -= SSRC.get(oldest).bytes
1349
- SSRC.delete(oldest)
1350
- }
1417
+ rememberSSR(key, c)
1418
+ await redisCache?.set(redisKey, c, n)
1351
1419
  }
1352
1420
  return c
1353
1421
  })
@@ -1364,13 +1432,27 @@ export function createServer(o = {}) {
1364
1432
  const running = FILLING.get(key)
1365
1433
  if (running) {
1366
1434
  c = await running
1435
+ if (req.aborted || res.destroyed) return
1367
1436
  if (c?.cacheable) return serve(c)
1368
1437
  return ssr(req, res, route, params, url, rid, null, 200, null, true)
1369
1438
  }
1370
1439
  const kept = {}
1371
- 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))
1372
1453
  FILLING.set(key, filling)
1373
1454
  c = await filling
1455
+ if (req.aborted || res.destroyed) return
1374
1456
  if (c) return serve(c)
1375
1457
  // the first render already asked the backend and logged why; another would only spend more of a limit it just hit
1376
1458
  if (req.$brainFailedReads?.temporary) return comeBackPage(req, res, route, params, url, rid)
@@ -1390,7 +1472,7 @@ export function createServer(o = {}) {
1390
1472
  res.setHeader('cache-control', 'no-store')
1391
1473
  return errorPage(req, res, route, params, url, rid, 503)
1392
1474
  }
1393
- const anonymous = req => (req.method === 'GET' || req.method === 'HEAD') && !req.headers.authorization && !req.headers.cookie
1475
+ const anonymous = req => publicCacheRequest(req)
1394
1476
 
1395
1477
  const preloadHints = new WeakMap()
1396
1478
  async function ssr(req, res, route, params, url, rid, forms = null, status = 200, fixed = null, nocache = false) {
@@ -1606,7 +1688,10 @@ export function createServer(o = {}) {
1606
1688
  }
1607
1689
  // the head is already out: the folder's _error.brain (props { message, rid }) renders inside the region, else a notice
1608
1690
  async function failBody(route, url, rid) {
1609
- 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>`
1610
1695
  if (!route.error) return notice
1611
1696
  try {
1612
1697
  const props = { message: 'Something went wrong.', rid }, at = { path: url.pathname, params: {}, query: {} }
@@ -1810,7 +1895,7 @@ export function createServer(o = {}) {
1810
1895
  DYN.forEach((r, n) => {
1811
1896
  for (const p of lists[n]) {
1812
1897
  const path = fill(r.path, p)
1813
- if (!path) continue
1898
+ if (!path || !r.rx.test(path)) continue
1814
1899
  const loc = origin + withBase(path)
1815
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) })
1816
1901
  else if (urls.length < MAX_URLS) urls.push({ loc, lastmod: day(p.lastmod) })
@@ -2090,6 +2175,14 @@ export function createServer(o = {}) {
2090
2175
  if (ms > 1000) log('warn', 'slow request', { rid, path: req.$brainPath || String(req.url || '').split('?')[0], status: res.statusCode, ms: Math.round(ms) })
2091
2176
  })
2092
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
+ }
2093
2186
  // One URL per page: `//route`, `/a//b` and `/route/index.html` answer one 301 on this origin. The path is rewritten
2094
2187
  // as text, before URL parsing: `//host/x` would be read as another host. Backend paths are left to the backend.
2095
2188
  const variant = urlVariant(req)
@@ -2115,6 +2208,15 @@ export function createServer(o = {}) {
2115
2208
  url = new URL('/' + full.pathname.slice(BASE.length) + full.search, full) // the app path
2116
2209
  }
2117
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
+ }
2118
2220
  const probe = brainProbe(url.pathname)
2119
2221
  if (probe) return await answerProbe(req, res, new URL(probe, url), rid)
2120
2222
  if (native && /^\/_brain\/files(?:\/|$)/.test(url.pathname)) return await native.fileStream(req,res,url,rid)
@@ -2189,13 +2291,49 @@ export function createServer(o = {}) {
2189
2291
  }
2190
2292
  }
2191
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
+ }
2192
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
+ }
2193
2312
  if (native && nativeSockets) server.$brainSocketStop = installNativeSockets(server,{target:o.backendURL ?? env.BRAIN_BACKEND_URL,base:BASE,cookie:native._socketCookie,client:native._forwardedFor,log})
2194
2313
  server.headersTimeout = 15000
2195
2314
  // With a native backend the file upload deadline is enforced per request (above); 30 s stays for every other body.
2196
2315
  server.requestTimeout = native ? 320000 : 30000
2197
2316
  // `warm: true` (`node server.mjs`): import the app's modules now; requests that arrive meanwhile share the same imports
2198
- 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
+ }
2199
2337
  return server
2200
2338
  }
2201
2339
 
@@ -2433,6 +2571,7 @@ Object.defineProperty(globalThis, '__brainLocale', { configurable: true, get: ()
2433
2571
  // ctx.signal: aborted when the client disconnects before the answer ends or the function passes its deadline, so the
2434
2572
  // work it started (fetch, a query) stops with it.
2435
2573
  const aborts = new WeakMap()
2574
+ const warmSignals = new WeakMap()
2436
2575
  const newSignal = () => {
2437
2576
  const c = new AbortController()
2438
2577
  aborts.set(c.signal, c)
@@ -2444,6 +2583,12 @@ const lifetime = res => {
2444
2583
  return signal
2445
2584
  }
2446
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
+ }
2447
2592
  let t
2448
2593
  return Promise.race([p, new Promise((_, rej) => (t = setTimeout(() => {
2449
2594
  aborts.get(ctx?.signal)?.abort()
@@ -2948,13 +3093,32 @@ if (process.argv[1] && real(process.argv[1]) === real(fileURLToPath(import.meta.
2948
3093
  console.error(json({ t: new Date().toISOString(), level: 'error', msg: `cannot listen on ${where.host}:${port}: ${cause}`, fix }))
2949
3094
  process.exit(1)
2950
3095
  })
2951
- 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()
2952
3115
  // SIGTERM/SIGINT: stop accepting, answer what is in flight or arrives on open connections (each then closes), for up
2953
3116
  // to BRAIN_DRAIN_MS (10 s), then cut whatever is left; exit 0 either way
2954
3117
  const drain = () => {
2955
3118
  if (s.draining) return
2956
3119
  s.draining = true
2957
3120
  s.$brainSocketStop?.()
3121
+ s.$brainCacheStop?.()
2958
3122
  const ms = +(ENV.BRAIN_DRAIN_MS || 10000)
2959
3123
  const force = setTimeout(() => {
2960
3124
  s.closeAllConnections()