@christianirack/brainllm-runtime 0.8.5 → 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/package.json +1 -1
- package/server/server.mjs +112 -21
- package/templates/captcha.js +87 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@christianirack/brainllm-runtime",
|
|
3
|
-
"version": "0.8.
|
|
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) {
|
|
@@ -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
|
-
|
|
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
|
-
|
|
599
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
795
|
-
|
|
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
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
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
|
|
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
|
+
}
|