@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 +5 -4
- package/package.json +1 -1
- package/server/server.mjs +156 -41
- package/templates/captcha.js +87 -0
package/brain-inspect.js
CHANGED
|
@@ -214,13 +214,14 @@ function leak(r) {
|
|
|
214
214
|
leaks.push(r)
|
|
215
215
|
push(r)
|
|
216
216
|
}
|
|
217
|
-
function b602(n, what, line) {
|
|
217
|
+
function b602(n, what, line, foreign) {
|
|
218
218
|
if (!n || flagged.has(n) || n.s >= 3) return
|
|
219
219
|
flagged.add(n)
|
|
220
220
|
const i = instOf(n), name = i ? nameOf(i) : '?'
|
|
221
221
|
const fx = `fx in ${name} (${what})${line ? ' ' + line : ''}`
|
|
222
222
|
if (i) (b602ByInst.get(i.r) || b602ByInst.set(i.r, []).get(i.r)).push(fx)
|
|
223
|
-
|
|
223
|
+
// a listener another module registered while this fx ran is not the component's: name where it really comes from
|
|
224
|
+
leak({ code: 'B0602', h: i ? i.h : null, name, file: foreign && line ? line.replace(/:\d+$/, '') : i && i.C.f, line, msg: `global ${what} inside an fx${foreign && line ? ` of ${name}, registered by ${line},` : ''} without cleanup`, fix: 'return a cleanup function from the fx: `fx(() => { …; return () => remove() })`', fx: [fx] })
|
|
224
225
|
}
|
|
225
226
|
function patchGlobalListeners() {
|
|
226
227
|
for (const t of [G.window, G.document].filter(Boolean)) {
|
|
@@ -229,8 +230,8 @@ function patchGlobalListeners() {
|
|
|
229
230
|
t.addEventListener = function (type, ...a) {
|
|
230
231
|
const c = ctx()
|
|
231
232
|
if (c && c.o === 'fx') {
|
|
232
|
-
const line = site()
|
|
233
|
-
O.st.call(G, () => !c.n.k && b602(c.n, `addEventListener('${type}')`, line), 0)
|
|
233
|
+
const { line, foreign } = site()
|
|
234
|
+
O.st.call(G, () => !c.n.k && b602(c.n, `addEventListener('${type}')`, line, foreign), 0)
|
|
234
235
|
}
|
|
235
236
|
return add.call(this, type, ...a)
|
|
236
237
|
}
|
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) {
|
|
@@ -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
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
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
|
-
|
|
559
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
583
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
779
|
-
|
|
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
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
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
|
|
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
|
|
1472
|
-
// build's sitemap
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|