@christianirack/brainllm-runtime 0.8.4 → 0.8.6

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-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
- leak({ code: 'B0602', h: i ? i.h : null, name, file: i && i.C.f, line, msg: `global ${what} inside an fx without cleanup`, fix: 'return a cleanup function from the fx: `fx(() => { …; return () => remove() })`', fx: [fx] })
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().line
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@christianirack/brainllm-runtime",
3
- "version": "0.8.4",
3
+ "version": "0.8.6",
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
@@ -396,7 +396,7 @@ export function nativeAuthBridge(raw, refused, serviceKey, report, ownSession) {
396
396
  let proxyBytes = 0, streams = 0
397
397
  // A file transfer lasts as long as the client needs, so streams have their own sockets and budget and never hold API slots.
398
398
  const streamAgent = new HttpAgent({ keepAlive: false, maxSockets: 32, maxTotalSockets: 32 })
399
- const REQUEST_HEADERS = ['origin','authorization','x-api-key','content-type','content-length','x-brain-rpc','accept','if-match','if-none-match','x-brain-webhook-id','x-brain-webhook-timestamp','x-brain-webhook-signature','user-agent','dnt','sec-gpc','cf-ipcountry']
399
+ const REQUEST_HEADERS = ['origin','authorization','x-api-key','content-type','content-length','x-brain-rpc','x-brain-captcha','x-brain-login-grant','accept','if-match','if-none-match','x-brain-webhook-id','x-brain-webhook-timestamp','x-brain-webhook-signature','user-agent','dnt','sec-gpc','cf-ipcountry']
400
400
  const FILE_HEADERS = ['range','if-range','if-modified-since','if-unmodified-since']
401
401
  const RESPONSE_HEADERS = ['content-type','content-disposition','allow','retry-after','www-authenticate','location','etag','last-modified','clear-site-data','cache-control']
402
402
  const FILE_RESPONSE_HEADERS = ['content-length','content-range','accept-ranges','vary','content-security-policy','x-content-type-options','cross-origin-resource-policy','referrer-policy']
@@ -531,6 +531,22 @@ export function nativeAuthBridge(raw, refused, serviceKey, report, ownSession) {
531
531
  } catch { outgoing?.destroy(); finish(true) }
532
532
  })
533
533
  }
534
+ const readCache = new Map()
535
+ let readCacheBytes = 0
536
+ const rememberRead = (path, etag, body) => {
537
+ readCacheBytes -= readCache.get(path)?.body.length || 0
538
+ readCache.delete(path)
539
+ if (body.length > 1024 * 1024) return
540
+ readCache.set(path, { etag, body })
541
+ readCacheBytes += body.length
542
+ while (readCache.size > 256 || readCacheBytes > 16 * 1024 * 1024) {
543
+ const oldest = readCache.keys().next().value
544
+ readCacheBytes -= readCache.get(oldest).body.length
545
+ readCache.delete(oldest)
546
+ }
547
+ }
548
+ // Reaches the request helper of a resource client without adding a method anyone can see on it.
549
+ const OPERATION_CALL = Symbol('operation')
534
550
  const resource = (incoming, response, rid, name, serviceKey) => {
535
551
  if (typeof name !== 'string' || !/^[a-z][a-z0-9_]{0,47}$/.test(name)) throw new HttpError(400, 'Invalid native resource name')
536
552
  const idPath = id => {
@@ -538,7 +554,7 @@ export function nativeAuthBridge(raw, refused, serviceKey, report, ownSession) {
538
554
  return '/api/' + name + '/' + id
539
555
  }
540
556
  const request = async (method, path, body, notFound) => {
541
- if (response.writableEnded || response.destroyed) throw new HttpError(409, 'The server request has ended')
557
+ if (response.writableEnded || response.destroyed) throw Object.assign(new HttpError(409, 'The server request has ended'), { clientGone: true })
542
558
  if (method !== 'GET' && incoming.method !== 'POST') throw new HttpError(405, 'Native writes require a server function called through POST')
543
559
  let bytes = Buffer.alloc(0)
544
560
  if (body !== undefined) {
@@ -546,17 +562,31 @@ export function nativeAuthBridge(raw, refused, serviceKey, report, ownSession) {
546
562
  try { bytes = Buffer.from(JSON.stringify(body)) } catch { throw new HttpError(400, 'Native resource values must be JSON serializable') }
547
563
  if (bytes.length > 65536) throw new HttpError(413, 'Native resource values exceed their byte budget')
548
564
  }
549
- const req = Readable.from(bytes.length ? [bytes] : [])
550
- req.method = method
551
- req.$brainForwardedFor = forwardedFor(incoming)
552
- // A service call acts as the key's owner, never as the visitor: it sends no session.
553
- req.headers = { ...(serviceKey ? { authorization: 'Bearer ' + serviceKey } : ownSession ? {} : { cookie: cookies(incoming, false) }), accept: 'application/json', 'content-type':'application/json', 'content-length':String(bytes.length) }
554
- if (method !== 'GET') {
555
- req.headers.origin = bridgeOrigin(incoming, rid, 'Native writes require a same-origin server request')
565
+ const outgoing = () => {
566
+ const req = Readable.from(bytes.length ? [bytes] : [])
567
+ req.method = method
568
+ req.$brainForwardedFor = forwardedFor(incoming)
569
+ // A service call acts as the key's owner, never as the visitor: it sends no session.
570
+ req.headers = { ...(serviceKey ? { authorization: 'Bearer ' + serviceKey } : ownSession ? {} : { cookie: cookies(incoming, false) }), accept: 'application/json', 'content-type':'application/json', 'content-length':String(bytes.length) }
571
+ if (method !== 'GET') {
572
+ req.headers.origin = bridgeOrigin(incoming, rid, 'Native writes require a same-origin server request')
573
+ }
574
+ return req
556
575
  }
576
+ let req = outgoing()
557
577
  let value
558
- try {
559
- await proxy(req, response, new URL(path, target), rid, (_res, status, data, headers) => {
578
+ // Revalidated reads (ETag/304): a read made without any credential remembers its body, so an unchanged answer is not
579
+ // sent or parsed again. Each answer is parsed afresh, so `load` can change what it gets. Signed-in reads stay out.
580
+ const remembers = method === 'GET' && !req.headers.authorization && !req.headers.cookie
581
+ const known = remembers ? readCache.get(path) : undefined
582
+ const conditional = r => (known ? (r.headers['if-none-match'] = known.etag, r) : r)
583
+ conditional(req)
584
+ const answered = (_res, status, data, headers) => {
585
+ if (status === 304 && known) {
586
+ readCache.delete(path); readCache.set(path, known)
587
+ value = JSON.parse(known.body)
588
+ return
589
+ }
560
590
  if (status === 422) {
561
591
  // the backend's own field messages (never driver text): a server function that rethrows answers 422 {errors}
562
592
  let fields
@@ -571,23 +601,41 @@ export function nativeAuthBridge(raw, refused, serviceKey, report, ownSession) {
571
601
  try { answer = JSON.parse(data.toString('utf8'))?.error } catch {}
572
602
  const code = typeof answer?.code === 'string' && /^[A-Z][A-Z0-9_]{0,63}$/.test(answer.code) ? answer.code : undefined
573
603
  const fix = code && typeof answer.fix === 'string' ? answer.fix.slice(0, 512) : undefined
574
- throw new HttpError(status >= 400 && status < 500 ? status : 503, NATIVE_FAILED, { code, fix })
604
+ const wait = Number(headers['retry-after'])
605
+ throw new HttpError(status >= 400 && status < 500 ? status : 503, NATIVE_FAILED, { code, fix, temporary: status === 429 || status === 503, ...(wait > 0 ? { retryAfter: wait } : {}) })
575
606
  }
576
607
  if (status === 204) { value = null; return }
577
608
  if (!String(headers['content-type'] || '').startsWith('application/json')) throw new HttpError(502, 'Native resource response must be JSON')
578
609
  try { value = JSON.parse(data.toString('utf8')) } catch { throw new HttpError(502, 'Native resource response is invalid') }
579
- }, 2 * 1024 * 1024)
610
+ if (remembers && typeof headers.etag === 'string') rememberRead(path, headers.etag, data)
611
+ }
612
+ try {
613
+ // A restarting backend refuses connections for a few seconds: a read waits for it instead of failing the page.
614
+ for (let wait = 250; ; wait *= 2) {
615
+ try {
616
+ await proxy(req, response, new URL(path, target), rid, answered, 2 * 1024 * 1024)
617
+ break
618
+ } catch (e) {
619
+ if (method !== 'GET' || wait > 2000 || stopped || response.destroyed || !['ECONNREFUSED', 'ECONNRESET'].includes(e?.transport)) throw e
620
+ req.destroy()
621
+ await new Promise(done => setTimeout(done, wait))
622
+ req = conditional(outgoing())
623
+ }
624
+ }
580
625
  } catch (e) {
581
626
  // 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 })
627
+ // "Not now" is the backend limiting or unavailable: the same request can succeed later, so a page must not stand in for it.
628
+ if (e && (e.transport || e.status === 504 || e.code === 'BRIDGE_BUSY')) e.temporary = true
629
+ if (method === 'GET' && (e?.temporary || !(e?.status >= 400 && e.status < 500))) {
630
+ const seen = incoming.$brainFailedReads || (incoming.$brainFailedReads = { reads: 0, backendCode: undefined, transport: undefined, temporary: false, retryAfter: undefined })
584
631
  seen.reads++
585
632
  seen.backendCode ??= e?.code
586
633
  seen.transport ??= e?.transport
634
+ if (e?.temporary) { seen.temporary = true; seen.retryAfter = Math.max(seen.retryAfter || 0, e.retryAfter || 0) || undefined }
587
635
  }
588
636
  throw e
589
637
  } finally { req.destroy() }
590
- if (response.destroyed || response.writableEnded) throw new HttpError(409, 'The server request has ended')
638
+ if (response.destroyed || response.writableEnded) throw Object.assign(new HttpError(409, 'The server request has ended'), { clientGone: true })
591
639
  return value
592
640
  }
593
641
  const names = (value, label) => {
@@ -597,6 +645,7 @@ export function nativeAuthBridge(raw, refused, serviceKey, report, ownSession) {
597
645
  return list.join(',')
598
646
  }
599
647
  return Object.freeze({
648
+ [OPERATION_CALL]: (operation, input) => request('POST', '/_brain/operations/' + operation, input),
600
649
  // the HTTP list contract (docs/backend/filters.md, sorting.md, pagination.md): the backend validates declared
601
650
  // filters and sorts and answers 422 {errors}; this only rejects shapes the query string cannot express
602
651
  list(options = {}) {
@@ -674,17 +723,32 @@ export function nativeAuthBridge(raw, refused, serviceKey, report, ownSession) {
674
723
  },
675
724
  })
676
725
  }
677
- async function login(incoming,response,rid,credentials) {
726
+ // The token of the captcha widget: passed by the server function, or the header the page sent with this request.
727
+ const captchaHeaders = (incoming,options) => {
728
+ const token = options?.captcha ?? incoming.headers['x-brain-captcha']
729
+ if (token === undefined) return {}
730
+ if (typeof token !== 'string' || !token || Buffer.byteLength(token) > 8192 || /[\x00-\x1f\x7f]/.test(token)) throw new HttpError(403,'Complete the captcha again',{code:'CAPTCHA_FAILED'})
731
+ return {'x-brain-captcha':token}
732
+ }
733
+ const captchaRefusal = (status,data) => {
734
+ let code
735
+ try {code=JSON.parse(data.toString('utf8'))?.error?.code}catch{}
736
+ if(status===403&&code==='CAPTCHA_FAILED')return new HttpError(403,'Complete the captcha again',{code})
737
+ if(status===503&&code==='CAPTCHA_UNAVAILABLE')return new HttpError(503,'The captcha could not be verified. Nothing was changed; retry shortly',{code})
738
+ return null
739
+ }
740
+ async function login(incoming,response,rid,credentials,options) {
678
741
  if (incoming.method !== 'POST' || response.headersSent || response.writableEnded || response.destroyed) throw new HttpError(403,'B0742: Sign in through a same-origin POST server function')
679
742
  if (incoming.$nativeLoginStarted || incoming.$nativeLogout) throw new HttpError(409,'Use one authentication change per server request')
680
743
  if (!credentials || typeof credentials !== 'object' || Array.isArray(credentials) || Object.keys(credentials).some(key=>!['email','password'].includes(key)) || typeof credentials.email !== 'string' || typeof credentials.password !== 'string' || Buffer.byteLength(credentials.email)>254 || Buffer.byteLength(credentials.password)>1024) throw new HttpError(422,'Enter a valid email and password')
681
744
  const origin=bridgeOrigin(incoming,rid,'Sign in through a same-origin POST server function')
682
745
  incoming.$nativeLoginStarted=true
683
746
  const bytes=Buffer.from(JSON.stringify({email:credentials.email,password:credentials.password}))
684
- const request=Readable.from([bytes]);request.method='POST';request.$brainForwardedFor=forwardedFor(incoming);request.headers={origin,accept:'application/json','content-type':'application/json','content-length':String(bytes.length)}
747
+ const request=Readable.from([bytes]);request.method='POST';request.$brainForwardedFor=forwardedFor(incoming);const grant=incoming.$nativeLoginGrant?.email===credentials.email?{'x-brain-login-grant':incoming.$nativeLoginGrant.grant}:captchaHeaders(incoming,options);delete incoming.$nativeLoginGrant;request.headers={origin,accept:'application/json','content-type':'application/json','content-length':String(bytes.length),...grant}
685
748
  let user
686
749
  try {
687
750
  await proxy(request,response,new URL('/_brain/auth/login',target),rid,(_res,status,data,headers)=>{
751
+ const refusal=captchaRefusal(status,data);if(refusal)throw refusal
688
752
  if(status===403)throw new HttpError(403,"B0740: the backend refused this site's origin; fix: add the site origin to backend.origins in brain.json. The backend log has the seen and allowed origins. Run brain explain B0740")
689
753
  if(status!==200)throw new HttpError(status===400||status===401||status===422?401:status===429?429:503,status===429?'Too many attempts. Wait before trying again.':status===400||status===401||status===422?'Wrong email or password':'Sign-in is temporarily unavailable')
690
754
  let payload
@@ -702,21 +766,26 @@ export function nativeAuthBridge(raw, refused, serviceKey, report, ownSession) {
702
766
  // Creates the account and leaves the session alone: `await ctx.backend.signup(v); return ctx.backend.login(v)` signs in
703
767
  // too. A refused address is the email field's error: the same 422 for an invalid one and for one already taken, and no
704
768
  // more than any signup form reveals by succeeding.
705
- async function signup(incoming,response,rid,credentials) {
769
+ async function signup(incoming,response,rid,credentials,options) {
706
770
  if (incoming.method !== 'POST' || response.headersSent || response.writableEnded || response.destroyed) throw new HttpError(403,'B0742: Sign up through a same-origin POST server function')
707
771
  if (!credentials || typeof credentials !== 'object' || Array.isArray(credentials) || Object.keys(credentials).some(key=>!['email','password'].includes(key)) || typeof credentials.email !== 'string' || typeof credentials.password !== 'string' || Buffer.byteLength(credentials.email)>254 || Buffer.byteLength(credentials.password)>1024) throw new HttpError(422,'Enter a valid email and password')
708
772
  if (Buffer.byteLength(credentials.password) < 12 || Buffer.byteLength(credentials.password) > 1024) throw new HttpError(422,'Use a password of 12 to 1024 bytes',{errors:{password:'Use a password of 12 to 1024 bytes'}})
709
773
  const origin=bridgeOrigin(incoming,rid,'Sign up through a same-origin POST server function')
710
774
  const bytes=Buffer.from(JSON.stringify({email:credentials.email,password:credentials.password}))
711
- const request=Readable.from([bytes]);request.method='POST';request.$brainForwardedFor=forwardedFor(incoming);request.headers={origin,accept:'application/json','content-type':'application/json','content-length':String(bytes.length)}
775
+ const request=Readable.from([bytes]);request.method='POST';request.$brainForwardedFor=forwardedFor(incoming);request.headers={origin,accept:'application/json','content-type':'application/json','content-length':String(bytes.length),...captchaHeaders(incoming,options)}
712
776
  let user
713
777
  try {
714
778
  await proxy(request,response,new URL('/_brain/auth/signup',target),rid,(_res,status,data)=>{
779
+ const refusal=captchaRefusal(status,data);if(refusal)throw refusal
715
780
  if(status===403)throw new HttpError(403,"B0740: the backend refused this site's origin; fix: add the site origin to backend.origins in brain.json. Run brain explain B0740")
716
781
  if(status===422)throw new HttpError(422,'This email cannot be used',{errors:{email:'This email cannot be used'}})
717
782
  if(status===429)throw new HttpError(429,'Too many attempts. Wait before trying again.')
718
783
  if(status!==200&&status!==201)throw unavailable()
719
- try {user=safeUser(JSON.parse(data.toString('utf8'))?.user)}catch{throw unavailable()}
784
+ try {
785
+ const payload=JSON.parse(data.toString('utf8'));user=safeUser(payload?.user)
786
+ // The login that follows in this same request needs no second challenge; the grant never reaches the page.
787
+ if(typeof payload?.login_grant==='string')incoming.$nativeLoginGrant={email:credentials.email,grant:payload.login_grant}
788
+ }catch{throw unavailable()}
720
789
  },16384)
721
790
  } finally {request.destroy();bytes.fill(0)}
722
791
  return {user}
@@ -775,8 +844,13 @@ export function nativeAuthBridge(raw, refused, serviceKey, report, ownSession) {
775
844
  return bucketClient(target, serviceKey, bucket)
776
845
  },
777
846
  slugRedirect: (name, slug) => resource(incoming, response, rid, name).slugRedirect(slug),
778
- login: credentials => login(incoming, response, rid, credentials),
779
- signup: credentials => signup(incoming, response, rid, credentials),
847
+ // A declared operation, as this visitor (docs/backend/transactions.md).
848
+ operation: (name, input = {}) => {
849
+ if (typeof name !== 'string' || !/^[a-z][a-z0-9_]{0,47}$/.test(name)) throw new HttpError(400, 'Invalid operation name')
850
+ return resource(incoming, response, rid, 'operations')[OPERATION_CALL](name, input)
851
+ },
852
+ login: (credentials, options) => login(incoming, response, rid, credentials, options),
853
+ signup: (credentials, options) => signup(incoming, response, rid, credentials, options),
780
854
  google: Object.freeze({
781
855
  challenge: () => googleChallenge(incoming, response, rid),
782
856
  login: values => googleLogin(incoming, response, rid, values),
@@ -1082,6 +1156,7 @@ export function createServer(o = {}) {
1082
1156
  // `<meta cache="N">` (route.cache, #235): the SSR HTML of an anonymous GET is kept N s per URL (and language) and
1083
1157
  // served again; between N and 2N s the copy is served while a fresh one renders in the background
1084
1158
  // (stale-while-revalidate). Never for requests with a session cookie or Authorization, forms or error pages.
1159
+ const FILLING = new Map()
1085
1160
  const SSRC = new Map(), SSRC_MAX = 500, SSRC_BYTES = 32 * 1024 * 1024, SSRC_ENTRY = 1024 * 1024
1086
1161
  let ssrBytes = 0
1087
1162
  async function cached(req, res, route, params, url, rid) {
@@ -1124,10 +1199,29 @@ export function createServer(o = {}) {
1124
1199
  if (age >= ms && !c.busy) (c.busy = true), fill().finally(() => (c.busy = false))
1125
1200
  return serve(c)
1126
1201
  }
1127
- c = await fill()
1128
- return c ? serve(c) : ssr(req, res, route, params, url, rid, null, 200, null, true) // a redirect or an error: as usual
1129
- }
1130
- const anonymous = req => req.method === 'GET' && !req.headers.authorization && !req.headers.cookie
1202
+ // Cold: the first request renders and the others that arrive meanwhile wait for it, so a burst runs load() once.
1203
+ // Only a shareable result is shared (a private one holds its visitor's cookie); anything else answers as usual, one by one.
1204
+ const running = FILLING.get(key)
1205
+ if (running) {
1206
+ c = await running
1207
+ if (c?.cacheable) return serve(c)
1208
+ return ssr(req, res, route, params, url, rid, null, 200, null, true)
1209
+ }
1210
+ const filling = fill().finally(() => FILLING.delete(key))
1211
+ FILLING.set(key, filling)
1212
+ c = await filling
1213
+ if (c) return serve(c)
1214
+ // the first render already asked the backend and logged why; another would only spend more of a limit it just hit
1215
+ if (req.$brainFailedReads?.temporary) return comeBackPage(req, res, route, params, url, rid)
1216
+ delete req.$brainFailedReads
1217
+ return ssr(req, res, route, params, url, rid, null, 200, null, true) // a redirect or an error: as usual
1218
+ }
1219
+ const comeBackPage = (req, res, route, params, url, rid) => {
1220
+ res.setHeader('retry-after', String(Math.min(Math.ceil(req.$brainFailedReads.retryAfter) || 5, 3600)))
1221
+ res.setHeader('cache-control', 'no-store')
1222
+ return errorPage(req, res, route, params, url, rid, 503)
1223
+ }
1224
+ const anonymous = req => (req.method === 'GET' || req.method === 'HEAD') && !req.headers.authorization && !req.headers.cookie
1131
1225
 
1132
1226
  async function ssr(req, res, route, params, url, rid, forms = null, status = 200, fixed = null, nocache = false) {
1133
1227
  // the route's preloads (the same Link values as _headers): on the response, where hosts turn them into early hints,
@@ -1168,11 +1262,13 @@ export function createServer(o = {}) {
1168
1262
  }
1169
1263
  // what load() thrown or returned instead of props answers: redirect, notFound() (F11), deliberate status, failure
1170
1264
  const loadOutcome = e => {
1265
+ // the visitor left (a closed tab, an aborted prefetch): nobody reads an answer, and it is no failure of the app
1266
+ if (e && e.clientGone && res.destroyed) return log('info', 'client left during load', { rid, path: url.pathname })
1171
1267
  if (e && e.redirect) return send(res, e.status || 303, '', { location: withBase(linkTo(route, redirect(e.redirect, e.status ?? 303).redirect)), ...ctx.session.headers() })
1172
1268
  if ((e instanceof HttpError && e.status === 404) || (e && e.notFound === true)) return missing(req, res, e) // `notFound()` 404 / `gone()` 410
1173
1269
  // the backend or another service said "not now": a temporary 503 tells crawlers to come back, a 500 tells them it broke
1174
1270
  // 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) {
1271
+ if (e && (e.status === 429 || e.status === 503) && (!(e instanceof HttpError) || e.temporary) && !res.headersSent) {
1176
1272
  log('warn', 'load temporarily unavailable', { rid, path: url.pathname, status: 503, ...trace(e) })
1177
1273
  res.setHeader('retry-after', String(Math.min(Math.ceil(e.retryAfter) || 5, 3600)))
1178
1274
  res.setHeader('cache-control', 'no-store')
@@ -1195,6 +1291,7 @@ export function createServer(o = {}) {
1195
1291
  }
1196
1292
  if (props && (props.redirect || props.notFound === true)) return loadOutcome(props) // `return redirect()` / `return notFound()`
1197
1293
  if (req.$brainFailedReads && !fixed) {
1294
+ if (status === 200 && req.$brainFailedReads.temporary) return loadOutcome(comeBack(req, rid, url))
1198
1295
  softFailure(req, rid, url, status)
1199
1296
  extra = { ...extra, 'cache-control': 'no-store' } // what it rendered instead is not worth keeping
1200
1297
  }
@@ -1226,7 +1323,13 @@ export function createServer(o = {}) {
1226
1323
  // (head, CSS, layout shell) is sent, the view when load() ends. The status and headers are out by then, so a late
1227
1324
  // redirect becomes location.replace and a late failure an inline notice with the request id (both logged); load()
1228
1325
  // 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.
1326
+ // `load` caught a read the backend refused for now (429, 503, unreachable) and rendered without its data: a 200 would put
1327
+ // that page in the index and tell a crawler to keep going, so it answers 503 with Retry-After. The log says what was behind it.
1328
+ const comeBack = (req, rid, url) => {
1329
+ softFailure(req, rid, url, 503)
1330
+ return Object.assign(new Error('A backend read failed for now'), { status: 503, retryAfter: req.$brainFailedReads.retryAfter })
1331
+ }
1332
+ // `load` caught any other failed backend read and rendered: the status it asked for stands, the log says what is behind it.
1230
1333
  const softFailure = (req, rid, url, status) => {
1231
1334
  const { reads, backendCode, transport } = req.$brainFailedReads
1232
1335
  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' })
@@ -1241,6 +1344,7 @@ export function createServer(o = {}) {
1241
1344
  const early = await Promise.race([loadP.then(v => ({ v }), e => ({ e })), new Promise(r => (timer = setTimeout(r, STREAM_HOLD)))])
1242
1345
  clearTimeout(timer)
1243
1346
  if (early && (early.e !== undefined || (early.v && (early.v.redirect || early.v.notFound === true)))) return outcome(early.e !== undefined ? early.e : early.v)
1347
+ if (early && req.$brainFailedReads?.temporary) return outcome(comeBack(req, rid, url))
1244
1348
  const seoF = sm.seo || (await load(route.view)).default.seo
1245
1349
  if (route.i18n) await load(route.i18n)
1246
1350
  const nonce = randomBytes(16).toString('base64'), n = h => h.replace(/<script(?![^>]*\bnonce=)/g, `<script nonce="${nonce}"`)
@@ -1297,6 +1401,7 @@ export function createServer(o = {}) {
1297
1401
  if (e && e.redirect) return end(`<meta name="robots" content="noindex"><script>location.replace(${jsonInHtml(withBase(linkTo(route, redirect(e.redirect, e.status ?? 303).redirect)))})</script>`)
1298
1402
  // a streamed page already sent its 200 head: the region gets the not-found notice and the page is noindex
1299
1403
  if (e && e.notFound === true) return end(`<meta name="robots" content="noindex"><p role="alert" class="brain-fallback">Page not found.</p>`, {}, bottom.replace(/<script type="module">(?:(?!<\/script>)[\s\S])*?brain-view[\s\S]*?<\/script>/g, ''))
1404
+ if (e && e.clientGone && res.destroyed) return log('info', 'client left during load', { rid, path: url.pathname })
1300
1405
  log('error', 'load failed (streamed)', { rid, path: url.pathname, status: 200, ...trace(e) }) // headers already out
1301
1406
  return fail()
1302
1407
  }
@@ -1304,8 +1409,10 @@ export function createServer(o = {}) {
1304
1409
  try {
1305
1410
  const { body, failed } = await (queue = queue.then(() => render(route, props, null, at), () => render(route, props, null, at)))
1306
1411
  for (const f of failed || []) log('error', 'component failed during SSR', { rid, path: url.pathname, comp: f.comp, where: f.w, ...trace(f.e) })
1412
+ // the 200 is already out: a page rendered without its data at least stays out of the index
1413
+ const stale = req.$brainFailedReads?.temporary ? '<meta name="robots" content="noindex">' : ''
1307
1414
  const pm = params && Object.keys(params).length ? `<meta name="brain-params" content="${esc(JSON.stringify(params))}">` : ''
1308
- end(pm + body, props)
1415
+ end(stale + pm + body, props)
1309
1416
  } catch (e) {
1310
1417
  log('error', 'render failed (streamed)', { rid, path: url.pathname, status: 200, ...trace(e) })
1311
1418
  await fail()
@@ -1352,6 +1459,7 @@ export function createServer(o = {}) {
1352
1459
  }
1353
1460
  if (route.i18n) await load(route.i18n) // the route's dictionary chunk: add(locale, {…}) before anything renders
1354
1461
  globalThis.__brainForms = forms // form() reads it while the view is built (renders are serialized)
1462
+ globalThis.__brainIds = (globalThis.__brainIds || 0) + 1 // each page numbers its instance ids from the start, as the client will
1355
1463
  globalThis.__brainLocale = route.locale || null // t(), fmt and locale() of this page (§37)
1356
1464
  globalThis.__brainAlternates = route.alternates || null // <LangSwitch> links, in the HTML before any JS
1357
1465
  globalThis.__brainRoute = at // route(): app path, params, query of this request
@@ -1468,8 +1576,8 @@ export function createServer(o = {}) {
1468
1576
  // pages, plus an optional `lastmod`, at request time. /sitemap.xml is the build's file with those URLs added (a sitemap
1469
1577
  // index gets one more child, /sitemap-dynamic.xml). A file holds at most 50 000 URLs (sitemaps.org): when the build's
1470
1578
  // URLs plus the dynamic ones pass that, /sitemap.xml becomes an index of /sitemap-static.xml (the build's file) and
1471
- // /sitemap-dynamic.xml; dynamic URLs past 50 000 are dropped with a warning. Absolute URLs take the origin of the
1472
- // build's sitemap (brain.json `site`); a build without a sitemap serves none. The list is kept 5 min (one sitemap() run
1579
+ // /sitemap-dynamic.xml; dynamic URLs past 50 000 are dropped with a warning. Absolute URLs take the site origin from
1580
+ // 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
1473
1581
  // at a time when it expires) and the response says max-age=300; a sitemap() that throws is logged and skipped.
1474
1582
  const DYN = routes.filter(r => r.sitemap && r.server)
1475
1583
  let dynList = null
@@ -1485,14 +1593,23 @@ export function createServer(o = {}) {
1485
1593
  const day = v => (v instanceof Date ? v.toISOString().slice(0, 10) : typeof v === 'string' && /^\d{4}-\d{2}-\d{2}/.test(v) ? v : null)
1486
1594
  const MAX_URLS = 50000
1487
1595
  let dynRun = null
1596
+ const dynGood = new Map()
1488
1597
  async function collect(origin) {
1489
1598
  const urls = []
1490
1599
  let dropped = 0
1491
- for (const r of DYN) {
1600
+ // Views answer at the same time, so one slow view costs one timeout; a view that fails keeps its last good list.
1601
+ const lists = await Promise.all(DYN.map(async r => {
1492
1602
  try {
1493
1603
  const sm = await load(r.server)
1494
1604
  const list = typeof sm.sitemap === 'function' ? await withTimeout(Promise.resolve().then(() => sm.sitemap({ env })), TIMEOUT) : []
1495
- for (const p of Array.isArray(list) ? list : []) {
1605
+ dynGood.set(r, Array.isArray(list) ? list : [])
1606
+ } catch (e) {
1607
+ log('error', 'sitemap() failed', { view: r.view, ...trace(e) })
1608
+ }
1609
+ return dynGood.get(r) || []
1610
+ }))
1611
+ DYN.forEach((r, n) => {
1612
+ for (const p of lists[n]) {
1496
1613
  const path = fill(r.path, p)
1497
1614
  if (!path) continue
1498
1615
  const loc = origin + withBase(path)
@@ -1500,19 +1617,17 @@ export function createServer(o = {}) {
1500
1617
  else if (urls.length < MAX_URLS) urls.push({ loc, lastmod: day(p.lastmod) })
1501
1618
  else dropped++
1502
1619
  }
1503
- } catch (e) {
1504
- log('error', 'sitemap() failed', { view: r.view, ...trace(e) })
1505
- }
1506
- }
1620
+ })
1507
1621
  if (dropped) log('warn', 'sitemap(): more than 50000 URLs, the rest are not listed', { dropped })
1622
+ 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) })
1508
1623
  dynList = { at: Date.now(), urls }
1509
1624
  return urls
1510
1625
  }
1511
1626
  async function dynamicUrls(origin) {
1512
1627
  if (dynList && Date.now() - dynList.at < 300000) return dynList.urls
1513
- // concurrent requests after expiry share one run of every sitemap()
1628
+ // concurrent requests after expiry share one run of every sitemap(); a crawler never waits while a list exists
1514
1629
  if (!dynRun) dynRun = collect(origin).finally(() => (dynRun = null))
1515
- return dynRun
1630
+ return dynList ? dynList.urls : dynRun
1516
1631
  }
1517
1632
  // §33 S01: every sitemap file holds at most 50 000 URLs and SITEMAP_BYTES bytes of UTF-8 XML; the dynamic URLs fill
1518
1633
  // /sitemap-dynamic.xml, /sitemap-dynamic-2.xml, … in sitemap() order, so the split is deterministic
@@ -1535,7 +1650,7 @@ export function createServer(o = {}) {
1535
1650
  const f = file('/sitemap.xml')
1536
1651
  if (!f) return false
1537
1652
  const text = readFileSync(f, 'utf8')
1538
- const origin = [...text.matchAll(/<loc>(https?:\/\/[^<]+)<\/loc>/g)].map(m => URL.canParse(m[1]) && new URL(m[1]).origin).find(Boolean)
1653
+ const origin = mf.site || [...text.matchAll(/<loc>(https?:\/\/[^<]+)<\/loc>/g)].map(m => URL.canParse(m[1]) && new URL(m[1]).origin).find(Boolean)
1539
1654
  if (!origin) return false
1540
1655
  const index = text.includes('<sitemapindex')
1541
1656
  const sets = pages(await dynamicUrls(origin))
@@ -0,0 +1,87 @@
1
+ // Application-owned captcha adapter for widgets with the common render/reset/remove API. The token stays in memory, is
2
+ // never logged or stored, and is good for one submit: reset after every submit, whatever it answered.
3
+ const PROVIDERS = {
4
+ turnstile: { global: 'turnstile', script: 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit' },
5
+ hcaptcha: { global: 'hcaptcha', script: 'https://js.hcaptcha.com/1/api.js?render=explicit' },
6
+ recaptcha: { global: 'grecaptcha', script: 'https://www.google.com/recaptcha/api.js?render=explicit' }
7
+ }
8
+ const LOAD_MS = 8000
9
+ const loading = new Map()
10
+ const sdk = provider => {
11
+ const ready = () => typeof globalThis[provider.global]?.render === 'function'
12
+ if (ready()) return Promise.resolve(globalThis[provider.global])
13
+ if (loading.has(provider.script)) return loading.get(provider.script)
14
+ const started = new Promise((resolve, reject) => {
15
+ const doc = globalThis.document, existing = doc.querySelector(`script[src="${provider.script}"]`)
16
+ const script = existing || doc.createElement('script')
17
+ const done = failed => {
18
+ clearTimeout(timer); clearInterval(poll); script.removeEventListener('error', fail)
19
+ loading.delete(provider.script)
20
+ if (failed) { if (!existing) script.remove(); reject(Error('The captcha could not load')) }
21
+ else resolve(globalThis[provider.global])
22
+ }
23
+ const fail = () => done(true)
24
+ // The global appears after `load`, and grecaptcha later still, so readiness is polled rather than assumed.
25
+ const poll = setInterval(() => { if (ready()) done(false) }, 50)
26
+ const timer = setTimeout(fail, LOAD_MS)
27
+ script.addEventListener('error', fail)
28
+ if (!existing) { script.src = provider.script; script.async = true; script.defer = true; doc.head.appendChild(script) }
29
+ })
30
+ loading.set(provider.script, started)
31
+ return started
32
+ }
33
+ /** A same-origin JSON POST with the captcha headers. Answers `{ status, body }`; the caller decides what a failure means. */
34
+ export async function post(path, body, headers = {}) {
35
+ const base = globalThis.document?.querySelector('meta[name=brain-base]')?.getAttribute('content') || ''
36
+ if (base && (!base.startsWith('/') || base.startsWith('//') || /[\\?#\x00-\x20]/.test(base))) throw Error('Invalid application base')
37
+ const response = await fetch(base.replace(/\/$/, '') + path, { method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json', ...headers }, body: JSON.stringify(body) })
38
+ return { status: response.status, body: await response.json().catch(() => null) }
39
+ }
40
+ export default {
41
+ post,
42
+ mount(element, initial = {}) {
43
+ let props = initial, alive = true, api = null, id = null, nonce = 0
44
+ const report = (status, token = '') => {
45
+ if (!alive) return
46
+ element.setAttribute('aria-busy', String(status === 'loading'))
47
+ if (typeof props.onState === 'function') props.onState({ status, token })
48
+ }
49
+ const start = async () => {
50
+ const mine = ++nonce
51
+ const provider = PROVIDERS[props.provider || 'turnstile']
52
+ if (!provider || !props.sitekey) return report('error')
53
+ report('loading')
54
+ try {
55
+ const loaded = await sdk(provider)
56
+ if (!alive || mine !== nonce) return
57
+ api = loaded
58
+ // No provider `ready()`: the poll in `sdk` already waited for `render`, and Turnstile's refuses a script loaded async.
59
+ id = api.render(element, {
60
+ sitekey: props.sitekey,
61
+ callback: token => report('verified', typeof token === 'string' ? token : ''),
62
+ 'expired-callback': () => report('expired'),
63
+ 'timeout-callback': () => report('expired'),
64
+ 'error-callback': () => { report('error'); return true }
65
+ })
66
+ report('ready')
67
+ } catch { if (alive && mine === nonce) report('error') }
68
+ }
69
+ const reset = () => {
70
+ if (id === null) return start()
71
+ report('ready')
72
+ try { api.reset(id) } catch { report('error') }
73
+ }
74
+ start()
75
+ return {
76
+ update(next = {}) {
77
+ props = { ...props, ...next }
78
+ if (next.action === 'reset') reset()
79
+ },
80
+ destroy() {
81
+ alive = false; nonce++
82
+ try { if (id !== null && typeof api?.remove === 'function') api.remove(id) } catch {}
83
+ element.replaceChildren()
84
+ }
85
+ }
86
+ }
87
+ }