@christianirack/brainllm-runtime 0.8.2 → 0.8.5
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 +12 -6
- package/brain-lazy.js +2 -0
- package/package.json +1 -1
- package/server/server.mjs +204 -49
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
|
@@ -214,13 +214,14 @@ function leak(r) {
|
|
|
214
214
|
leaks.push(r)
|
|
215
215
|
push(r)
|
|
216
216
|
}
|
|
217
|
-
function b602(n, what, line) {
|
|
217
|
+
function b602(n, what, line, foreign) {
|
|
218
218
|
if (!n || flagged.has(n) || n.s >= 3) return
|
|
219
219
|
flagged.add(n)
|
|
220
220
|
const i = instOf(n), name = i ? nameOf(i) : '?'
|
|
221
221
|
const fx = `fx in ${name} (${what})${line ? ' ' + line : ''}`
|
|
222
222
|
if (i) (b602ByInst.get(i.r) || b602ByInst.set(i.r, []).get(i.r)).push(fx)
|
|
223
|
-
|
|
223
|
+
// a listener another module registered while this fx ran is not the component's: name where it really comes from
|
|
224
|
+
leak({ code: 'B0602', h: i ? i.h : null, name, file: foreign && line ? line.replace(/:\d+$/, '') : i && i.C.f, line, msg: `global ${what} inside an fx${foreign && line ? ` of ${name}, registered by ${line},` : ''} without cleanup`, fix: 'return a cleanup function from the fx: `fx(() => { …; return () => remove() })`', fx: [fx] })
|
|
224
225
|
}
|
|
225
226
|
function patchGlobalListeners() {
|
|
226
227
|
for (const t of [G.window, G.document].filter(Boolean)) {
|
|
@@ -229,8 +230,8 @@ function patchGlobalListeners() {
|
|
|
229
230
|
t.addEventListener = function (type, ...a) {
|
|
230
231
|
const c = ctx()
|
|
231
232
|
if (c && c.o === 'fx') {
|
|
232
|
-
const line = site()
|
|
233
|
-
O.st.call(G, () => !c.n.k && b602(c.n, `addEventListener('${type}')`, line), 0)
|
|
233
|
+
const { line, foreign } = site()
|
|
234
|
+
O.st.call(G, () => !c.n.k && b602(c.n, `addEventListener('${type}')`, line, foreign), 0)
|
|
234
235
|
}
|
|
235
236
|
return add.call(this, type, ...a)
|
|
236
237
|
}
|
|
@@ -264,6 +265,9 @@ const fin = G.FinalizationRegistry ? new FinalizationRegistry(w => (w.dead = 1))
|
|
|
264
265
|
// Where a pending request went, without origin or query: evidence for a fix, never a value.
|
|
265
266
|
const pendingUrls = () => [...inflight.values()].slice(0, 3).map(r => `${r.m} ${r.url.replace(/^[a-z]+:\/\/[^/]+/i, '').split(/[?#]/)[0]}`)
|
|
266
267
|
const SETTLE_MS = 60000
|
|
268
|
+
// While a suite holds the verdict, an instance only ages from its release: a promise or a script load that begins after
|
|
269
|
+
// the unmount is invisible to the network watch and keeps the instance alive for a bounded time.
|
|
270
|
+
let held_verdict = false
|
|
267
271
|
function watch(d) {
|
|
268
272
|
if (!fin) return
|
|
269
273
|
// 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 +283,13 @@ function checkLeaks() {
|
|
|
279
283
|
for (let j = watched.length - 1; j >= 0; j--) {
|
|
280
284
|
const w = watched[j]
|
|
281
285
|
if (w.dead) watched.splice(j, 1)
|
|
286
|
+
else if (held_verdict) w.at = gcs
|
|
282
287
|
// A request from before the unmount may be what holds it: judge after it settles (bounded, for a long poll).
|
|
283
288
|
else if (w.net.some(id => inflight.has(id)) && now() - w.since < SETTLE_MS) w.at = gcs
|
|
284
289
|
else if (gcs - w.at >= 2) {
|
|
285
290
|
watched.splice(j, 1)
|
|
286
|
-
const
|
|
291
|
+
const finished = !w.net.some(id => inflight.has(id))
|
|
292
|
+
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
293
|
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
294
|
}
|
|
289
295
|
}
|
|
@@ -708,7 +714,7 @@ export function inspect(o) {
|
|
|
708
714
|
G.document.addEventListener.call(G.document, 'keydown', e => e.altKey && e.shiftKey && e.code === 'KeyB' && toggle())
|
|
709
715
|
return api
|
|
710
716
|
}
|
|
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() } }
|
|
717
|
+
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
718
|
|
|
713
719
|
// ---------- mini panel (Alt+Shift+B): heap, leaks and link to the full debugger (§9.1) ----------
|
|
714
720
|
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.5",
|
|
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) }
|
|
@@ -527,17 +546,20 @@ export function nativeAuthBridge(raw, refused, serviceKey, report) {
|
|
|
527
546
|
try { bytes = Buffer.from(JSON.stringify(body)) } catch { throw new HttpError(400, 'Native resource values must be JSON serializable') }
|
|
528
547
|
if (bytes.length > 65536) throw new HttpError(413, 'Native resource values exceed their byte budget')
|
|
529
548
|
}
|
|
530
|
-
const
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
549
|
+
const outgoing = () => {
|
|
550
|
+
const req = Readable.from(bytes.length ? [bytes] : [])
|
|
551
|
+
req.method = method
|
|
552
|
+
req.$brainForwardedFor = forwardedFor(incoming)
|
|
553
|
+
// A service call acts as the key's owner, never as the visitor: it sends no session.
|
|
554
|
+
req.headers = { ...(serviceKey ? { authorization: 'Bearer ' + serviceKey } : ownSession ? {} : { cookie: cookies(incoming, false) }), accept: 'application/json', 'content-type':'application/json', 'content-length':String(bytes.length) }
|
|
555
|
+
if (method !== 'GET') {
|
|
556
|
+
req.headers.origin = bridgeOrigin(incoming, rid, 'Native writes require a same-origin server request')
|
|
557
|
+
}
|
|
558
|
+
return req
|
|
537
559
|
}
|
|
560
|
+
let req = outgoing()
|
|
538
561
|
let value
|
|
539
|
-
|
|
540
|
-
await proxy(req, response, new URL(path, target), rid, (_res, status, data, headers) => {
|
|
562
|
+
const answered = (_res, status, data, headers) => {
|
|
541
563
|
if (status === 422) {
|
|
542
564
|
// the backend's own field messages (never driver text): a server function that rethrows answers 422 {errors}
|
|
543
565
|
let fields
|
|
@@ -557,7 +579,29 @@ export function nativeAuthBridge(raw, refused, serviceKey, report) {
|
|
|
557
579
|
if (status === 204) { value = null; return }
|
|
558
580
|
if (!String(headers['content-type'] || '').startsWith('application/json')) throw new HttpError(502, 'Native resource response must be JSON')
|
|
559
581
|
try { value = JSON.parse(data.toString('utf8')) } catch { throw new HttpError(502, 'Native resource response is invalid') }
|
|
560
|
-
|
|
582
|
+
}
|
|
583
|
+
try {
|
|
584
|
+
// A restarting backend refuses connections for a few seconds: a read waits for it instead of failing the page.
|
|
585
|
+
for (let wait = 250; ; wait *= 2) {
|
|
586
|
+
try {
|
|
587
|
+
await proxy(req, response, new URL(path, target), rid, answered, 2 * 1024 * 1024)
|
|
588
|
+
break
|
|
589
|
+
} catch (e) {
|
|
590
|
+
if (method !== 'GET' || wait > 2000 || stopped || response.destroyed || !['ECONNREFUSED', 'ECONNRESET'].includes(e?.transport)) throw e
|
|
591
|
+
req.destroy()
|
|
592
|
+
await new Promise(done => setTimeout(done, wait))
|
|
593
|
+
req = outgoing()
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
} catch (e) {
|
|
597
|
+
// A read that failed: `load` may catch it and render anyway, so the page answer says what happened (ssr below).
|
|
598
|
+
if (method === 'GET' && !(e?.status >= 400 && e.status < 500)) {
|
|
599
|
+
const seen = incoming.$brainFailedReads || (incoming.$brainFailedReads = { reads: 0, backendCode: undefined, transport: undefined })
|
|
600
|
+
seen.reads++
|
|
601
|
+
seen.backendCode ??= e?.code
|
|
602
|
+
seen.transport ??= e?.transport
|
|
603
|
+
}
|
|
604
|
+
throw e
|
|
561
605
|
} finally { req.destroy() }
|
|
562
606
|
if (response.destroyed || response.writableEnded) throw new HttpError(409, 'The server request has ended')
|
|
563
607
|
return value
|
|
@@ -796,18 +840,22 @@ export function createServer(o = {}) {
|
|
|
796
840
|
// ctx.env: what the supervisor passed, read-only; the service key stays with ctx.backend.service
|
|
797
841
|
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
842
|
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
843
|
const backendSession = o.backendSession ?? env.BRAIN_BACKEND_SESSION
|
|
802
844
|
if (backendSession && backendSession !== 'app') throw Error("BRAIN_BACKEND_SESSION must be app, or unset for the backend's own session")
|
|
845
|
+
const native = nativeAuthBridge(o.backendURL ?? env.BRAIN_BACKEND_URL, (...why) => originRefused(...why), serviceKey, (...entry) => log(...entry), backendSession === 'app')
|
|
846
|
+
// Using the native backend does not have to mean it owns the visitor's session: `app` keeps the signed cookie.
|
|
803
847
|
const nativeSession = native && backendSession !== 'app'
|
|
804
848
|
if (o.nativeSockets !== undefined && typeof o.nativeSockets !== 'boolean') throw Error('nativeSockets must be an explicit boolean')
|
|
805
849
|
const SITEMAP_BYTES = +(o.sitemapBytes ?? 50 * 1024 * 1024) // protocol: 50 MB uncompressed per sitemap file
|
|
806
850
|
const secret = o.secret ?? env.BRAIN_SECRET ?? ''
|
|
807
851
|
const COMPRESS = o.compress !== false && env.BRAIN_COMPRESS !== 'off' // a proxy that already compresses can opt out
|
|
808
852
|
const sink = o.log || ((level, msg, extra) => console[level === 'error' ? 'error' : 'log'](json({ t: new Date().toISOString(), level, msg, ...extra })))
|
|
853
|
+
// Requests in flight by rid: a 5xx whose cause reached no log line gets one when the response closes (dispatch below).
|
|
854
|
+
const liveRequests = new Map()
|
|
855
|
+
let statusPageWindow = 0, statusPageLogs = 0
|
|
809
856
|
const log = (level, msg, extra = {}) => {
|
|
810
857
|
const safe = { ...extra }
|
|
858
|
+
if (level !== 'info' && extra.rid) { const live = liveRequests.get(extra.rid); if (live) live.logged = true }
|
|
811
859
|
// Error messages may contain credentials or request data. Retain only bounded V8 source frames.
|
|
812
860
|
if (typeof safe.stack === 'string') {
|
|
813
861
|
const kind = /^(TypeError|ReferenceError|RangeError|SyntaxError|AggregateError|Error):/.exec(safe.stack)?.[1] || 'Error'
|
|
@@ -843,6 +891,9 @@ export function createServer(o = {}) {
|
|
|
843
891
|
// log fields for a thrown value: its stack (sanitized by log) plus, for B09xx budget failures, the typed fields only
|
|
844
892
|
const trace = e => {
|
|
845
893
|
const out = { stack: String((e && e.stack) || e) }
|
|
894
|
+
if (e instanceof HttpError && OWN_CAUSES.has(e.message)) out.cause = e.message
|
|
895
|
+
if (e && CODE_SHAPE.test(e.transport || '')) out.transport = e.transport
|
|
896
|
+
if (e && typeof e.code === 'string' && CODE_SHAPE.test(e.code)) out.backendCode = e.code
|
|
846
897
|
if (e && e.kind === 'budget' && /^B09\d\d$/.test(e.code) && typeof e.budget === 'string' && /^[a-zA-Z]{1,16}$/.test(e.budget)) {
|
|
847
898
|
const str = (v, n) => typeof v === 'string' ? v.slice(0, n) : null, pos = v => Number.isSafeInteger(v) && v > 0 ? v : null
|
|
848
899
|
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 +987,7 @@ export function createServer(o = {}) {
|
|
|
936
987
|
res.req.$nativeLogout.then(() => send(res, status, body, headers, configuredExternal), () => send(res, 503, '', {}, false))
|
|
937
988
|
return
|
|
938
989
|
}
|
|
939
|
-
if (native && res.req.$nativeLogoutError) { status = 503; body = ''; headers = {}; res.req.$nativeLogoutError = false }
|
|
990
|
+
if (native && res.req.$nativeLogoutError) { status = 503; body = ''; headers = {}; res.req.$nativeLogoutError = false; res.$brainCause = 'native sign-out failed' }
|
|
940
991
|
if (native && res.req.$nativeLogoutHeaders) {
|
|
941
992
|
const relay = res.req.$nativeLogoutHeaders, own = headers['set-cookie']
|
|
942
993
|
headers = { ...headers, ...relay, 'set-cookie': [...(own ? Array.isArray(own) ? own : [own] : []), ...relay['set-cookie']] }
|
|
@@ -974,6 +1025,7 @@ export function createServer(o = {}) {
|
|
|
974
1025
|
delete headers['content-length']
|
|
975
1026
|
}
|
|
976
1027
|
}
|
|
1028
|
+
if (headers.vary) headers = { ...headers, vary: varyOnce(headers.vary) }
|
|
977
1029
|
res.writeHead(status, { ...SECURITY, ...(server.draining ? { connection: 'close' } : {}), ...headers })
|
|
978
1030
|
res.end(res.req.method === 'HEAD' || status === 204 || status === 205 || status === 304 ? undefined : body)
|
|
979
1031
|
}
|
|
@@ -995,8 +1047,8 @@ export function createServer(o = {}) {
|
|
|
995
1047
|
const notFound = (req, res) => failPage(res, 404, res.getHeader('x-request-id'))
|
|
996
1048
|
// `gone()` from load (§33): 410 tells crawlers the URL was removed for good; views/410.brain, else the 404 page
|
|
997
1049
|
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'] })
|
|
1050
|
+
const failPage = (res, status, rid, detail) => {
|
|
1051
|
+
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
1052
|
const custom = (status === 404 && mf.notFound) || (mf.errors && mf.errors[status]) || (status === 410 && !file('410.html') ? mf.notFound : `${status}.html`)
|
|
1001
1053
|
const path = typeof custom === 'string' && file(custom)
|
|
1002
1054
|
return html(res, status, path ? readFileSync(path, 'utf8') : defaultError(status, rid, BASE))
|
|
@@ -1049,16 +1101,20 @@ export function createServer(o = {}) {
|
|
|
1049
1101
|
const SSRC = new Map(), SSRC_MAX = 500, SSRC_BYTES = 32 * 1024 * 1024, SSRC_ENTRY = 1024 * 1024
|
|
1050
1102
|
let ssrBytes = 0
|
|
1051
1103
|
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
|
-
|
|
1104
|
+
const n = +route.cache, ms = n * 1000, key = (route.locale || '') + url.pathname + cacheSearch(url)
|
|
1105
|
+
const fill = () => {
|
|
1106
|
+
let failed = false
|
|
1107
|
+
return new Promise(ok => {
|
|
1055
1108
|
const cap = { req, $capture: (out, h, st) => {
|
|
1056
1109
|
const headers = Object.fromEntries(Object.entries(h).map(([k, v]) => [k.toLowerCase(), v]))
|
|
1057
1110
|
const privateResponse = headers['set-cookie'] || headers['vary'] || /private|no-store|no-cache/i.test(headers['cache-control'] || '')
|
|
1111
|
+
if (st >= 500) failed = true
|
|
1058
1112
|
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() {} }
|
|
1113
|
+
}, writeHead(st) { if (st >= 500) failed = true }, end: () => ok(null), setHeader() {}, getHeader: name => (name === 'x-request-id' ? rid : undefined), once() {}, removeListener() {} }
|
|
1060
1114
|
ssr(req, cap, route, params, url, rid, null, 200, null, true).catch(() => ok(null))
|
|
1061
1115
|
}).then(c => {
|
|
1116
|
+
// A failed revalidation keeps the copy until 2N, which is what `stale-if-error` promises downstream.
|
|
1117
|
+
if ((!c || !c.cacheable) && (failed || req.$brainFailedReads) && SSRC.has(key)) return c
|
|
1062
1118
|
if (!c || !c.cacheable) {
|
|
1063
1119
|
ssrBytes -= SSRC.get(key)?.bytes || 0
|
|
1064
1120
|
SSRC.delete(key)
|
|
@@ -1076,7 +1132,8 @@ export function createServer(o = {}) {
|
|
|
1076
1132
|
}
|
|
1077
1133
|
return c
|
|
1078
1134
|
})
|
|
1079
|
-
|
|
1135
|
+
}
|
|
1136
|
+
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
1137
|
let c = SSRC.get(key)
|
|
1081
1138
|
const age = c ? Date.now() - c.t : Infinity
|
|
1082
1139
|
if (c && age < 2 * ms) {
|
|
@@ -1153,6 +1210,10 @@ export function createServer(o = {}) {
|
|
|
1153
1210
|
return loadOutcome(e)
|
|
1154
1211
|
}
|
|
1155
1212
|
if (props && (props.redirect || props.notFound === true)) return loadOutcome(props) // `return redirect()` / `return notFound()`
|
|
1213
|
+
if (req.$brainFailedReads && !fixed) {
|
|
1214
|
+
softFailure(req, rid, url, status)
|
|
1215
|
+
extra = { ...extra, 'cache-control': 'no-store' } // what it rendered instead is not worth keeping
|
|
1216
|
+
}
|
|
1156
1217
|
let body, seo, failed
|
|
1157
1218
|
try {
|
|
1158
1219
|
const at = { path: url.pathname, params: params || {}, query: Object.fromEntries(url.searchParams) } // route() (§41.5)
|
|
@@ -1181,6 +1242,11 @@ export function createServer(o = {}) {
|
|
|
1181
1242
|
// (head, CSS, layout shell) is sent, the view when load() ends. The status and headers are out by then, so a late
|
|
1182
1243
|
// redirect becomes location.replace and a late failure an inline notice with the request id (both logged); load()
|
|
1183
1244
|
// cannot change the session on a streamed route.
|
|
1245
|
+
// `load` caught a failed backend read and rendered: the status it asked for stands, the log says what is behind it.
|
|
1246
|
+
const softFailure = (req, rid, url, status) => {
|
|
1247
|
+
const { reads, backendCode, transport } = req.$brainFailedReads
|
|
1248
|
+
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' })
|
|
1249
|
+
}
|
|
1184
1250
|
async function streamed(req, res, route, params, url, rid, ctx, extra, outcome) {
|
|
1185
1251
|
const [top, bottom = ''] = readFileSync(file(route.page), 'utf8').split('<!--brain:ssr-->')
|
|
1186
1252
|
const sm = await load(route.server)
|
|
@@ -1250,6 +1316,7 @@ export function createServer(o = {}) {
|
|
|
1250
1316
|
log('error', 'load failed (streamed)', { rid, path: url.pathname, status: 200, ...trace(e) }) // headers already out
|
|
1251
1317
|
return fail()
|
|
1252
1318
|
}
|
|
1319
|
+
if (req.$brainFailedReads) softFailure(req, rid, url, 200)
|
|
1253
1320
|
try {
|
|
1254
1321
|
const { body, failed } = await (queue = queue.then(() => render(route, props, null, at), () => render(route, props, null, at)))
|
|
1255
1322
|
for (const f of failed || []) log('error', 'component failed during SSR', { rid, path: url.pathname, comp: f.comp, where: f.w, ...trace(f.e) })
|
|
@@ -1301,6 +1368,7 @@ export function createServer(o = {}) {
|
|
|
1301
1368
|
}
|
|
1302
1369
|
if (route.i18n) await load(route.i18n) // the route's dictionary chunk: add(locale, {…}) before anything renders
|
|
1303
1370
|
globalThis.__brainForms = forms // form() reads it while the view is built (renders are serialized)
|
|
1371
|
+
globalThis.__brainIds = (globalThis.__brainIds || 0) + 1 // each page numbers its instance ids from the start, as the client will
|
|
1304
1372
|
globalThis.__brainLocale = route.locale || null // t(), fmt and locale() of this page (§37)
|
|
1305
1373
|
globalThis.__brainAlternates = route.alternates || null // <LangSwitch> links, in the HTML before any JS
|
|
1306
1374
|
globalThis.__brainRoute = at // route(): app path, params, query of this request
|
|
@@ -1417,8 +1485,8 @@ export function createServer(o = {}) {
|
|
|
1417
1485
|
// pages, plus an optional `lastmod`, at request time. /sitemap.xml is the build's file with those URLs added (a sitemap
|
|
1418
1486
|
// index gets one more child, /sitemap-dynamic.xml). A file holds at most 50 000 URLs (sitemaps.org): when the build's
|
|
1419
1487
|
// URLs plus the dynamic ones pass that, /sitemap.xml becomes an index of /sitemap-static.xml (the build's file) and
|
|
1420
|
-
// /sitemap-dynamic.xml; dynamic URLs past 50 000 are dropped with a warning. Absolute URLs take the origin
|
|
1421
|
-
// build's sitemap
|
|
1488
|
+
// /sitemap-dynamic.xml; dynamic URLs past 50 000 are dropped with a warning. Absolute URLs take the site origin from
|
|
1489
|
+
// the manifest (an older manifest: from the build's sitemap); a build without a sitemap serves none. The list is kept 5 min (one sitemap() run
|
|
1422
1490
|
// at a time when it expires) and the response says max-age=300; a sitemap() that throws is logged and skipped.
|
|
1423
1491
|
const DYN = routes.filter(r => r.sitemap && r.server)
|
|
1424
1492
|
let dynList = null
|
|
@@ -1434,14 +1502,23 @@ export function createServer(o = {}) {
|
|
|
1434
1502
|
const day = v => (v instanceof Date ? v.toISOString().slice(0, 10) : typeof v === 'string' && /^\d{4}-\d{2}-\d{2}/.test(v) ? v : null)
|
|
1435
1503
|
const MAX_URLS = 50000
|
|
1436
1504
|
let dynRun = null
|
|
1505
|
+
const dynGood = new Map()
|
|
1437
1506
|
async function collect(origin) {
|
|
1438
1507
|
const urls = []
|
|
1439
1508
|
let dropped = 0
|
|
1440
|
-
|
|
1509
|
+
// Views answer at the same time, so one slow view costs one timeout; a view that fails keeps its last good list.
|
|
1510
|
+
const lists = await Promise.all(DYN.map(async r => {
|
|
1441
1511
|
try {
|
|
1442
1512
|
const sm = await load(r.server)
|
|
1443
1513
|
const list = typeof sm.sitemap === 'function' ? await withTimeout(Promise.resolve().then(() => sm.sitemap({ env })), TIMEOUT) : []
|
|
1444
|
-
|
|
1514
|
+
dynGood.set(r, Array.isArray(list) ? list : [])
|
|
1515
|
+
} catch (e) {
|
|
1516
|
+
log('error', 'sitemap() failed', { view: r.view, ...trace(e) })
|
|
1517
|
+
}
|
|
1518
|
+
return dynGood.get(r) || []
|
|
1519
|
+
}))
|
|
1520
|
+
DYN.forEach((r, n) => {
|
|
1521
|
+
for (const p of lists[n]) {
|
|
1445
1522
|
const path = fill(r.path, p)
|
|
1446
1523
|
if (!path) continue
|
|
1447
1524
|
const loc = origin + withBase(path)
|
|
@@ -1449,19 +1526,17 @@ export function createServer(o = {}) {
|
|
|
1449
1526
|
else if (urls.length < MAX_URLS) urls.push({ loc, lastmod: day(p.lastmod) })
|
|
1450
1527
|
else dropped++
|
|
1451
1528
|
}
|
|
1452
|
-
|
|
1453
|
-
log('error', 'sitemap() failed', { view: r.view, ...trace(e) })
|
|
1454
|
-
}
|
|
1455
|
-
}
|
|
1529
|
+
})
|
|
1456
1530
|
if (dropped) log('warn', 'sitemap(): more than 50000 URLs, the rest are not listed', { dropped })
|
|
1531
|
+
if (!urls.length) log('warn', 'sitemap(): no view returned a URL, so /sitemap.xml lists only the pages of the build', { views: DYN.map(r => r.view) })
|
|
1457
1532
|
dynList = { at: Date.now(), urls }
|
|
1458
1533
|
return urls
|
|
1459
1534
|
}
|
|
1460
1535
|
async function dynamicUrls(origin) {
|
|
1461
1536
|
if (dynList && Date.now() - dynList.at < 300000) return dynList.urls
|
|
1462
|
-
// concurrent requests after expiry share one run of every sitemap()
|
|
1537
|
+
// concurrent requests after expiry share one run of every sitemap(); a crawler never waits while a list exists
|
|
1463
1538
|
if (!dynRun) dynRun = collect(origin).finally(() => (dynRun = null))
|
|
1464
|
-
return dynRun
|
|
1539
|
+
return dynList ? dynList.urls : dynRun
|
|
1465
1540
|
}
|
|
1466
1541
|
// §33 S01: every sitemap file holds at most 50 000 URLs and SITEMAP_BYTES bytes of UTF-8 XML; the dynamic URLs fill
|
|
1467
1542
|
// /sitemap-dynamic.xml, /sitemap-dynamic-2.xml, … in sitemap() order, so the split is deterministic
|
|
@@ -1484,7 +1559,7 @@ export function createServer(o = {}) {
|
|
|
1484
1559
|
const f = file('/sitemap.xml')
|
|
1485
1560
|
if (!f) return false
|
|
1486
1561
|
const text = readFileSync(f, 'utf8')
|
|
1487
|
-
const origin = [...text.matchAll(/<loc>(https?:\/\/[^<]+)<\/loc>/g)].map(m => URL.canParse(m[1]) && new URL(m[1]).origin).find(Boolean)
|
|
1562
|
+
const origin = mf.site || [...text.matchAll(/<loc>(https?:\/\/[^<]+)<\/loc>/g)].map(m => URL.canParse(m[1]) && new URL(m[1]).origin).find(Boolean)
|
|
1488
1563
|
if (!origin) return false
|
|
1489
1564
|
const index = text.includes('<sitemapindex')
|
|
1490
1565
|
const sets = pages(await dynamicUrls(origin))
|
|
@@ -1527,9 +1602,19 @@ export function createServer(o = {}) {
|
|
|
1527
1602
|
if (path === null) return false
|
|
1528
1603
|
const md = file(mdPath(path))
|
|
1529
1604
|
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' })
|
|
1605
|
+
send(res, 200, req.method === 'HEAD' ? '' : readFileSync(md), { 'content-type': MIME['.md'], 'cache-control': 'public, max-age=3600', vary: 'accept', ...twinCanonical(path) })
|
|
1531
1606
|
return true
|
|
1532
1607
|
}
|
|
1608
|
+
// A Markdown copy repeats its page: the header names the canonical the page itself declares. A page without one (no
|
|
1609
|
+
// site configured) or with a per-request pattern gets no header, so this never invents a URL.
|
|
1610
|
+
function twinCanonical(path) {
|
|
1611
|
+
const stem = path.replace(/\.md$/, '').replace(/\/+$/, '')
|
|
1612
|
+
const page = file(stem ? stem + '.html' : 'index.html') || file(stem + '/index.html')
|
|
1613
|
+
if (!page) return {}
|
|
1614
|
+
const tag = /<link\b[^>]*\brel=["']canonical["'][^>]*>/i.exec(readFileSync(page, 'utf8'))?.[0]
|
|
1615
|
+
const href = tag && /\bhref="(https?:\/\/[^"<>\s]+)"/.exec(tag)?.[1].replaceAll('&', '&')
|
|
1616
|
+
return href && URL.canParse(href) && !/\/[:*]/.test(new URL(href).pathname) ? { link: `<${href}>; rel="canonical"` } : {}
|
|
1617
|
+
}
|
|
1533
1618
|
function serveStatic(req, res, url) {
|
|
1534
1619
|
const p = publicStaticPath(url.pathname)
|
|
1535
1620
|
if (p === null) return false
|
|
@@ -1547,6 +1632,7 @@ export function createServer(o = {}) {
|
|
|
1547
1632
|
return html(res, 200, page, md ? { vary: 'accept', 'x-brain-static': '1' } : { 'x-brain-static': '1' }, '"' + (mf.build || tag(page)) + '"'), true
|
|
1548
1633
|
}
|
|
1549
1634
|
const headers = {
|
|
1635
|
+
...(ext === '.md' ? twinCanonical(p) : {}),
|
|
1550
1636
|
'content-type': type,
|
|
1551
1637
|
// the worker script must be revalidated on every check, or a CDN could keep an old worker for an hour (§35.7)
|
|
1552
1638
|
'cache-control': p.startsWith('/_b/') ? 'public, max-age=31536000, immutable' : p === '/sw.js' ? 'no-cache' : 'public, max-age=3600',
|
|
@@ -1577,7 +1663,43 @@ export function createServer(o = {}) {
|
|
|
1577
1663
|
if (req.method !== 'GET' && req.method !== 'HEAD') return notFound(req, res)
|
|
1578
1664
|
if (!native) return send(res, 200, json({ ok: true }), headers)
|
|
1579
1665
|
try { await native.proxy(req, res, new URL(url.pathname, url), rid, send) }
|
|
1580
|
-
catch
|
|
1666
|
+
catch (e) {
|
|
1667
|
+
if (!res.headersSent) {
|
|
1668
|
+
log('warn', 'health probe could not reach the native backend', { rid, path: url.pathname, status: 503, ...trace(e) })
|
|
1669
|
+
send(res, 503, json({ ok: false }), headers)
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
const urlVariant = req => {
|
|
1675
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') return null
|
|
1676
|
+
const q = req.url.indexOf('?'), raw = q < 0 ? req.url : req.url.slice(0, q)
|
|
1677
|
+
if (!raw.includes('//') && !raw.endsWith('/index.html')) return null
|
|
1678
|
+
let path = raw.replace(/\/{2,}/g, '/')
|
|
1679
|
+
const app = '/' + (path.startsWith(BASE) ? path.slice(BASE.length) : path.slice(1))
|
|
1680
|
+
if (/^\/(?:api|_brain|_r|s)(?:\/|$)/.test(app) && !raw.startsWith('//')) return null
|
|
1681
|
+
if (path.endsWith('/index.html')) path = path.slice(0, -'index.html'.length)
|
|
1682
|
+
if (path !== BASE && path !== '/' && (path.endsWith('/') && SLASH !== 'always')) path = path.replace(/\/+$/, '')
|
|
1683
|
+
const location = path + (q < 0 ? '' : req.url.slice(q))
|
|
1684
|
+
try { safeLocation(location) } catch { return null }
|
|
1685
|
+
return location
|
|
1686
|
+
}
|
|
1687
|
+
// `<meta seo query="page">`: `page` is a number from 1 and page 1 is the URL without it. A view decides past its last
|
|
1688
|
+
// page with notFound(). → null (nothing to do) · 404 · the URL to redirect to.
|
|
1689
|
+
const pagedRoutes = new Map()
|
|
1690
|
+
const pageParameter = (route, url) => {
|
|
1691
|
+
if (!url.searchParams.has('page') || !route.page) return null
|
|
1692
|
+
if (!pagedRoutes.has(route.page)) {
|
|
1693
|
+
const keys = /<meta name="brain-query" content="([^"]*)">/.exec(readFileSync(file(route.page), 'utf8'))?.[1]
|
|
1694
|
+
pagedRoutes.set(route.page, !!keys && keys.split(/[\s,]+/).includes('page'))
|
|
1695
|
+
}
|
|
1696
|
+
if (!pagedRoutes.get(route.page)) return null
|
|
1697
|
+
const values = url.searchParams.getAll('page')
|
|
1698
|
+
if (values.length > 1 || !/^[1-9]\d{0,8}$/.test(values[0])) return 404
|
|
1699
|
+
if (values[0] !== '1') return null
|
|
1700
|
+
const rest = new URLSearchParams(url.search)
|
|
1701
|
+
rest.delete('page')
|
|
1702
|
+
return url.pathname + (rest.size ? '?' + rest : '')
|
|
1581
1703
|
}
|
|
1582
1704
|
|
|
1583
1705
|
// ---------- dispatch ----------
|
|
@@ -1594,13 +1716,23 @@ export function createServer(o = {}) {
|
|
|
1594
1716
|
}
|
|
1595
1717
|
const rid = req.headers['x-request-id'] && /^[\w-]{1,64}$/.test(req.headers['x-request-id']) ? req.headers['x-request-id'] : randomUUID()
|
|
1596
1718
|
res.setHeader('x-request-id', rid)
|
|
1719
|
+
const live = { logged: false }
|
|
1720
|
+
liveRequests.set(rid, live)
|
|
1721
|
+
res.once('close', () => {
|
|
1722
|
+
if (liveRequests.get(rid) === live) liveRequests.delete(rid)
|
|
1723
|
+
// The net under every path: a 5xx that was answered always leaves a line with its request id.
|
|
1724
|
+
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' }) })
|
|
1725
|
+
})
|
|
1597
1726
|
const t0 = performance.now()
|
|
1598
1727
|
res.on('finish', () => {
|
|
1599
1728
|
const ms = performance.now() - t0
|
|
1600
1729
|
if (ms > 1000) log('warn', 'slow request', { rid, path: req.$brainPath || String(req.url || '').split('?')[0], status: res.statusCode, ms: Math.round(ms) })
|
|
1601
1730
|
})
|
|
1602
1731
|
try {
|
|
1603
|
-
//
|
|
1732
|
+
// One URL per page: `//route`, `/a//b` and `/route/index.html` answer one 301 on this origin. The path is rewritten
|
|
1733
|
+
// as text, before URL parsing: `//host/x` would be read as another host. Backend paths are left to the backend.
|
|
1734
|
+
const variant = urlVariant(req)
|
|
1735
|
+
if (variant) return send(res, 301, '', { location: variant, 'cache-control': 'public, max-age=3600' })
|
|
1604
1736
|
if (req.url.startsWith('//')) return notFound(req, res)
|
|
1605
1737
|
const full = new URL(req.url, 'http://' + (req.headers.host || 'localhost'))
|
|
1606
1738
|
// /_health stays at the root: the supervisor calls it there whatever the base
|
|
@@ -1629,13 +1761,23 @@ export function createServer(o = {}) {
|
|
|
1629
1761
|
// a status page opened directly answers its own status: `/403` is never a 200 that a crawler or a probe can trust
|
|
1630
1762
|
const reserved = post ? 0 : reservedStatus(url.pathname)
|
|
1631
1763
|
if (reserved === 403) return forbidden(req, res, url, rid, '')
|
|
1632
|
-
if (reserved)
|
|
1764
|
+
if (reserved) {
|
|
1765
|
+
// Anyone can open it, so the note about it is bounded and is not an error.
|
|
1766
|
+
const minute = Math.floor(Date.now() / 60000)
|
|
1767
|
+
if (statusPageWindow !== minute) { statusPageWindow = minute; statusPageLogs = 0 }
|
|
1768
|
+
res.$brainCause = STATUS_PAGE
|
|
1769
|
+
res.$brainMuted = statusPageLogs++ >= 16
|
|
1770
|
+
return failPage(res, reserved, rid)
|
|
1771
|
+
}
|
|
1633
1772
|
for (const route of routes) {
|
|
1634
1773
|
const m = route.rx.exec(url.pathname)
|
|
1635
1774
|
if (!m) continue
|
|
1636
1775
|
// a form page without load is prerendered: GET serves that file (build ETag); only its POST needs the server
|
|
1637
1776
|
if (!post && route.forms && serveStatic(req, res, url)) return
|
|
1638
1777
|
if (!post && serveTwin(req, res, url)) return
|
|
1778
|
+
const paged = post ? null : pageParameter(route, url)
|
|
1779
|
+
if (paged === 404) return notFound(req, res)
|
|
1780
|
+
if (paged) return send(res, 301, '', { location: withBase(paged), 'cache-control': 'public, max-age=3600' })
|
|
1639
1781
|
return await (post ? formPost : ssr)(req, res, route, m.groups ? { ...m.groups } : {}, url, rid)
|
|
1640
1782
|
}
|
|
1641
1783
|
if (post) return send(res, 405, 'Method not allowed', { allow: 'GET, HEAD' })
|
|
@@ -1646,9 +1788,19 @@ export function createServer(o = {}) {
|
|
|
1646
1788
|
if (isProbe(url.pathname)) return await answerProbe(req, res, url, rid)
|
|
1647
1789
|
notFound(req, res)
|
|
1648
1790
|
} catch (e) {
|
|
1649
|
-
|
|
1650
|
-
if (
|
|
1651
|
-
else
|
|
1791
|
+
const busy = e instanceof HttpError && e.code === 'BRIDGE_BUSY'
|
|
1792
|
+
if (busy) log('warn', 'backend bridge busy', { rid, path: req.$brainPath || String(req.url || '').split('?')[0], fix: e.fix })
|
|
1793
|
+
else log('error', 'unhandled', { rid, path: req.url, ...trace(e) })
|
|
1794
|
+
try {
|
|
1795
|
+
if (busy && !res.headersSent) { res.setHeader('retry-after', String(e.retryAfter)); failPage(res, 503, rid, { code: e.code, message: 'Service Unavailable', fix: e.fix }) }
|
|
1796
|
+
else if (!res.headersSent) failPage(res, intendedStatus(e) || 500, rid)
|
|
1797
|
+
else res.end()
|
|
1798
|
+
} catch (again) {
|
|
1799
|
+
// the error page itself failed: still an answer, never an unhandled rejection that takes the process down
|
|
1800
|
+
log('error', 'error page failed', { rid, path: req.url, ...trace(again) })
|
|
1801
|
+
if (!res.headersSent) res.writeHead(500, SECURITY)
|
|
1802
|
+
res.end()
|
|
1803
|
+
}
|
|
1652
1804
|
}
|
|
1653
1805
|
})
|
|
1654
1806
|
if (native) server.once('close', () => native.close())
|
|
@@ -1660,6 +1812,7 @@ export function createServer(o = {}) {
|
|
|
1660
1812
|
}
|
|
1661
1813
|
|
|
1662
1814
|
// ---------- session (signed cookie; shared with the dev server) ----------
|
|
1815
|
+
const STATUS_PAGE = 'status page opened directly'
|
|
1663
1816
|
const COOKIE = '__Host-session', SECRET_COOKIE = '__Host-session-secret', SECRET_MAX = 3500, DEFAULT_SESSION_SECONDS = 7 * 24 * 3600
|
|
1664
1817
|
// Keys that still verify and decrypt during a rotation; new cookies always use the current key.
|
|
1665
1818
|
const previousKeys = () => (process.env.BRAIN_SECRET_PREVIOUS || '').split(',').map(k => k.trim()).filter(Boolean)
|
|
@@ -2202,6 +2355,8 @@ function real(p) {
|
|
|
2202
2355
|
}
|
|
2203
2356
|
if (process.argv[1] && real(process.argv[1]) === real(fileURLToPath(import.meta.url))) {
|
|
2204
2357
|
process.env.NODE_ENV = 'production'
|
|
2358
|
+
// A closed log pipe is the operator's matter: requests do not depend on it.
|
|
2359
|
+
for (const stream of [process.stdout, process.stderr]) stream.on('error', error => { if (error.code !== 'EPIPE') throw error })
|
|
2205
2360
|
const s = createServer({ dist: fileURLToPath(new URL('.', import.meta.url)) })
|
|
2206
2361
|
const fd = ENV.BRAIN_LISTEN_FD
|
|
2207
2362
|
const where = fd ? { fd: Number(fd) } : { port: +(ENV.PORT || 3000), host: ENV.HOST || '127.0.0.1' }
|