@christianirack/brainllm-runtime 0.9.0 → 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/brain-analytics.js +29 -5
- package/brain-consent.js +78 -17
- package/brain-google.js +113 -0
- package/brain-lazy.js +4 -1
- package/brain-native-vitals.js +78 -0
- package/brain-sentry.js +71 -0
- package/brain-vitals.js +8 -2
- package/brain.js +3 -2
- package/package.json +9 -2
- package/server/redis-cache.mjs +173 -0
- package/server/sentry.mjs +156 -0
- package/server/server.mjs +224 -60
- package/server/warmup-watchdog.mjs +9 -0
- package/server/warmup.mjs +134 -0
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
307
|
-
if (typeof data !== 'string' && !(data instanceof Uint8Array)) throw
|
|
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
|
|
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
|
|
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
|
|
622
|
-
try { bytes = Buffer.from(JSON.stringify(body)) } catch { throw
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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') :
|
|
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:
|
|
1301
|
-
...(
|
|
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 || ''
|
|
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
|
-
|
|
1328
|
-
const privateResponse =
|
|
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
|
-
|
|
1343
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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()
|