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