@christianirack/brainllm-runtime 0.8.5 → 0.8.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@christianirack/brainllm-runtime",
3
- "version": "0.8.5",
3
+ "version": "0.8.7",
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) {
@@ -559,7 +575,18 @@ export function nativeAuthBridge(raw, refused, serviceKey, report, ownSession) {
559
575
  }
560
576
  let req = outgoing()
561
577
  let value
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)
562
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
+ }
563
590
  if (status === 422) {
564
591
  // the backend's own field messages (never driver text): a server function that rethrows answers 422 {errors}
565
592
  let fields
@@ -574,11 +601,13 @@ export function nativeAuthBridge(raw, refused, serviceKey, report, ownSession) {
574
601
  try { answer = JSON.parse(data.toString('utf8'))?.error } catch {}
575
602
  const code = typeof answer?.code === 'string' && /^[A-Z][A-Z0-9_]{0,63}$/.test(answer.code) ? answer.code : undefined
576
603
  const fix = code && typeof answer.fix === 'string' ? answer.fix.slice(0, 512) : undefined
577
- 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 } : {}) })
578
606
  }
579
607
  if (status === 204) { value = null; return }
580
608
  if (!String(headers['content-type'] || '').startsWith('application/json')) throw new HttpError(502, 'Native resource response must be JSON')
581
609
  try { value = JSON.parse(data.toString('utf8')) } catch { throw new HttpError(502, 'Native resource response is invalid') }
610
+ if (remembers && typeof headers.etag === 'string') rememberRead(path, headers.etag, data)
582
611
  }
583
612
  try {
584
613
  // A restarting backend refuses connections for a few seconds: a read waits for it instead of failing the page.
@@ -590,20 +619,23 @@ export function nativeAuthBridge(raw, refused, serviceKey, report, ownSession) {
590
619
  if (method !== 'GET' || wait > 2000 || stopped || response.destroyed || !['ECONNREFUSED', 'ECONNRESET'].includes(e?.transport)) throw e
591
620
  req.destroy()
592
621
  await new Promise(done => setTimeout(done, wait))
593
- req = outgoing()
622
+ req = conditional(outgoing())
594
623
  }
595
624
  }
596
625
  } catch (e) {
597
626
  // 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 })
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 })
600
631
  seen.reads++
601
632
  seen.backendCode ??= e?.code
602
633
  seen.transport ??= e?.transport
634
+ if (e?.temporary) { seen.temporary = true; seen.retryAfter = Math.max(seen.retryAfter || 0, e.retryAfter || 0) || undefined }
603
635
  }
604
636
  throw e
605
637
  } finally { req.destroy() }
606
- 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 })
607
639
  return value
608
640
  }
609
641
  const names = (value, label) => {
@@ -613,6 +645,7 @@ export function nativeAuthBridge(raw, refused, serviceKey, report, ownSession) {
613
645
  return list.join(',')
614
646
  }
615
647
  return Object.freeze({
648
+ [OPERATION_CALL]: (operation, input) => request('POST', '/_brain/operations/' + operation, input),
616
649
  // the HTTP list contract (docs/backend/filters.md, sorting.md, pagination.md): the backend validates declared
617
650
  // filters and sorts and answers 422 {errors}; this only rejects shapes the query string cannot express
618
651
  list(options = {}) {
@@ -690,17 +723,32 @@ export function nativeAuthBridge(raw, refused, serviceKey, report, ownSession) {
690
723
  },
691
724
  })
692
725
  }
693
- 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) {
694
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')
695
742
  if (incoming.$nativeLoginStarted || incoming.$nativeLogout) throw new HttpError(409,'Use one authentication change per server request')
696
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')
697
744
  const origin=bridgeOrigin(incoming,rid,'Sign in through a same-origin POST server function')
698
745
  incoming.$nativeLoginStarted=true
699
746
  const bytes=Buffer.from(JSON.stringify({email:credentials.email,password:credentials.password}))
700
- 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}
701
748
  let user
702
749
  try {
703
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
704
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")
705
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')
706
754
  let payload
@@ -718,21 +766,26 @@ export function nativeAuthBridge(raw, refused, serviceKey, report, ownSession) {
718
766
  // Creates the account and leaves the session alone: `await ctx.backend.signup(v); return ctx.backend.login(v)` signs in
719
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
720
768
  // more than any signup form reveals by succeeding.
721
- async function signup(incoming,response,rid,credentials) {
769
+ async function signup(incoming,response,rid,credentials,options) {
722
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')
723
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')
724
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'}})
725
773
  const origin=bridgeOrigin(incoming,rid,'Sign up through a same-origin POST server function')
726
774
  const bytes=Buffer.from(JSON.stringify({email:credentials.email,password:credentials.password}))
727
- 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)}
728
776
  let user
729
777
  try {
730
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
731
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")
732
781
  if(status===422)throw new HttpError(422,'This email cannot be used',{errors:{email:'This email cannot be used'}})
733
782
  if(status===429)throw new HttpError(429,'Too many attempts. Wait before trying again.')
734
783
  if(status!==200&&status!==201)throw unavailable()
735
- 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()}
736
789
  },16384)
737
790
  } finally {request.destroy();bytes.fill(0)}
738
791
  return {user}
@@ -791,8 +844,13 @@ export function nativeAuthBridge(raw, refused, serviceKey, report, ownSession) {
791
844
  return bucketClient(target, serviceKey, bucket)
792
845
  },
793
846
  slugRedirect: (name, slug) => resource(incoming, response, rid, name).slugRedirect(slug),
794
- login: credentials => login(incoming, response, rid, credentials),
795
- 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),
796
854
  google: Object.freeze({
797
855
  challenge: () => googleChallenge(incoming, response, rid),
798
856
  login: values => googleLogin(incoming, response, rid, values),
@@ -1098,6 +1156,7 @@ export function createServer(o = {}) {
1098
1156
  // `<meta cache="N">` (route.cache, #235): the SSR HTML of an anonymous GET is kept N s per URL (and language) and
1099
1157
  // served again; between N and 2N s the copy is served while a fresh one renders in the background
1100
1158
  // (stale-while-revalidate). Never for requests with a session cookie or Authorization, forms or error pages.
1159
+ const FILLING = new Map()
1101
1160
  const SSRC = new Map(), SSRC_MAX = 500, SSRC_BYTES = 32 * 1024 * 1024, SSRC_ENTRY = 1024 * 1024
1102
1161
  let ssrBytes = 0
1103
1162
  async function cached(req, res, route, params, url, rid) {
@@ -1140,10 +1199,29 @@ export function createServer(o = {}) {
1140
1199
  if (age >= ms && !c.busy) (c.busy = true), fill().finally(() => (c.busy = false))
1141
1200
  return serve(c)
1142
1201
  }
1143
- c = await fill()
1144
- return c ? serve(c) : ssr(req, res, route, params, url, rid, null, 200, null, true) // a redirect or an error: as usual
1145
- }
1146
- 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
1147
1225
 
1148
1226
  async function ssr(req, res, route, params, url, rid, forms = null, status = 200, fixed = null, nocache = false) {
1149
1227
  // the route's preloads (the same Link values as _headers): on the response, where hosts turn them into early hints,
@@ -1184,11 +1262,13 @@ export function createServer(o = {}) {
1184
1262
  }
1185
1263
  // what load() thrown or returned instead of props answers: redirect, notFound() (F11), deliberate status, failure
1186
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 })
1187
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() })
1188
1268
  if ((e instanceof HttpError && e.status === 404) || (e && e.notFound === true)) return missing(req, res, e) // `notFound()` 404 / `gone()` 410
1189
1269
  // the backend or another service said "not now": a temporary 503 tells crawlers to come back, a 500 tells them it broke
1190
1270
  // a status the page chose itself (`new HttpError(429)`) stays as it is, with the same hint to come back
1191
- 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) {
1192
1272
  log('warn', 'load temporarily unavailable', { rid, path: url.pathname, status: 503, ...trace(e) })
1193
1273
  res.setHeader('retry-after', String(Math.min(Math.ceil(e.retryAfter) || 5, 3600)))
1194
1274
  res.setHeader('cache-control', 'no-store')
@@ -1211,6 +1291,7 @@ export function createServer(o = {}) {
1211
1291
  }
1212
1292
  if (props && (props.redirect || props.notFound === true)) return loadOutcome(props) // `return redirect()` / `return notFound()`
1213
1293
  if (req.$brainFailedReads && !fixed) {
1294
+ if (status === 200 && req.$brainFailedReads.temporary) return loadOutcome(comeBack(req, rid, url))
1214
1295
  softFailure(req, rid, url, status)
1215
1296
  extra = { ...extra, 'cache-control': 'no-store' } // what it rendered instead is not worth keeping
1216
1297
  }
@@ -1242,7 +1323,13 @@ export function createServer(o = {}) {
1242
1323
  // (head, CSS, layout shell) is sent, the view when load() ends. The status and headers are out by then, so a late
1243
1324
  // redirect becomes location.replace and a late failure an inline notice with the request id (both logged); load()
1244
1325
  // 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.
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.
1246
1333
  const softFailure = (req, rid, url, status) => {
1247
1334
  const { reads, backendCode, transport } = req.$brainFailedReads
1248
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' })
@@ -1257,6 +1344,7 @@ export function createServer(o = {}) {
1257
1344
  const early = await Promise.race([loadP.then(v => ({ v }), e => ({ e })), new Promise(r => (timer = setTimeout(r, STREAM_HOLD)))])
1258
1345
  clearTimeout(timer)
1259
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))
1260
1348
  const seoF = sm.seo || (await load(route.view)).default.seo
1261
1349
  if (route.i18n) await load(route.i18n)
1262
1350
  const nonce = randomBytes(16).toString('base64'), n = h => h.replace(/<script(?![^>]*\bnonce=)/g, `<script nonce="${nonce}"`)
@@ -1313,6 +1401,7 @@ export function createServer(o = {}) {
1313
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>`)
1314
1402
  // a streamed page already sent its 200 head: the region gets the not-found notice and the page is noindex
1315
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 })
1316
1405
  log('error', 'load failed (streamed)', { rid, path: url.pathname, status: 200, ...trace(e) }) // headers already out
1317
1406
  return fail()
1318
1407
  }
@@ -1320,8 +1409,10 @@ export function createServer(o = {}) {
1320
1409
  try {
1321
1410
  const { body, failed } = await (queue = queue.then(() => render(route, props, null, at), () => render(route, props, null, at)))
1322
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">' : ''
1323
1414
  const pm = params && Object.keys(params).length ? `<meta name="brain-params" content="${esc(JSON.stringify(params))}">` : ''
1324
- end(pm + body, props)
1415
+ end(stale + pm + body, props)
1325
1416
  } catch (e) {
1326
1417
  log('error', 'render failed (streamed)', { rid, path: url.pathname, status: 200, ...trace(e) })
1327
1418
  await fail()
@@ -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
+ }