@christianirack/brainllm-runtime 0.8.2 → 0.8.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/brain-analytics.js +6 -2
- package/brain-form.js +8 -2
- package/brain-inspect.js +7 -2
- package/brain-lazy.js +2 -0
- package/package.json +1 -1
- package/server/server.mjs +160 -29
package/brain-analytics.js
CHANGED
|
@@ -70,13 +70,17 @@ export function start(config = {}, env = globalThis) {
|
|
|
70
70
|
)
|
|
71
71
|
|
|
72
72
|
// ── identity: anonymous first-party visitor (localStorage) and a session that ends after 30 idle minutes
|
|
73
|
+
// A session belongs to one visitor (the server refuses it from another), so without storage the visitor id lives in
|
|
74
|
+
// memory for the page, and a stored session whose visitor is gone or different starts over.
|
|
73
75
|
let session = null
|
|
76
|
+
let visitor = null
|
|
74
77
|
const ids = () => {
|
|
75
78
|
const t = Date.now()
|
|
76
79
|
let vid = get(VISITOR)
|
|
77
|
-
if (typeof vid
|
|
80
|
+
if (typeof vid === 'string') visitor = vid
|
|
81
|
+
else put(VISITOR, (vid = visitor = visitor || uuid()))
|
|
78
82
|
if (!session) session = get(SESSION)
|
|
79
|
-
if (!session || typeof session.id !== 'string' || t - session.last > IDLE_MS) session = { id: uuid(), last: t, pv: 0, ctx: 0 }
|
|
83
|
+
if (!session || typeof session.id !== 'string' || session.vid !== vid || t - session.last > IDLE_MS) session = { id: uuid(), vid, last: t, pv: 0, ctx: 0 }
|
|
80
84
|
session.last = t
|
|
81
85
|
put(SESSION, session)
|
|
82
86
|
return vid
|
package/brain-form.js
CHANGED
|
@@ -112,7 +112,7 @@ function remoteCheck(o, x, checking) {
|
|
|
112
112
|
}
|
|
113
113
|
function createForm(schema, o, readRule, validateRule, remote) {
|
|
114
114
|
const id = 'f' + ++seq, owner = I.owner() // aria effects are created later (in handlers) but live with the component
|
|
115
|
-
const busy = sig(false), error = sig(''), shown = sig(0) // shown: bumped when touched set changes
|
|
115
|
+
const busy = sig(false), error = sig(''), status = sig(0), shown = sig(0) // shown: bumped when touched set changes
|
|
116
116
|
const checking = sig(0), saved = sig('') // async checks running · JSON of the last saved values
|
|
117
117
|
const F = [] // flat fields: { path, r, s (value sig), e (error sig), el, touched }
|
|
118
118
|
const byPath = {}
|
|
@@ -230,6 +230,9 @@ function createForm(schema, o, readRule, validateRule, remote) {
|
|
|
230
230
|
get error() {
|
|
231
231
|
return error.v
|
|
232
232
|
},
|
|
233
|
+
get status() {
|
|
234
|
+
return status.v
|
|
235
|
+
},
|
|
233
236
|
get dirty() {
|
|
234
237
|
return now() !== saved.v
|
|
235
238
|
},
|
|
@@ -277,6 +280,7 @@ function createForm(schema, o, readRule, validateRule, remote) {
|
|
|
277
280
|
if (!first && x.e.v) first = x
|
|
278
281
|
}
|
|
279
282
|
error.v = ''
|
|
283
|
+
status.v = 0
|
|
280
284
|
shown.v++
|
|
281
285
|
})
|
|
282
286
|
if (o.validate && !first) {
|
|
@@ -305,6 +309,7 @@ function createForm(schema, o, readRule, validateRule, remote) {
|
|
|
305
309
|
}
|
|
306
310
|
return r
|
|
307
311
|
} catch (x) {
|
|
312
|
+
status.v = x?.status || 0
|
|
308
313
|
if (x?.errors) mark(x.errors)
|
|
309
314
|
// a server failure reads the same as without JS: 4xx and the author's own errors keep their message; 5xx and a
|
|
310
315
|
// TypeError (the network dropped, or a bug) get the generic one; the runtime's own English sentence (`plain`) gives
|
|
@@ -318,7 +323,8 @@ function createForm(schema, o, readRule, validateRule, remote) {
|
|
|
318
323
|
},
|
|
319
324
|
}
|
|
320
325
|
DIRTY.add(f)
|
|
321
|
-
|
|
326
|
+
// outside the fx: the listener outlives the form on purpose, one per page, and is not this effect's to clean up
|
|
327
|
+
const watch = () => fx(() => void (f.dirty && queueMicrotask(guardUnload)))
|
|
322
328
|
owner ? I.root(watch, owner) : watch()
|
|
323
329
|
if (owner) I.root(() => fx(() => () => DIRTY.delete(f)), owner)
|
|
324
330
|
return f
|
package/brain-inspect.js
CHANGED
|
@@ -264,6 +264,9 @@ const fin = G.FinalizationRegistry ? new FinalizationRegistry(w => (w.dead = 1))
|
|
|
264
264
|
// Where a pending request went, without origin or query: evidence for a fix, never a value.
|
|
265
265
|
const pendingUrls = () => [...inflight.values()].slice(0, 3).map(r => `${r.m} ${r.url.replace(/^[a-z]+:\/\/[^/]+/i, '').split(/[?#]/)[0]}`)
|
|
266
266
|
const SETTLE_MS = 60000
|
|
267
|
+
// While a suite holds the verdict, an instance only ages from its release: a promise or a script load that begins after
|
|
268
|
+
// the unmount is invisible to the network watch and keeps the instance alive for a bounded time.
|
|
269
|
+
let held_verdict = false
|
|
267
270
|
function watch(d) {
|
|
268
271
|
if (!fin) return
|
|
269
272
|
// its first DOM node: an app that keeps a detached node (a module array, a cache) is the leak to catch. The browser
|
|
@@ -279,11 +282,13 @@ function checkLeaks() {
|
|
|
279
282
|
for (let j = watched.length - 1; j >= 0; j--) {
|
|
280
283
|
const w = watched[j]
|
|
281
284
|
if (w.dead) watched.splice(j, 1)
|
|
285
|
+
else if (held_verdict) w.at = gcs
|
|
282
286
|
// A request from before the unmount may be what holds it: judge after it settles (bounded, for a long poll).
|
|
283
287
|
else if (w.net.some(id => inflight.has(id)) && now() - w.since < SETTLE_MS) w.at = gcs
|
|
284
288
|
else if (gcs - w.at >= 2) {
|
|
285
289
|
watched.splice(j, 1)
|
|
286
|
-
const
|
|
290
|
+
const finished = !w.net.some(id => inflight.has(id))
|
|
291
|
+
const waited = w.urls.length ? `${w.urls.length > 2 ? 'several requests' : w.urls.join(', ')} ${w.urls.length > 1 ? 'were' : 'was'} in flight when it unmounted${finished ? ' and finished, so look past it' : ''}; ` : ''
|
|
287
292
|
leak({ code: 'B0601', h: w.h, name: w.name, file: w.file, msg: `unmounted ${w.name} instance still alive after 2 GC cycles`, fix: w.fx.length ? 'add cleanup to the listed fx: they retain the instance' : `${waited}look for what still references its nodes or signals: an in-flight promise (a pending server call, \`.then\` or \`await\`) or timer that writes to the component, global listeners, caches, module closures`, fx: w.fx })
|
|
288
293
|
}
|
|
289
294
|
}
|
|
@@ -708,7 +713,7 @@ export function inspect(o) {
|
|
|
708
713
|
G.document.addEventListener.call(G.document, 'keydown', e => e.altKey && e.shiftKey && e.code === 'KeyB' && toggle())
|
|
709
714
|
return api
|
|
710
715
|
}
|
|
711
|
-
const api = { tick, drain, comps, onLoaf, onEvents, leaks: () => leaks.slice(), timers: () => [...timers.values()], toggle: () => toggle(), inflight: () => [...inflight.values()].map(r => `${r.m} ${r.url}`), abortInflight: () => { for (const r of inflight.values()) if (r.stop) r.stop() } }
|
|
716
|
+
const api = { tick, drain, comps, onLoaf, onEvents, leaks: () => leaks.slice(), timers: () => [...timers.values()], toggle: () => toggle(), inflight: () => [...inflight.values()].map(r => `${r.m} ${r.url}`), abortInflight: () => { for (const r of inflight.values()) if (r.stop) r.stop() }, holdVerdict: on => (held_verdict = !!on), alive: () => watched.filter(w => !w.dead).length }
|
|
712
717
|
|
|
713
718
|
// ---------- mini panel (Alt+Shift+B): heap, leaks and link to the full debugger (§9.1) ----------
|
|
714
719
|
let panel = null
|
package/brain-lazy.js
CHANGED
|
@@ -75,6 +75,8 @@ export function lazy(el, imp, mode, props = {}) {
|
|
|
75
75
|
stop = () => {
|
|
76
76
|
for (const t of ACT) el.removeEventListener(t, f, t === 'click')
|
|
77
77
|
}
|
|
78
|
+
} else if (G.document && document.visibilityState === 'hidden') {
|
|
79
|
+
go() // a hidden tab runs no observer: without this the content is missing until the tab is shown
|
|
78
80
|
} else if (G.IntersectionObserver) {
|
|
79
81
|
const io = new IntersectionObserver(es => es.some(e => e.isIntersecting) && go(), { rootMargin: '200px' })
|
|
80
82
|
io.observe(el)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@christianirack/brainllm-runtime",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.4",
|
|
4
4
|
"description": "The BRAINLLM runtime for the Rust-built web compiler for LLMs: dependency-free ES modules (signals, DOM, router, data, forms, channels). The brain binary embeds this same version.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
package/server/server.mjs
CHANGED
|
@@ -81,6 +81,7 @@ export class HttpError extends Error {
|
|
|
81
81
|
Object.assign(this, extra)
|
|
82
82
|
}
|
|
83
83
|
}
|
|
84
|
+
const bridgeBusy = message => new HttpError(503, message, { code: 'BRIDGE_BUSY', retryAfter: 1, fix: 'The server is at its limit of concurrent backend requests; retry after the Retry-After delay' })
|
|
84
85
|
// Plain deliberate 4xx signals are portable from server-only application modules; exception messages stay private.
|
|
85
86
|
function intendedStatus(error) {
|
|
86
87
|
if (!error || typeof error !== 'object') return null
|
|
@@ -179,6 +180,21 @@ export function acceptedEncodings(header = '', order = ['br', 'zstd', 'gzip']) {
|
|
|
179
180
|
// already has an encoding, a `no-transform` cache policy or a status without content is sent as is.
|
|
180
181
|
const COMPRESSIBLE = /^(?:text\/|image\/svg\+xml|application\/(?:json|javascript|xml|[\w.-]+\+(?:json|xml)))/i
|
|
181
182
|
const DYNAMIC = ['zstd', 'br', 'gzip']
|
|
183
|
+
// Each Vary token once: a relayed backend header and the additions here must not repeat one (tokens are case-insensitive).
|
|
184
|
+
const varyOnce = v => {
|
|
185
|
+
const seen = new Set()
|
|
186
|
+
return String(v).split(',').map(t => t.trim()).filter(t => t && !seen.has(t.toLowerCase()) && seen.add(t.toLowerCase())).join(', ')
|
|
187
|
+
}
|
|
188
|
+
// Parameters that do not make a different page: the list of `<meta seo query>` (brain-check seo.rs, B0503).
|
|
189
|
+
const TRACKING = /^(?:utm_.*|gclid|fbclid|msclkid)$/
|
|
190
|
+
const cacheSearch = url => {
|
|
191
|
+
const kept = new URLSearchParams([...url.searchParams].filter(([key]) => !TRACKING.test(key)))
|
|
192
|
+
kept.sort()
|
|
193
|
+
return kept.size ? '?' + kept : ''
|
|
194
|
+
}
|
|
195
|
+
// Messages this file writes itself, safe to log as the cause; anything else in an error message may hold private data.
|
|
196
|
+
const OWN_CAUSES = new Set(['Backend request unavailable', 'Backend request timed out', 'Backend response exceeds its byte budget', 'Backend request capacity is unavailable', 'Backend stream capacity is unavailable', 'Native file request failed', 'Native file response is invalid', 'Native authentication is temporarily unavailable', 'Native resource response must be JSON', 'Native resource response is invalid', 'Native resource request failed', 'Invalid final response status', 'Invalid configured redirect location', 'timeout'])
|
|
197
|
+
const CODE_SHAPE = /^[A-Z][A-Z0-9_]{0,63}$/
|
|
182
198
|
const varyEncoding = v => (/accept-encoding/i.test(v || '') ? v : [v, 'Accept-Encoding'].filter(Boolean).join(', '))
|
|
183
199
|
function bodyEncoding(req, status, headers) {
|
|
184
200
|
if (status < 200 || status === 204 || status === 206 || status === 304 || headers['content-encoding'] || !COMPRESSIBLE.test(headers['content-type'] || '') || /no-transform/i.test(headers['cache-control'] || '')) return null
|
|
@@ -250,7 +266,7 @@ function bucketClient(target, key, bucket) {
|
|
|
250
266
|
async remove(id) { await send('DELETE', idPath(id), undefined, 10000); return null },
|
|
251
267
|
})
|
|
252
268
|
}
|
|
253
|
-
export function nativeAuthBridge(raw, refused, serviceKey, report) {
|
|
269
|
+
export function nativeAuthBridge(raw, refused, serviceKey, report, ownSession) {
|
|
254
270
|
// How to fix the configuration is for the operator: a visitor of a production app reads a generic message.
|
|
255
271
|
const configuration = (status, detail, rid, shown = 'This service is not available right now') => {
|
|
256
272
|
if (process.env.NODE_ENV !== 'production') return new HttpError(status, detail)
|
|
@@ -412,7 +428,7 @@ export function nativeAuthBridge(raw, refused, serviceKey, report) {
|
|
|
412
428
|
const nativeCookies = values => (values || []).filter(value => /^(?:__Host-brain(?:-access|-refresh|-google)?)=/.test(value) && value.length <= 8192).slice(0,8)
|
|
413
429
|
// /_brain/files: both directions stream with backpressure. The bucket enforces its own maxBytes and access checks.
|
|
414
430
|
function fileStream(req, res, url, rid) {
|
|
415
|
-
if (stopped || streams >= 32) throw
|
|
431
|
+
if (stopped || streams >= 32) throw bridgeBusy('Backend stream capacity is unavailable')
|
|
416
432
|
if (!['GET','HEAD','POST','DELETE'].includes(req.method)) throw new HttpError(405, 'Method not allowed')
|
|
417
433
|
const ceiling = 2 * 1024 * 1024 * 1024 + 1024 * 1024
|
|
418
434
|
if (Number(req.headers['content-length'] || 0) > ceiling) throw new HttpError(413, 'Request body is too large')
|
|
@@ -430,7 +446,7 @@ export function nativeAuthBridge(raw, refused, serviceKey, report) {
|
|
|
430
446
|
}
|
|
431
447
|
const closed = () => finish()
|
|
432
448
|
const count = chunk => { sent += chunk.length; if (sent > ceiling) finish(new HttpError(413, 'Request body is too large')) }
|
|
433
|
-
const unavailable =
|
|
449
|
+
const unavailable = transport => new HttpError(503, 'Backend request unavailable', { transport })
|
|
434
450
|
try {
|
|
435
451
|
outgoing = httpRequest(new URL(url.pathname + url.search, target), { agent:streamAgent, method:req.method, headers:requestHeaders(req, rid, FILE_HEADERS) }, backend => {
|
|
436
452
|
const safe = { 'cache-control':'no-store' }
|
|
@@ -440,9 +456,11 @@ export function nativeAuthBridge(raw, refused, serviceKey, report) {
|
|
|
440
456
|
}
|
|
441
457
|
const cookies = nativeCookies(backend.headers['set-cookie'])
|
|
442
458
|
if (cookies.length) safe['set-cookie'] = cookies
|
|
459
|
+
if (safe.vary) safe.vary = varyOnce(safe.vary)
|
|
460
|
+
if (backend.statusCode >= 500) res.$brainCause = 'answered by the native backend'
|
|
443
461
|
res.writeHead(backend.statusCode || 502, safe)
|
|
444
|
-
backend.once('error',
|
|
445
|
-
backend.once('aborted', () => finish(unavailable()))
|
|
462
|
+
backend.once('error', e => finish(unavailable(e.code)))
|
|
463
|
+
backend.once('aborted', () => finish(unavailable('BACKEND_ABORTED')))
|
|
446
464
|
backend.once('end', () => { res.end(() => finish()) })
|
|
447
465
|
backend.pipe(res)
|
|
448
466
|
})
|
|
@@ -451,7 +469,7 @@ export function nativeAuthBridge(raw, refused, serviceKey, report) {
|
|
|
451
469
|
outgoing.setTimeout(60000, () => finish(new HttpError(504, 'Backend request timed out')))
|
|
452
470
|
// The backend's own upload deadline is 300 s; nothing else needs a total limit after the headers.
|
|
453
471
|
if (req.method === 'POST') deadline = setTimeout(() => finish(new HttpError(504, 'Backend request timed out')), 310000)
|
|
454
|
-
outgoing.once('error',
|
|
472
|
+
outgoing.once('error', e => finish(unavailable(e.code)))
|
|
455
473
|
res.once('close', closed)
|
|
456
474
|
req.on('data', count)
|
|
457
475
|
req.pipe(outgoing)
|
|
@@ -459,14 +477,14 @@ export function nativeAuthBridge(raw, refused, serviceKey, report) {
|
|
|
459
477
|
})
|
|
460
478
|
}
|
|
461
479
|
function proxy(req, res, url, rid, reply, responseLimit = 20 * 1024 * 1024, declared) {
|
|
462
|
-
if (stopped || active >= 8) throw
|
|
480
|
+
if (stopped || active >= 8) throw bridgeBusy('Backend request capacity is unavailable')
|
|
463
481
|
if (!['GET','HEAD','POST','PUT','PATCH','DELETE','OPTIONS'].includes(req.method)) throw new HttpError(405, 'Method not allowed')
|
|
464
482
|
const post = req.method === 'POST'
|
|
465
483
|
const limit = url.pathname === '/_brain/uploads' && post ? 5 * 1024 * 1024 + 65536 : declared || post && /^\/api\/[^/]+\/batch$/.test(url.pathname) ? 1024 * 1024 : 65536
|
|
466
484
|
if (Number(req.headers['content-length'] || 0) > limit) throw new HttpError(413, 'Request body is too large')
|
|
467
485
|
active++
|
|
468
486
|
return new Promise((resolve, reject) => {
|
|
469
|
-
let outgoing, timer, done = false, bytes = 0, requestBytes = 0, failure
|
|
487
|
+
let outgoing, timer, done = false, bytes = 0, requestBytes = 0, failure, transport
|
|
470
488
|
const chunks = []
|
|
471
489
|
const headers = requestHeaders(req, rid, declared?.headers)
|
|
472
490
|
const finish = (error, backend) => {
|
|
@@ -474,7 +492,7 @@ export function nativeAuthBridge(raw, refused, serviceKey, report) {
|
|
|
474
492
|
done = true; clearTimeout(timer); active--; proxyBytes -= bytes; owned.delete(outgoing)
|
|
475
493
|
req.removeListener('aborted',cancel); req.removeListener('data',data); req.removeListener('end',end)
|
|
476
494
|
res.removeListener('close',cancel)
|
|
477
|
-
if (error) { if (!res.destroyed) reject(failure || new HttpError(503,'Backend request unavailable')); else resolve(); return }
|
|
495
|
+
if (error) { if (!res.destroyed) reject(failure || new HttpError(503,'Backend request unavailable',{transport})); else resolve(); return }
|
|
478
496
|
const safe = { 'cache-control':'no-store' }
|
|
479
497
|
for (const key of RESPONSE_HEADERS) {
|
|
480
498
|
const value = backend.headers[key]
|
|
@@ -486,6 +504,7 @@ export function nativeAuthBridge(raw, refused, serviceKey, report) {
|
|
|
486
504
|
}
|
|
487
505
|
const cookies = nativeCookies(backend.headers['set-cookie'])
|
|
488
506
|
if (cookies.length) safe['set-cookie'] = cookies
|
|
507
|
+
if (backend.statusCode >= 500 && !res.$capture) res.$brainCause = 'answered by the native backend'
|
|
489
508
|
try { reply(res,backend.statusCode || 502,req.method === 'HEAD' ? '' : Buffer.concat(chunks,bytes),safe); resolve() } catch (e) { reject(e) }
|
|
490
509
|
}
|
|
491
510
|
const cancel = () => { outgoing?.destroy(); finish(true) }
|
|
@@ -502,11 +521,11 @@ export function nativeAuthBridge(raw, refused, serviceKey, report) {
|
|
|
502
521
|
if (bytes > responseLimit || proxyBytes > 64 * 1024 * 1024) { failure = new HttpError(502,'Backend response exceeds its byte budget'); outgoing.destroy(); finish(true); return }
|
|
503
522
|
chunks.push(chunk)
|
|
504
523
|
})
|
|
505
|
-
backend.once('end',()=>finish(false,backend)); backend.once('error',
|
|
524
|
+
backend.once('end',()=>finish(false,backend)); backend.once('error',e=>{transport=e.code;finish(true)}); backend.once('aborted',()=>{transport='BACKEND_ABORTED';finish(true)})
|
|
506
525
|
})
|
|
507
526
|
owned.add(outgoing)
|
|
508
527
|
timer = setTimeout(()=>{failure = new HttpError(504,'Backend request timed out');outgoing.destroy();finish(true)},declared || url.pathname === '/_brain/uploads' || url.pathname.startsWith('/_brain/uploads/') ? 35000 : 10000)
|
|
509
|
-
outgoing.once('error',
|
|
528
|
+
outgoing.once('error',e=>{transport=e.code;finish(true)}); req.once('aborted',cancel); res.once('close',cancel)
|
|
510
529
|
req.on('data',data); req.once('end',end)
|
|
511
530
|
if (req.readableEnded) end()
|
|
512
531
|
} catch { outgoing?.destroy(); finish(true) }
|
|
@@ -531,7 +550,7 @@ export function nativeAuthBridge(raw, refused, serviceKey, report) {
|
|
|
531
550
|
req.method = method
|
|
532
551
|
req.$brainForwardedFor = forwardedFor(incoming)
|
|
533
552
|
// A service call acts as the key's owner, never as the visitor: it sends no session.
|
|
534
|
-
req.headers = { ...(serviceKey ? { authorization: 'Bearer ' + serviceKey } : { cookie: cookies(incoming, false) }), accept: 'application/json', 'content-type':'application/json', 'content-length':String(bytes.length) }
|
|
553
|
+
req.headers = { ...(serviceKey ? { authorization: 'Bearer ' + serviceKey } : ownSession ? {} : { cookie: cookies(incoming, false) }), accept: 'application/json', 'content-type':'application/json', 'content-length':String(bytes.length) }
|
|
535
554
|
if (method !== 'GET') {
|
|
536
555
|
req.headers.origin = bridgeOrigin(incoming, rid, 'Native writes require a same-origin server request')
|
|
537
556
|
}
|
|
@@ -558,6 +577,15 @@ export function nativeAuthBridge(raw, refused, serviceKey, report) {
|
|
|
558
577
|
if (!String(headers['content-type'] || '').startsWith('application/json')) throw new HttpError(502, 'Native resource response must be JSON')
|
|
559
578
|
try { value = JSON.parse(data.toString('utf8')) } catch { throw new HttpError(502, 'Native resource response is invalid') }
|
|
560
579
|
}, 2 * 1024 * 1024)
|
|
580
|
+
} catch (e) {
|
|
581
|
+
// A read that failed: `load` may catch it and render anyway, so the page answer says what happened (ssr below).
|
|
582
|
+
if (method === 'GET' && !(e?.status >= 400 && e.status < 500)) {
|
|
583
|
+
const seen = incoming.$brainFailedReads || (incoming.$brainFailedReads = { reads: 0, backendCode: undefined, transport: undefined })
|
|
584
|
+
seen.reads++
|
|
585
|
+
seen.backendCode ??= e?.code
|
|
586
|
+
seen.transport ??= e?.transport
|
|
587
|
+
}
|
|
588
|
+
throw e
|
|
561
589
|
} finally { req.destroy() }
|
|
562
590
|
if (response.destroyed || response.writableEnded) throw new HttpError(409, 'The server request has ended')
|
|
563
591
|
return value
|
|
@@ -796,18 +824,22 @@ export function createServer(o = {}) {
|
|
|
796
824
|
// ctx.env: what the supervisor passed, read-only; the service key stays with ctx.backend.service
|
|
797
825
|
const { BRAIN_SERVICE_API_KEY: serviceKey, BRAIN_JOBS_API_KEY: _jobs, BRAIN_JOBS_SYSTEM_KEY: _jobsSystem, BRAIN_ROUTES_API_KEY: _routes, BRAIN_ROUTES_SYSTEM_KEY: _routesSystem, ...visibleEnv } = o.env || ENV
|
|
798
826
|
const env = Object.freeze(visibleEnv)
|
|
799
|
-
const native = nativeAuthBridge(o.backendURL ?? env.BRAIN_BACKEND_URL, (...why) => originRefused(...why), serviceKey, (...entry) => log(...entry))
|
|
800
|
-
// Using the native backend does not have to mean it owns the visitor's session: `app` keeps the signed cookie.
|
|
801
827
|
const backendSession = o.backendSession ?? env.BRAIN_BACKEND_SESSION
|
|
802
828
|
if (backendSession && backendSession !== 'app') throw Error("BRAIN_BACKEND_SESSION must be app, or unset for the backend's own session")
|
|
829
|
+
const native = nativeAuthBridge(o.backendURL ?? env.BRAIN_BACKEND_URL, (...why) => originRefused(...why), serviceKey, (...entry) => log(...entry), backendSession === 'app')
|
|
830
|
+
// Using the native backend does not have to mean it owns the visitor's session: `app` keeps the signed cookie.
|
|
803
831
|
const nativeSession = native && backendSession !== 'app'
|
|
804
832
|
if (o.nativeSockets !== undefined && typeof o.nativeSockets !== 'boolean') throw Error('nativeSockets must be an explicit boolean')
|
|
805
833
|
const SITEMAP_BYTES = +(o.sitemapBytes ?? 50 * 1024 * 1024) // protocol: 50 MB uncompressed per sitemap file
|
|
806
834
|
const secret = o.secret ?? env.BRAIN_SECRET ?? ''
|
|
807
835
|
const COMPRESS = o.compress !== false && env.BRAIN_COMPRESS !== 'off' // a proxy that already compresses can opt out
|
|
808
836
|
const sink = o.log || ((level, msg, extra) => console[level === 'error' ? 'error' : 'log'](json({ t: new Date().toISOString(), level, msg, ...extra })))
|
|
837
|
+
// Requests in flight by rid: a 5xx whose cause reached no log line gets one when the response closes (dispatch below).
|
|
838
|
+
const liveRequests = new Map()
|
|
839
|
+
let statusPageWindow = 0, statusPageLogs = 0
|
|
809
840
|
const log = (level, msg, extra = {}) => {
|
|
810
841
|
const safe = { ...extra }
|
|
842
|
+
if (level !== 'info' && extra.rid) { const live = liveRequests.get(extra.rid); if (live) live.logged = true }
|
|
811
843
|
// Error messages may contain credentials or request data. Retain only bounded V8 source frames.
|
|
812
844
|
if (typeof safe.stack === 'string') {
|
|
813
845
|
const kind = /^(TypeError|ReferenceError|RangeError|SyntaxError|AggregateError|Error):/.exec(safe.stack)?.[1] || 'Error'
|
|
@@ -843,6 +875,9 @@ export function createServer(o = {}) {
|
|
|
843
875
|
// log fields for a thrown value: its stack (sanitized by log) plus, for B09xx budget failures, the typed fields only
|
|
844
876
|
const trace = e => {
|
|
845
877
|
const out = { stack: String((e && e.stack) || e) }
|
|
878
|
+
if (e instanceof HttpError && OWN_CAUSES.has(e.message)) out.cause = e.message
|
|
879
|
+
if (e && CODE_SHAPE.test(e.transport || '')) out.transport = e.transport
|
|
880
|
+
if (e && typeof e.code === 'string' && CODE_SHAPE.test(e.code)) out.backendCode = e.code
|
|
846
881
|
if (e && e.kind === 'budget' && /^B09\d\d$/.test(e.code) && typeof e.budget === 'string' && /^[a-zA-Z]{1,16}$/.test(e.budget)) {
|
|
847
882
|
const str = (v, n) => typeof v === 'string' ? v.slice(0, n) : null, pos = v => Number.isSafeInteger(v) && v > 0 ? v : null
|
|
848
883
|
out.budgetFailure = { code: e.code, budget: e.budget, limit: Number.isFinite(e.limit) ? e.limit : null, file: str(e.file, 256), component: str(e.component, 80), line: pos(e.line), column: pos(e.column), fix: str(e.fix, 200) || 'Bound the work or move it to an owned worker.' }
|
|
@@ -936,7 +971,7 @@ export function createServer(o = {}) {
|
|
|
936
971
|
res.req.$nativeLogout.then(() => send(res, status, body, headers, configuredExternal), () => send(res, 503, '', {}, false))
|
|
937
972
|
return
|
|
938
973
|
}
|
|
939
|
-
if (native && res.req.$nativeLogoutError) { status = 503; body = ''; headers = {}; res.req.$nativeLogoutError = false }
|
|
974
|
+
if (native && res.req.$nativeLogoutError) { status = 503; body = ''; headers = {}; res.req.$nativeLogoutError = false; res.$brainCause = 'native sign-out failed' }
|
|
940
975
|
if (native && res.req.$nativeLogoutHeaders) {
|
|
941
976
|
const relay = res.req.$nativeLogoutHeaders, own = headers['set-cookie']
|
|
942
977
|
headers = { ...headers, ...relay, 'set-cookie': [...(own ? Array.isArray(own) ? own : [own] : []), ...relay['set-cookie']] }
|
|
@@ -974,6 +1009,7 @@ export function createServer(o = {}) {
|
|
|
974
1009
|
delete headers['content-length']
|
|
975
1010
|
}
|
|
976
1011
|
}
|
|
1012
|
+
if (headers.vary) headers = { ...headers, vary: varyOnce(headers.vary) }
|
|
977
1013
|
res.writeHead(status, { ...SECURITY, ...(server.draining ? { connection: 'close' } : {}), ...headers })
|
|
978
1014
|
res.end(res.req.method === 'HEAD' || status === 204 || status === 205 || status === 304 ? undefined : body)
|
|
979
1015
|
}
|
|
@@ -995,8 +1031,8 @@ export function createServer(o = {}) {
|
|
|
995
1031
|
const notFound = (req, res) => failPage(res, 404, res.getHeader('x-request-id'))
|
|
996
1032
|
// `gone()` from load (§33): 410 tells crawlers the URL was removed for good; views/410.brain, else the 404 page
|
|
997
1033
|
const missing = (req, res, x) => (x && x.gone === true ? failPage(res, 410, res.getHeader('x-request-id')) : notFound(req, res))
|
|
998
|
-
const failPage = (res, status, rid) => {
|
|
999
|
-
if (!wantsHtml(res.req)) return send(res, status, json({ schemaVersion: 1, status, rid, error: STATUS_CODES[status] || 'Request failed' }), { 'content-type': MIME['.json'] })
|
|
1034
|
+
const failPage = (res, status, rid, detail) => {
|
|
1035
|
+
if (!wantsHtml(res.req)) return send(res, status, json({ schemaVersion: 1, status, rid, error: detail || STATUS_CODES[status] || 'Request failed' }), { 'content-type': MIME['.json'] })
|
|
1000
1036
|
const custom = (status === 404 && mf.notFound) || (mf.errors && mf.errors[status]) || (status === 410 && !file('410.html') ? mf.notFound : `${status}.html`)
|
|
1001
1037
|
const path = typeof custom === 'string' && file(custom)
|
|
1002
1038
|
return html(res, status, path ? readFileSync(path, 'utf8') : defaultError(status, rid, BASE))
|
|
@@ -1049,16 +1085,20 @@ export function createServer(o = {}) {
|
|
|
1049
1085
|
const SSRC = new Map(), SSRC_MAX = 500, SSRC_BYTES = 32 * 1024 * 1024, SSRC_ENTRY = 1024 * 1024
|
|
1050
1086
|
let ssrBytes = 0
|
|
1051
1087
|
async function cached(req, res, route, params, url, rid) {
|
|
1052
|
-
const n = +route.cache, ms = n * 1000, key = (route.locale || '') + url.pathname + url
|
|
1053
|
-
const fill = () =>
|
|
1054
|
-
|
|
1088
|
+
const n = +route.cache, ms = n * 1000, key = (route.locale || '') + url.pathname + cacheSearch(url)
|
|
1089
|
+
const fill = () => {
|
|
1090
|
+
let failed = false
|
|
1091
|
+
return new Promise(ok => {
|
|
1055
1092
|
const cap = { req, $capture: (out, h, st) => {
|
|
1056
1093
|
const headers = Object.fromEntries(Object.entries(h).map(([k, v]) => [k.toLowerCase(), v]))
|
|
1057
1094
|
const privateResponse = headers['set-cookie'] || headers['vary'] || /private|no-store|no-cache/i.test(headers['cache-control'] || '')
|
|
1095
|
+
if (st >= 500) failed = true
|
|
1058
1096
|
ok(st === 200 ? { out, h, cacheable: !privateResponse, etag: 'W/"' + tag(out) + '"', t: Date.now(), bytes: Buffer.byteLength(out) } : null)
|
|
1059
|
-
}, writeHead() {}, end: () => ok(null), setHeader() {}, getHeader() {} }
|
|
1097
|
+
}, writeHead(st) { if (st >= 500) failed = true }, end: () => ok(null), setHeader() {}, getHeader: name => (name === 'x-request-id' ? rid : undefined), once() {}, removeListener() {} }
|
|
1060
1098
|
ssr(req, cap, route, params, url, rid, null, 200, null, true).catch(() => ok(null))
|
|
1061
1099
|
}).then(c => {
|
|
1100
|
+
// A failed revalidation keeps the copy until 2N, which is what `stale-if-error` promises downstream.
|
|
1101
|
+
if ((!c || !c.cacheable) && (failed || req.$brainFailedReads) && SSRC.has(key)) return c
|
|
1062
1102
|
if (!c || !c.cacheable) {
|
|
1063
1103
|
ssrBytes -= SSRC.get(key)?.bytes || 0
|
|
1064
1104
|
SSRC.delete(key)
|
|
@@ -1076,7 +1116,8 @@ export function createServer(o = {}) {
|
|
|
1076
1116
|
}
|
|
1077
1117
|
return c
|
|
1078
1118
|
})
|
|
1079
|
-
|
|
1119
|
+
}
|
|
1120
|
+
const serve = c => html(res, 200, c.out, { ...c.h, 'cache-control': c.cacheable ? `public, s-maxage=${n}, stale-while-revalidate=${n}, stale-if-error=${n}` : 'no-store' }, c.etag)
|
|
1080
1121
|
let c = SSRC.get(key)
|
|
1081
1122
|
const age = c ? Date.now() - c.t : Infinity
|
|
1082
1123
|
if (c && age < 2 * ms) {
|
|
@@ -1153,6 +1194,10 @@ export function createServer(o = {}) {
|
|
|
1153
1194
|
return loadOutcome(e)
|
|
1154
1195
|
}
|
|
1155
1196
|
if (props && (props.redirect || props.notFound === true)) return loadOutcome(props) // `return redirect()` / `return notFound()`
|
|
1197
|
+
if (req.$brainFailedReads && !fixed) {
|
|
1198
|
+
softFailure(req, rid, url, status)
|
|
1199
|
+
extra = { ...extra, 'cache-control': 'no-store' } // what it rendered instead is not worth keeping
|
|
1200
|
+
}
|
|
1156
1201
|
let body, seo, failed
|
|
1157
1202
|
try {
|
|
1158
1203
|
const at = { path: url.pathname, params: params || {}, query: Object.fromEntries(url.searchParams) } // route() (§41.5)
|
|
@@ -1181,6 +1226,11 @@ export function createServer(o = {}) {
|
|
|
1181
1226
|
// (head, CSS, layout shell) is sent, the view when load() ends. The status and headers are out by then, so a late
|
|
1182
1227
|
// redirect becomes location.replace and a late failure an inline notice with the request id (both logged); load()
|
|
1183
1228
|
// cannot change the session on a streamed route.
|
|
1229
|
+
// `load` caught a failed backend read and rendered: the status it asked for stands, the log says what is behind it.
|
|
1230
|
+
const softFailure = (req, rid, url, status) => {
|
|
1231
|
+
const { reads, backendCode, transport } = req.$brainFailedReads
|
|
1232
|
+
log('warn', 'page rendered after a failed backend read', { rid, path: url.pathname, status, reads, backendCode, transport, fix: 'load caught the failure and returned other props; let it throw for a 503, or show that the data is missing' })
|
|
1233
|
+
}
|
|
1184
1234
|
async function streamed(req, res, route, params, url, rid, ctx, extra, outcome) {
|
|
1185
1235
|
const [top, bottom = ''] = readFileSync(file(route.page), 'utf8').split('<!--brain:ssr-->')
|
|
1186
1236
|
const sm = await load(route.server)
|
|
@@ -1250,6 +1300,7 @@ export function createServer(o = {}) {
|
|
|
1250
1300
|
log('error', 'load failed (streamed)', { rid, path: url.pathname, status: 200, ...trace(e) }) // headers already out
|
|
1251
1301
|
return fail()
|
|
1252
1302
|
}
|
|
1303
|
+
if (req.$brainFailedReads) softFailure(req, rid, url, 200)
|
|
1253
1304
|
try {
|
|
1254
1305
|
const { body, failed } = await (queue = queue.then(() => render(route, props, null, at), () => render(route, props, null, at)))
|
|
1255
1306
|
for (const f of failed || []) log('error', 'component failed during SSR', { rid, path: url.pathname, comp: f.comp, where: f.w, ...trace(f.e) })
|
|
@@ -1527,9 +1578,19 @@ export function createServer(o = {}) {
|
|
|
1527
1578
|
if (path === null) return false
|
|
1528
1579
|
const md = file(mdPath(path))
|
|
1529
1580
|
if (!md) return false
|
|
1530
|
-
send(res, 200, req.method === 'HEAD' ? '' : readFileSync(md), { 'content-type': MIME['.md'], 'cache-control': 'public, max-age=3600', vary: 'accept' })
|
|
1581
|
+
send(res, 200, req.method === 'HEAD' ? '' : readFileSync(md), { 'content-type': MIME['.md'], 'cache-control': 'public, max-age=3600', vary: 'accept', ...twinCanonical(path) })
|
|
1531
1582
|
return true
|
|
1532
1583
|
}
|
|
1584
|
+
// A Markdown copy repeats its page: the header names the canonical the page itself declares. A page without one (no
|
|
1585
|
+
// site configured) or with a per-request pattern gets no header, so this never invents a URL.
|
|
1586
|
+
function twinCanonical(path) {
|
|
1587
|
+
const stem = path.replace(/\.md$/, '').replace(/\/+$/, '')
|
|
1588
|
+
const page = file(stem ? stem + '.html' : 'index.html') || file(stem + '/index.html')
|
|
1589
|
+
if (!page) return {}
|
|
1590
|
+
const tag = /<link\b[^>]*\brel=["']canonical["'][^>]*>/i.exec(readFileSync(page, 'utf8'))?.[0]
|
|
1591
|
+
const href = tag && /\bhref="(https?:\/\/[^"<>\s]+)"/.exec(tag)?.[1].replaceAll('&', '&')
|
|
1592
|
+
return href && URL.canParse(href) && !/\/[:*]/.test(new URL(href).pathname) ? { link: `<${href}>; rel="canonical"` } : {}
|
|
1593
|
+
}
|
|
1533
1594
|
function serveStatic(req, res, url) {
|
|
1534
1595
|
const p = publicStaticPath(url.pathname)
|
|
1535
1596
|
if (p === null) return false
|
|
@@ -1547,6 +1608,7 @@ export function createServer(o = {}) {
|
|
|
1547
1608
|
return html(res, 200, page, md ? { vary: 'accept', 'x-brain-static': '1' } : { 'x-brain-static': '1' }, '"' + (mf.build || tag(page)) + '"'), true
|
|
1548
1609
|
}
|
|
1549
1610
|
const headers = {
|
|
1611
|
+
...(ext === '.md' ? twinCanonical(p) : {}),
|
|
1550
1612
|
'content-type': type,
|
|
1551
1613
|
// the worker script must be revalidated on every check, or a CDN could keep an old worker for an hour (§35.7)
|
|
1552
1614
|
'cache-control': p.startsWith('/_b/') ? 'public, max-age=31536000, immutable' : p === '/sw.js' ? 'no-cache' : 'public, max-age=3600',
|
|
@@ -1577,7 +1639,43 @@ export function createServer(o = {}) {
|
|
|
1577
1639
|
if (req.method !== 'GET' && req.method !== 'HEAD') return notFound(req, res)
|
|
1578
1640
|
if (!native) return send(res, 200, json({ ok: true }), headers)
|
|
1579
1641
|
try { await native.proxy(req, res, new URL(url.pathname, url), rid, send) }
|
|
1580
|
-
catch
|
|
1642
|
+
catch (e) {
|
|
1643
|
+
if (!res.headersSent) {
|
|
1644
|
+
log('warn', 'health probe could not reach the native backend', { rid, path: url.pathname, status: 503, ...trace(e) })
|
|
1645
|
+
send(res, 503, json({ ok: false }), headers)
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
const urlVariant = req => {
|
|
1651
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') return null
|
|
1652
|
+
const q = req.url.indexOf('?'), raw = q < 0 ? req.url : req.url.slice(0, q)
|
|
1653
|
+
if (!raw.includes('//') && !raw.endsWith('/index.html')) return null
|
|
1654
|
+
let path = raw.replace(/\/{2,}/g, '/')
|
|
1655
|
+
const app = '/' + (path.startsWith(BASE) ? path.slice(BASE.length) : path.slice(1))
|
|
1656
|
+
if (/^\/(?:api|_brain|_r|s)(?:\/|$)/.test(app) && !raw.startsWith('//')) return null
|
|
1657
|
+
if (path.endsWith('/index.html')) path = path.slice(0, -'index.html'.length)
|
|
1658
|
+
if (path !== BASE && path !== '/' && (path.endsWith('/') && SLASH !== 'always')) path = path.replace(/\/+$/, '')
|
|
1659
|
+
const location = path + (q < 0 ? '' : req.url.slice(q))
|
|
1660
|
+
try { safeLocation(location) } catch { return null }
|
|
1661
|
+
return location
|
|
1662
|
+
}
|
|
1663
|
+
// `<meta seo query="page">`: `page` is a number from 1 and page 1 is the URL without it. A view decides past its last
|
|
1664
|
+
// page with notFound(). → null (nothing to do) · 404 · the URL to redirect to.
|
|
1665
|
+
const pagedRoutes = new Map()
|
|
1666
|
+
const pageParameter = (route, url) => {
|
|
1667
|
+
if (!url.searchParams.has('page') || !route.page) return null
|
|
1668
|
+
if (!pagedRoutes.has(route.page)) {
|
|
1669
|
+
const keys = /<meta name="brain-query" content="([^"]*)">/.exec(readFileSync(file(route.page), 'utf8'))?.[1]
|
|
1670
|
+
pagedRoutes.set(route.page, !!keys && keys.split(/[\s,]+/).includes('page'))
|
|
1671
|
+
}
|
|
1672
|
+
if (!pagedRoutes.get(route.page)) return null
|
|
1673
|
+
const values = url.searchParams.getAll('page')
|
|
1674
|
+
if (values.length > 1 || !/^[1-9]\d{0,8}$/.test(values[0])) return 404
|
|
1675
|
+
if (values[0] !== '1') return null
|
|
1676
|
+
const rest = new URLSearchParams(url.search)
|
|
1677
|
+
rest.delete('page')
|
|
1678
|
+
return url.pathname + (rest.size ? '?' + rest : '')
|
|
1581
1679
|
}
|
|
1582
1680
|
|
|
1583
1681
|
// ---------- dispatch ----------
|
|
@@ -1594,13 +1692,23 @@ export function createServer(o = {}) {
|
|
|
1594
1692
|
}
|
|
1595
1693
|
const rid = req.headers['x-request-id'] && /^[\w-]{1,64}$/.test(req.headers['x-request-id']) ? req.headers['x-request-id'] : randomUUID()
|
|
1596
1694
|
res.setHeader('x-request-id', rid)
|
|
1695
|
+
const live = { logged: false }
|
|
1696
|
+
liveRequests.set(rid, live)
|
|
1697
|
+
res.once('close', () => {
|
|
1698
|
+
if (liveRequests.get(rid) === live) liveRequests.delete(rid)
|
|
1699
|
+
// The net under every path: a 5xx that was answered always leaves a line with its request id.
|
|
1700
|
+
if (res.statusCode >= 500 && !live.logged && !res.$brainMuted) log(res.$brainCause === STATUS_PAGE ? 'info' : 'error', 'server error answered', { rid, path: req.$brainPath || String(req.url || '').split('?')[0], status: res.statusCode, cause: res.$brainCause || 'no cause recorded on this path', ...(res.$brainCause === STATUS_PAGE ? {} : { fix: 'Search this rid in the native backend log; its failed requests carry the same id' }) })
|
|
1701
|
+
})
|
|
1597
1702
|
const t0 = performance.now()
|
|
1598
1703
|
res.on('finish', () => {
|
|
1599
1704
|
const ms = performance.now() - t0
|
|
1600
1705
|
if (ms > 1000) log('warn', 'slow request', { rid, path: req.$brainPath || String(req.url || '').split('?')[0], status: res.statusCode, ms: Math.round(ms) })
|
|
1601
1706
|
})
|
|
1602
1707
|
try {
|
|
1603
|
-
//
|
|
1708
|
+
// One URL per page: `//route`, `/a//b` and `/route/index.html` answer one 301 on this origin. The path is rewritten
|
|
1709
|
+
// as text, before URL parsing: `//host/x` would be read as another host. Backend paths are left to the backend.
|
|
1710
|
+
const variant = urlVariant(req)
|
|
1711
|
+
if (variant) return send(res, 301, '', { location: variant, 'cache-control': 'public, max-age=3600' })
|
|
1604
1712
|
if (req.url.startsWith('//')) return notFound(req, res)
|
|
1605
1713
|
const full = new URL(req.url, 'http://' + (req.headers.host || 'localhost'))
|
|
1606
1714
|
// /_health stays at the root: the supervisor calls it there whatever the base
|
|
@@ -1629,13 +1737,23 @@ export function createServer(o = {}) {
|
|
|
1629
1737
|
// a status page opened directly answers its own status: `/403` is never a 200 that a crawler or a probe can trust
|
|
1630
1738
|
const reserved = post ? 0 : reservedStatus(url.pathname)
|
|
1631
1739
|
if (reserved === 403) return forbidden(req, res, url, rid, '')
|
|
1632
|
-
if (reserved)
|
|
1740
|
+
if (reserved) {
|
|
1741
|
+
// Anyone can open it, so the note about it is bounded and is not an error.
|
|
1742
|
+
const minute = Math.floor(Date.now() / 60000)
|
|
1743
|
+
if (statusPageWindow !== minute) { statusPageWindow = minute; statusPageLogs = 0 }
|
|
1744
|
+
res.$brainCause = STATUS_PAGE
|
|
1745
|
+
res.$brainMuted = statusPageLogs++ >= 16
|
|
1746
|
+
return failPage(res, reserved, rid)
|
|
1747
|
+
}
|
|
1633
1748
|
for (const route of routes) {
|
|
1634
1749
|
const m = route.rx.exec(url.pathname)
|
|
1635
1750
|
if (!m) continue
|
|
1636
1751
|
// a form page without load is prerendered: GET serves that file (build ETag); only its POST needs the server
|
|
1637
1752
|
if (!post && route.forms && serveStatic(req, res, url)) return
|
|
1638
1753
|
if (!post && serveTwin(req, res, url)) return
|
|
1754
|
+
const paged = post ? null : pageParameter(route, url)
|
|
1755
|
+
if (paged === 404) return notFound(req, res)
|
|
1756
|
+
if (paged) return send(res, 301, '', { location: withBase(paged), 'cache-control': 'public, max-age=3600' })
|
|
1639
1757
|
return await (post ? formPost : ssr)(req, res, route, m.groups ? { ...m.groups } : {}, url, rid)
|
|
1640
1758
|
}
|
|
1641
1759
|
if (post) return send(res, 405, 'Method not allowed', { allow: 'GET, HEAD' })
|
|
@@ -1646,9 +1764,19 @@ export function createServer(o = {}) {
|
|
|
1646
1764
|
if (isProbe(url.pathname)) return await answerProbe(req, res, url, rid)
|
|
1647
1765
|
notFound(req, res)
|
|
1648
1766
|
} catch (e) {
|
|
1649
|
-
|
|
1650
|
-
if (
|
|
1651
|
-
else
|
|
1767
|
+
const busy = e instanceof HttpError && e.code === 'BRIDGE_BUSY'
|
|
1768
|
+
if (busy) log('warn', 'backend bridge busy', { rid, path: req.$brainPath || String(req.url || '').split('?')[0], fix: e.fix })
|
|
1769
|
+
else log('error', 'unhandled', { rid, path: req.url, ...trace(e) })
|
|
1770
|
+
try {
|
|
1771
|
+
if (busy && !res.headersSent) { res.setHeader('retry-after', String(e.retryAfter)); failPage(res, 503, rid, { code: e.code, message: 'Service Unavailable', fix: e.fix }) }
|
|
1772
|
+
else if (!res.headersSent) failPage(res, intendedStatus(e) || 500, rid)
|
|
1773
|
+
else res.end()
|
|
1774
|
+
} catch (again) {
|
|
1775
|
+
// the error page itself failed: still an answer, never an unhandled rejection that takes the process down
|
|
1776
|
+
log('error', 'error page failed', { rid, path: req.url, ...trace(again) })
|
|
1777
|
+
if (!res.headersSent) res.writeHead(500, SECURITY)
|
|
1778
|
+
res.end()
|
|
1779
|
+
}
|
|
1652
1780
|
}
|
|
1653
1781
|
})
|
|
1654
1782
|
if (native) server.once('close', () => native.close())
|
|
@@ -1660,6 +1788,7 @@ export function createServer(o = {}) {
|
|
|
1660
1788
|
}
|
|
1661
1789
|
|
|
1662
1790
|
// ---------- session (signed cookie; shared with the dev server) ----------
|
|
1791
|
+
const STATUS_PAGE = 'status page opened directly'
|
|
1663
1792
|
const COOKIE = '__Host-session', SECRET_COOKIE = '__Host-session-secret', SECRET_MAX = 3500, DEFAULT_SESSION_SECONDS = 7 * 24 * 3600
|
|
1664
1793
|
// Keys that still verify and decrypt during a rotation; new cookies always use the current key.
|
|
1665
1794
|
const previousKeys = () => (process.env.BRAIN_SECRET_PREVIOUS || '').split(',').map(k => k.trim()).filter(Boolean)
|
|
@@ -2202,6 +2331,8 @@ function real(p) {
|
|
|
2202
2331
|
}
|
|
2203
2332
|
if (process.argv[1] && real(process.argv[1]) === real(fileURLToPath(import.meta.url))) {
|
|
2204
2333
|
process.env.NODE_ENV = 'production'
|
|
2334
|
+
// A closed log pipe is the operator's matter: requests do not depend on it.
|
|
2335
|
+
for (const stream of [process.stdout, process.stderr]) stream.on('error', error => { if (error.code !== 'EPIPE') throw error })
|
|
2205
2336
|
const s = createServer({ dist: fileURLToPath(new URL('.', import.meta.url)) })
|
|
2206
2337
|
const fd = ENV.BRAIN_LISTEN_FD
|
|
2207
2338
|
const where = fd ? { fd: Number(fd) } : { port: +(ENV.PORT || 3000), host: ENV.HOST || '127.0.0.1' }
|