@christianirack/brainllm-runtime 0.8.1 → 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 +13 -2
- package/brain-inspect.js +7 -2
- package/brain-lazy.js +2 -0
- package/package.json +1 -1
- package/server/server.mjs +172 -32
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
|
@@ -35,7 +35,10 @@ const go = u => {
|
|
|
35
35
|
// submitted does not ask (#299): the user chose to save, and a submit() that navigates once the server accepted the
|
|
36
36
|
// data (`navigate('/products/' + p.id)`) leaves before submit() returns and marks the values saved.
|
|
37
37
|
const DIRTY = (globalThis.__brainDirty ||= new Set())
|
|
38
|
-
|
|
38
|
+
// registered with the first edit, not at load: a `beforeunload` listener can keep a page out of the back/forward cache,
|
|
39
|
+
// and most pages that load this file are never edited
|
|
40
|
+
const guardUnload = () => {
|
|
41
|
+
if (!globalThis.addEventListener || globalThis.__brainDirtyHook) return
|
|
39
42
|
globalThis.__brainDirtyHook = 1
|
|
40
43
|
globalThis.addEventListener('beforeunload', e => {
|
|
41
44
|
for (const f of DIRTY) if (f.dirty && !f.busy) return void (e.preventDefault(), (e.returnValue = ''))
|
|
@@ -109,7 +112,7 @@ function remoteCheck(o, x, checking) {
|
|
|
109
112
|
}
|
|
110
113
|
function createForm(schema, o, readRule, validateRule, remote) {
|
|
111
114
|
const id = 'f' + ++seq, owner = I.owner() // aria effects are created later (in handlers) but live with the component
|
|
112
|
-
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
|
|
113
116
|
const checking = sig(0), saved = sig('') // async checks running · JSON of the last saved values
|
|
114
117
|
const F = [] // flat fields: { path, r, s (value sig), e (error sig), el, touched }
|
|
115
118
|
const byPath = {}
|
|
@@ -227,6 +230,9 @@ function createForm(schema, o, readRule, validateRule, remote) {
|
|
|
227
230
|
get error() {
|
|
228
231
|
return error.v
|
|
229
232
|
},
|
|
233
|
+
get status() {
|
|
234
|
+
return status.v
|
|
235
|
+
},
|
|
230
236
|
get dirty() {
|
|
231
237
|
return now() !== saved.v
|
|
232
238
|
},
|
|
@@ -274,6 +280,7 @@ function createForm(schema, o, readRule, validateRule, remote) {
|
|
|
274
280
|
if (!first && x.e.v) first = x
|
|
275
281
|
}
|
|
276
282
|
error.v = ''
|
|
283
|
+
status.v = 0
|
|
277
284
|
shown.v++
|
|
278
285
|
})
|
|
279
286
|
if (o.validate && !first) {
|
|
@@ -302,6 +309,7 @@ function createForm(schema, o, readRule, validateRule, remote) {
|
|
|
302
309
|
}
|
|
303
310
|
return r
|
|
304
311
|
} catch (x) {
|
|
312
|
+
status.v = x?.status || 0
|
|
305
313
|
if (x?.errors) mark(x.errors)
|
|
306
314
|
// a server failure reads the same as without JS: 4xx and the author's own errors keep their message; 5xx and a
|
|
307
315
|
// TypeError (the network dropped, or a bug) get the generic one; the runtime's own English sentence (`plain`) gives
|
|
@@ -315,6 +323,9 @@ function createForm(schema, o, readRule, validateRule, remote) {
|
|
|
315
323
|
},
|
|
316
324
|
}
|
|
317
325
|
DIRTY.add(f)
|
|
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)))
|
|
328
|
+
owner ? I.root(watch, owner) : watch()
|
|
318
329
|
if (owner) I.root(() => fx(() => () => DIRTY.delete(f)), owner)
|
|
319
330
|
return f
|
|
320
331
|
}
|
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) {
|
|
@@ -1129,8 +1170,17 @@ export function createServer(o = {}) {
|
|
|
1129
1170
|
const loadOutcome = e => {
|
|
1130
1171
|
if (e && e.redirect) return send(res, e.status || 303, '', { location: withBase(linkTo(route, redirect(e.redirect, e.status ?? 303).redirect)), ...ctx.session.headers() })
|
|
1131
1172
|
if ((e instanceof HttpError && e.status === 404) || (e && e.notFound === true)) return missing(req, res, e) // `notFound()` 404 / `gone()` 410
|
|
1173
|
+
// the backend or another service said "not now": a temporary 503 tells crawlers to come back, a 500 tells them it broke
|
|
1174
|
+
// a status the page chose itself (`new HttpError(429)`) stays as it is, with the same hint to come back
|
|
1175
|
+
if (e && (e.status === 429 || e.status === 503) && !(e instanceof HttpError) && !res.headersSent) {
|
|
1176
|
+
log('warn', 'load temporarily unavailable', { rid, path: url.pathname, status: 503, ...trace(e) })
|
|
1177
|
+
res.setHeader('retry-after', String(Math.min(Math.ceil(e.retryAfter) || 5, 3600)))
|
|
1178
|
+
res.setHeader('cache-control', 'no-store')
|
|
1179
|
+
return errorPage(req, res, route, params, url, rid, 503)
|
|
1180
|
+
}
|
|
1132
1181
|
const status = intendedStatus(e) || 500
|
|
1133
1182
|
log('error', 'load failed', { rid, path: url.pathname, status, ...trace(e) })
|
|
1183
|
+
if ((status === 429 || status === 503) && !res.headersSent) res.setHeader('retry-after', String(Math.min(Math.ceil(e.retryAfter) || 5, 3600)))
|
|
1134
1184
|
return status === 500 ? errorPage(req, res, route, params, url, rid) : failPage(res, status, rid)
|
|
1135
1185
|
}
|
|
1136
1186
|
if (route.stream && route.server && !fixed && !forms && status === 200 && !res.$capture) return streamed(req, res, route, params, url, rid, ctx, keep(extra || {}), loadOutcome)
|
|
@@ -1144,6 +1194,10 @@ export function createServer(o = {}) {
|
|
|
1144
1194
|
return loadOutcome(e)
|
|
1145
1195
|
}
|
|
1146
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
|
+
}
|
|
1147
1201
|
let body, seo, failed
|
|
1148
1202
|
try {
|
|
1149
1203
|
const at = { path: url.pathname, params: params || {}, query: Object.fromEntries(url.searchParams) } // route() (§41.5)
|
|
@@ -1172,6 +1226,11 @@ export function createServer(o = {}) {
|
|
|
1172
1226
|
// (head, CSS, layout shell) is sent, the view when load() ends. The status and headers are out by then, so a late
|
|
1173
1227
|
// redirect becomes location.replace and a late failure an inline notice with the request id (both logged); load()
|
|
1174
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
|
+
}
|
|
1175
1234
|
async function streamed(req, res, route, params, url, rid, ctx, extra, outcome) {
|
|
1176
1235
|
const [top, bottom = ''] = readFileSync(file(route.page), 'utf8').split('<!--brain:ssr-->')
|
|
1177
1236
|
const sm = await load(route.server)
|
|
@@ -1241,6 +1300,7 @@ export function createServer(o = {}) {
|
|
|
1241
1300
|
log('error', 'load failed (streamed)', { rid, path: url.pathname, status: 200, ...trace(e) }) // headers already out
|
|
1242
1301
|
return fail()
|
|
1243
1302
|
}
|
|
1303
|
+
if (req.$brainFailedReads) softFailure(req, rid, url, 200)
|
|
1244
1304
|
try {
|
|
1245
1305
|
const { body, failed } = await (queue = queue.then(() => render(route, props, null, at), () => render(route, props, null, at)))
|
|
1246
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) })
|
|
@@ -1272,9 +1332,9 @@ export function createServer(o = {}) {
|
|
|
1272
1332
|
return failPage(res, 403, rid)
|
|
1273
1333
|
}
|
|
1274
1334
|
// a failing load or guard → the folder's _error.brain (route `error`, props { message, rid }), else the generic page
|
|
1275
|
-
function errorPage(req, res, route, params, url, rid) {
|
|
1276
|
-
if (wantsHtml(req) && route.error) return ssr(req, res, route.error, params, url, rid, null,
|
|
1277
|
-
return failPage(res,
|
|
1335
|
+
function errorPage(req, res, route, params, url, rid, status = 500) {
|
|
1336
|
+
if (wantsHtml(req) && route.error) return ssr(req, res, route.error, params, url, rid, null, status, { message: 'Something went wrong.', rid })
|
|
1337
|
+
return failPage(res, status, rid)
|
|
1278
1338
|
}
|
|
1279
1339
|
async function render(route, props, forms, at = null) {
|
|
1280
1340
|
const { install } = await load('_server/dom.js')
|
|
@@ -1518,9 +1578,19 @@ export function createServer(o = {}) {
|
|
|
1518
1578
|
if (path === null) return false
|
|
1519
1579
|
const md = file(mdPath(path))
|
|
1520
1580
|
if (!md) return false
|
|
1521
|
-
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) })
|
|
1522
1582
|
return true
|
|
1523
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
|
+
}
|
|
1524
1594
|
function serveStatic(req, res, url) {
|
|
1525
1595
|
const p = publicStaticPath(url.pathname)
|
|
1526
1596
|
if (p === null) return false
|
|
@@ -1538,6 +1608,7 @@ export function createServer(o = {}) {
|
|
|
1538
1608
|
return html(res, 200, page, md ? { vary: 'accept', 'x-brain-static': '1' } : { 'x-brain-static': '1' }, '"' + (mf.build || tag(page)) + '"'), true
|
|
1539
1609
|
}
|
|
1540
1610
|
const headers = {
|
|
1611
|
+
...(ext === '.md' ? twinCanonical(p) : {}),
|
|
1541
1612
|
'content-type': type,
|
|
1542
1613
|
// the worker script must be revalidated on every check, or a CDN could keep an old worker for an hour (§35.7)
|
|
1543
1614
|
'cache-control': p.startsWith('/_b/') ? 'public, max-age=31536000, immutable' : p === '/sw.js' ? 'no-cache' : 'public, max-age=3600',
|
|
@@ -1568,7 +1639,43 @@ export function createServer(o = {}) {
|
|
|
1568
1639
|
if (req.method !== 'GET' && req.method !== 'HEAD') return notFound(req, res)
|
|
1569
1640
|
if (!native) return send(res, 200, json({ ok: true }), headers)
|
|
1570
1641
|
try { await native.proxy(req, res, new URL(url.pathname, url), rid, send) }
|
|
1571
|
-
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 : '')
|
|
1572
1679
|
}
|
|
1573
1680
|
|
|
1574
1681
|
// ---------- dispatch ----------
|
|
@@ -1585,13 +1692,23 @@ export function createServer(o = {}) {
|
|
|
1585
1692
|
}
|
|
1586
1693
|
const rid = req.headers['x-request-id'] && /^[\w-]{1,64}$/.test(req.headers['x-request-id']) ? req.headers['x-request-id'] : randomUUID()
|
|
1587
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
|
+
})
|
|
1588
1702
|
const t0 = performance.now()
|
|
1589
1703
|
res.on('finish', () => {
|
|
1590
1704
|
const ms = performance.now() - t0
|
|
1591
1705
|
if (ms > 1000) log('warn', 'slow request', { rid, path: req.$brainPath || String(req.url || '').split('?')[0], status: res.statusCode, ms: Math.round(ms) })
|
|
1592
1706
|
})
|
|
1593
1707
|
try {
|
|
1594
|
-
//
|
|
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' })
|
|
1595
1712
|
if (req.url.startsWith('//')) return notFound(req, res)
|
|
1596
1713
|
const full = new URL(req.url, 'http://' + (req.headers.host || 'localhost'))
|
|
1597
1714
|
// /_health stays at the root: the supervisor calls it there whatever the base
|
|
@@ -1620,13 +1737,23 @@ export function createServer(o = {}) {
|
|
|
1620
1737
|
// a status page opened directly answers its own status: `/403` is never a 200 that a crawler or a probe can trust
|
|
1621
1738
|
const reserved = post ? 0 : reservedStatus(url.pathname)
|
|
1622
1739
|
if (reserved === 403) return forbidden(req, res, url, rid, '')
|
|
1623
|
-
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
|
+
}
|
|
1624
1748
|
for (const route of routes) {
|
|
1625
1749
|
const m = route.rx.exec(url.pathname)
|
|
1626
1750
|
if (!m) continue
|
|
1627
1751
|
// a form page without load is prerendered: GET serves that file (build ETag); only its POST needs the server
|
|
1628
1752
|
if (!post && route.forms && serveStatic(req, res, url)) return
|
|
1629
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' })
|
|
1630
1757
|
return await (post ? formPost : ssr)(req, res, route, m.groups ? { ...m.groups } : {}, url, rid)
|
|
1631
1758
|
}
|
|
1632
1759
|
if (post) return send(res, 405, 'Method not allowed', { allow: 'GET, HEAD' })
|
|
@@ -1637,9 +1764,19 @@ export function createServer(o = {}) {
|
|
|
1637
1764
|
if (isProbe(url.pathname)) return await answerProbe(req, res, url, rid)
|
|
1638
1765
|
notFound(req, res)
|
|
1639
1766
|
} catch (e) {
|
|
1640
|
-
|
|
1641
|
-
if (
|
|
1642
|
-
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
|
+
}
|
|
1643
1780
|
}
|
|
1644
1781
|
})
|
|
1645
1782
|
if (native) server.once('close', () => native.close())
|
|
@@ -1651,6 +1788,7 @@ export function createServer(o = {}) {
|
|
|
1651
1788
|
}
|
|
1652
1789
|
|
|
1653
1790
|
// ---------- session (signed cookie; shared with the dev server) ----------
|
|
1791
|
+
const STATUS_PAGE = 'status page opened directly'
|
|
1654
1792
|
const COOKIE = '__Host-session', SECRET_COOKIE = '__Host-session-secret', SECRET_MAX = 3500, DEFAULT_SESSION_SECONDS = 7 * 24 * 3600
|
|
1655
1793
|
// Keys that still verify and decrypt during a rotation; new cookies always use the current key.
|
|
1656
1794
|
const previousKeys = () => (process.env.BRAIN_SECRET_PREVIOUS || '').split(',').map(k => k.trim()).filter(Boolean)
|
|
@@ -2193,6 +2331,8 @@ function real(p) {
|
|
|
2193
2331
|
}
|
|
2194
2332
|
if (process.argv[1] && real(process.argv[1]) === real(fileURLToPath(import.meta.url))) {
|
|
2195
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 })
|
|
2196
2336
|
const s = createServer({ dist: fileURLToPath(new URL('.', import.meta.url)) })
|
|
2197
2337
|
const fd = ENV.BRAIN_LISTEN_FD
|
|
2198
2338
|
const where = fd ? { fd: Number(fd) } : { port: +(ENV.PORT || 3000), host: ENV.HOST || '127.0.0.1' }
|