@christianirack/brainllm-runtime 0.1.0 → 0.4.0
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/README.md +1 -1
- package/THIRD_PARTY_NOTICES.txt +211 -0
- package/brain-ai.js +129 -0
- package/brain-budget.js +142 -0
- package/brain-data.js +15 -3
- package/brain-dev.js +191 -28
- package/brain-err.js +168 -43
- package/brain-form.js +77 -27
- package/brain-hash.js +17 -0
- package/brain-inspect.js +6 -4
- package/brain-interop.js +50 -0
- package/brain-mcp.js +32 -17
- package/brain-overlay.js +12 -0
- package/brain-preferences.js +64 -0
- package/brain-prefetch-shared.js +36 -0
- package/brain-prefetch.js +19 -0
- package/brain-router.js +229 -13
- package/brain-rpc.js +3 -0
- package/brain-schema.js +105 -30
- package/brain-sqlite.js +122 -0
- package/brain-storage.js +143 -0
- package/brain-threads.js +49 -0
- package/brain-time.js +77 -0
- package/brain-url.js +125 -0
- package/brain-worker.js +105 -13
- package/brain.js +6 -5
- package/package.json +46 -2
- package/server/server.mjs +615 -52
- package/server/templates/gcs-adapter.mjs +99 -0
- package/templates/google-signin.js +102 -0
- package/ui/button.brain +1 -1
- package/ui/checkbox.brain +2 -1
- package/ui/dialog.brain +5 -3
- package/ui/error-state.brain +1 -1
- package/ui/input.brain +4 -3
- package/ui/menu.brain +4 -1
- package/ui/select.brain +2 -2
- package/ui/toast.brain +4 -1
package/server/server.mjs
CHANGED
|
@@ -17,12 +17,13 @@
|
|
|
17
17
|
// `build` is the build signature (§35.7): sent as X-Brain-Build on every HTML response and in /_health.
|
|
18
18
|
// A page template (`page`) is the route's final HTML with `<!--brain:ssr-->` inside #brain-view and
|
|
19
19
|
// `<!--brain:props-->` inside <script type="application/json" id="brain-props">.
|
|
20
|
-
import { createServer as http } from 'node:http'
|
|
20
|
+
import { createServer as http, STATUS_CODES, request as httpRequest, Agent as HttpAgent } from 'node:http'
|
|
21
21
|
import net from 'node:net'
|
|
22
|
+
import { Readable } from 'node:stream'
|
|
22
23
|
import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'
|
|
23
|
-
import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs'
|
|
24
|
+
import { existsSync, readFileSync, realpathSync, statSync, lstatSync } from 'node:fs'
|
|
24
25
|
import * as Module from 'node:module'
|
|
25
|
-
import { basename, extname, join, normalize, resolve, sep } from 'node:path'
|
|
26
|
+
import { basename, dirname, extname, join, normalize, resolve, sep } from 'node:path'
|
|
26
27
|
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
27
28
|
|
|
28
29
|
const MIME = {
|
|
@@ -43,6 +44,8 @@ const SECURITY = {
|
|
|
43
44
|
'x-frame-options': 'DENY',
|
|
44
45
|
'permissions-policy': 'camera=(), microphone=(), geolocation=(), payment=(), usb=()',
|
|
45
46
|
'cross-origin-opener-policy': 'same-origin',
|
|
47
|
+
// what serves the app and where it lives, for crawlers and LLMs that read headers (no version: that stays in the HTML)
|
|
48
|
+
'x-powered-by': 'BRAINLLM (https://chrisifm.github.io/BRAINLLM/)',
|
|
46
49
|
}
|
|
47
50
|
// The build's policy (manifest `csp`, without hashes: the same one static hosting uses) plus this response's nonce in
|
|
48
51
|
// script-src; without it, a default that lets api() reach HTTPS APIs of other origins.
|
|
@@ -70,21 +73,351 @@ export class HttpError extends Error {
|
|
|
70
73
|
Object.assign(this, extra)
|
|
71
74
|
}
|
|
72
75
|
}
|
|
73
|
-
//
|
|
76
|
+
// Plain deliberate 4xx signals are portable from server-only application modules; exception messages stay private.
|
|
77
|
+
function intendedStatus(error) {
|
|
78
|
+
if (!error || typeof error !== 'object') return null
|
|
79
|
+
const descriptor = Object.getOwnPropertyDescriptor(error, 'status')
|
|
80
|
+
const status = descriptor && descriptor.value
|
|
81
|
+
const plain = Object.getPrototypeOf(error) === Object.prototype || Object.getPrototypeOf(error) === null
|
|
82
|
+
return Number.isInteger(status) && STATUS_CODES[status] && (error instanceof HttpError && status >= 400 && status <= 599 || plain && status >= 400 && status < 500) ? status : null
|
|
83
|
+
}
|
|
84
|
+
const REDIRECT_CODES = new Set([301, 302, 303, 307, 308])
|
|
85
|
+
function safeLocation(value) {
|
|
86
|
+
if (typeof value !== 'string' || value.length > 8192 || !value.startsWith('/') || value.startsWith('//') || /[\\\x00-\x20\x7f]/.test(value) || /^\/%2f/i.test(value) || /%(?:0[0-9a-f]|1[0-9a-f]|5c|7f)/i.test(value) || /%20/i.test(value.split(/[?#]/,1)[0])) throw new HttpError(500, 'Invalid same-site redirect location')
|
|
87
|
+
return value
|
|
88
|
+
}
|
|
89
|
+
// `throw redirect('/login')` (or return it) from load/guards; same-site paths only.
|
|
74
90
|
export const redirect = (url, status = 303) => {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
91
|
+
if (!REDIRECT_CODES.has(status)) throw new HttpError(500, 'Invalid redirect status')
|
|
92
|
+
return { redirect: safeLocation(url), status }
|
|
93
|
+
}
|
|
94
|
+
function mediaQuality(header, kind) {
|
|
95
|
+
let specificity = -1, quality = 0
|
|
96
|
+
for (const part of String(header).slice(0, 8192).split(',').slice(0, 64)) {
|
|
97
|
+
const [raw, ...params] = part.trim().split(';'), name = raw.toLowerCase()
|
|
98
|
+
const rank = name === kind ? 2 : name === kind.split('/')[0] + '/*' ? 1 : name === '*/*' ? 0 : -1
|
|
99
|
+
if (rank < 0) continue
|
|
100
|
+
let q = 1
|
|
101
|
+
for (const param of params) {
|
|
102
|
+
const [key, value] = param.trim().split('=')
|
|
103
|
+
if (key.toLowerCase() === 'q') { q = Number(value); if (!value || !Number.isFinite(q) || q < 0 || q > 1) q = 0 }
|
|
104
|
+
}
|
|
105
|
+
if (rank > specificity || rank === specificity && q > quality) { specificity = rank; quality = q }
|
|
106
|
+
}
|
|
107
|
+
return quality
|
|
108
|
+
}
|
|
109
|
+
function wantsHtml(req) {
|
|
110
|
+
const path = req.$brainPath || String(req.url || '/').split('?')[0]
|
|
111
|
+
if (/^\/(?:api|_brain|_r)(?:\/|$)/.test(path)) return false
|
|
112
|
+
const accept = req.headers.accept
|
|
113
|
+
if (!accept) return true
|
|
114
|
+
const quality = mediaQuality(accept, 'text/html')
|
|
115
|
+
return quality > 0 && quality >= mediaQuality(accept, 'application/json')
|
|
116
|
+
}
|
|
117
|
+
function defaultError(status, rid, home = '/') {
|
|
118
|
+
const title = STATUS_CODES[status] || 'Request failed'
|
|
119
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex,nofollow"><title>${status} — ${esc(title)}</title></head><body><main><h1>${status} — ${esc(title)}</h1><p>The request could not be completed. Try again or contact support with this reference.</p><p>Reference: <code>${esc(rid)}</code></p><a href="${esc(home)}">Return home</a></main></body></html>`
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Coding weights are transport preferences, not substring matches (br;q=0 explicitly forbids Brotli).
|
|
123
|
+
export function acceptedEncodings(header = '') {
|
|
124
|
+
const values = new Map()
|
|
125
|
+
for (const part of String(header).split(',')) {
|
|
126
|
+
const [name, ...params] = part.trim().split(';')
|
|
127
|
+
let q = 1
|
|
128
|
+
for (const p of params) {
|
|
129
|
+
const [k,v] = p.trim().split('=')
|
|
130
|
+
if (k && k.trim().toLowerCase() === 'q') { q = v && v.trim() ? Number(v) : NaN; if (!Number.isFinite(q) || q < 0 || q > 1) q = 0 }
|
|
131
|
+
}
|
|
132
|
+
values.set(name.toLowerCase(),q)
|
|
133
|
+
}
|
|
134
|
+
return ['br','gzip'].map(name => [name,values.get(name) ?? values.get('*') ?? 0]).filter(([,q]) => q > 0).sort((a,b) => b[1] - a[1]).map(([name]) => name)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function googleSignInPage(page) {
|
|
138
|
+
let head = false, templates = 0
|
|
139
|
+
const tags = /<!--[\s\S]*?-->|<(script|style|title|noscript)\b(?:"[^"]*"|'[^']*'|[^'">])*?>[\s\S]*?<\/\1\s*>|<([^'">]|"[^"]*"|'[^']*')*>/gi
|
|
140
|
+
for (const match of page.matchAll(tags)) {
|
|
141
|
+
const tag = match[0]
|
|
142
|
+
if (tag.startsWith('<!--') || match[1]) continue
|
|
143
|
+
const name = /^<\/?([^\s/>]+)/.exec(tag)?.[1].toLowerCase(), closing = tag.startsWith('</')
|
|
144
|
+
if (name === 'head' && !templates) { if (closing) return false; head = true; continue }
|
|
145
|
+
if (name === 'body' && !templates) return false
|
|
146
|
+
if (name === 'template') { templates = Math.max(0, templates + (closing ? -1 : 1)); continue }
|
|
147
|
+
if (!closing && ['script', 'style', 'title', 'noscript'].includes(name)) return false
|
|
148
|
+
if (!head || templates || closing || name !== 'meta') continue
|
|
149
|
+
const attrs = new Map()
|
|
150
|
+
for (const item of tag.slice(5, -1).matchAll(/([^\s=/]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g)) {
|
|
151
|
+
const key = item[1].toLowerCase(); if (!attrs.has(key)) attrs.set(key, item[2] ?? item[3] ?? item[4] ?? '')
|
|
152
|
+
}
|
|
153
|
+
if (attrs.get('name')?.toLowerCase() === 'brain-google-signin' && attrs.get('content') === '1') return true
|
|
154
|
+
}
|
|
155
|
+
return false
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// One configured loopback authority. No user-controlled target, proxy, redirect, auth cache or pending queue.
|
|
159
|
+
export function nativeAuthBridge(raw) {
|
|
160
|
+
if (!raw) return null
|
|
161
|
+
if (typeof raw !== 'string' || raw.length > 2048) throw Error('BRAIN_BACKEND_URL must be an explicit loopback HTTP origin')
|
|
162
|
+
let target
|
|
163
|
+
try { target = new URL(raw) } catch { throw Error('BRAIN_BACKEND_URL must be an explicit loopback HTTP origin') }
|
|
164
|
+
if (target.protocol !== 'http:' || !['127.0.0.1', '[::1]', 'localhost'].includes(target.hostname) || target.username || target.password || target.pathname !== '/' || target.search || target.hash) throw Error('BRAIN_BACKEND_URL must be an explicit loopback HTTP origin')
|
|
165
|
+
if (target.hostname === 'localhost') target.hostname = '127.0.0.1'
|
|
166
|
+
const names = new Set(['__Host-brain', '__Host-brain-access', '__Host-brain-refresh'])
|
|
167
|
+
const agent = new HttpAgent({ keepAlive: true, maxSockets: 8, maxTotalSockets: 8, maxFreeSockets: 2, timeout: 30000 })
|
|
168
|
+
const owned = new Set()
|
|
169
|
+
let active = 0, stopped = false
|
|
170
|
+
const cookies = (req, includeRefresh) => {
|
|
171
|
+
const selected = new Map(), header = req.headers.cookie || ''
|
|
172
|
+
if (header.length > 16384) throw new HttpError(431, 'Authentication cookies exceed the request limit')
|
|
173
|
+
for (const part of header.split(';')) {
|
|
174
|
+
const at = part.indexOf('='); if (at < 0) continue
|
|
175
|
+
const name = part.slice(0, at).trim(), value = part.slice(at + 1).trim()
|
|
176
|
+
if (!names.has(name) || name === '__Host-brain-refresh' && !includeRefresh) continue
|
|
177
|
+
if (selected.has(name) || value.length > 8192 || !/^[a-zA-Z0-9._-]*$/.test(value)) throw new HttpError(400, 'Invalid authentication cookies')
|
|
178
|
+
selected.set(name, value)
|
|
179
|
+
}
|
|
180
|
+
return [...selected].map(([name, value]) => name + '=' + value).join('; ')
|
|
181
|
+
}
|
|
182
|
+
const safeUser = user => {
|
|
183
|
+
if (!user || typeof user.id !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(user.id) || typeof user.email !== 'string' || user.email.length > 254 || typeof user.role !== 'string' || !/^[a-z][a-z0-9_]{0,47}$/.test(user.role) || ['public','owner'].includes(user.role) || typeof user.verified !== 'boolean') throw new HttpError(503,'Native authentication response is unavailable')
|
|
184
|
+
return Object.freeze({id:user.id,email:user.email,role:user.role,verified:user.verified})
|
|
185
|
+
}
|
|
186
|
+
const unavailable = () => new HttpError(503, 'Native authentication is temporarily unavailable')
|
|
187
|
+
const call = (incoming, response, path, method, rid, origin) => {
|
|
188
|
+
if (stopped || active >= 8) return Promise.reject(unavailable())
|
|
189
|
+
const cookie = cookies(incoming, method === 'POST')
|
|
190
|
+
active++
|
|
191
|
+
return new Promise((resolve, reject) => {
|
|
192
|
+
let outgoing, timer, finished = false
|
|
193
|
+
const finish = (error, value) => {
|
|
194
|
+
if (finished) return
|
|
195
|
+
finished = true; active--; clearTimeout(timer)
|
|
196
|
+
incoming.removeListener('aborted', cancel); response.removeListener('close', disconnected)
|
|
197
|
+
if (outgoing) owned.delete(outgoing)
|
|
198
|
+
error ? reject(unavailable()) : resolve(value)
|
|
199
|
+
}
|
|
200
|
+
const cancel = () => outgoing?.destroy(unavailable())
|
|
201
|
+
const disconnected = () => { if (!response.writableEnded) cancel() }
|
|
202
|
+
try {
|
|
203
|
+
outgoing = httpRequest(new URL(path, target), { method, agent, headers: { accept: 'application/json', ...(cookie ? { cookie } : {}), 'x-request-id': rid, ...(origin ? { origin } : {}), ...(method === 'POST' ? { 'content-length': '0' } : {}) } }, backend => {
|
|
204
|
+
let bytes = 0; const chunks = []
|
|
205
|
+
if (backend.headers['content-encoding'] || Number(backend.headers['content-length']) > 16384) { backend.destroy(); return finish(true) }
|
|
206
|
+
backend.on('data', chunk => { bytes += chunk.length; if (bytes > 16384) { backend.destroy(); finish(true) } else chunks.push(chunk) })
|
|
207
|
+
backend.on('error', () => finish(true))
|
|
208
|
+
backend.on('end', () => {
|
|
209
|
+
if (backend.statusCode === 401 && method === 'GET') return finish(false, { user: null })
|
|
210
|
+
if (backend.statusCode !== 200) return finish(true)
|
|
211
|
+
let payload
|
|
212
|
+
try { payload = JSON.parse(Buffer.concat(chunks).toString('utf8')) } catch { return finish(true) }
|
|
213
|
+
if (method === 'GET') {
|
|
214
|
+
try { return finish(false, {user:safeUser(payload?.user)}) } catch { return finish(true) }
|
|
215
|
+
}
|
|
216
|
+
if (payload?.ok !== true) return finish(true)
|
|
217
|
+
const cleared = []
|
|
218
|
+
for (const value of backend.headers['set-cookie'] || []) {
|
|
219
|
+
if (value.length > 8192 || /[\r\n]/.test(value)) return finish(true)
|
|
220
|
+
const name = value.split('=', 1)[0]
|
|
221
|
+
if (names.has(name) || name === '__Host-brain-google') cleared.push(value)
|
|
222
|
+
}
|
|
223
|
+
finish(false, { 'set-cookie': cleared, 'clear-site-data': '"storage"' })
|
|
224
|
+
})
|
|
225
|
+
})
|
|
226
|
+
owned.add(outgoing); incoming.once('aborted', cancel); response.once('close', disconnected)
|
|
227
|
+
timer = setTimeout(() => outgoing.destroy(unavailable()), 2000)
|
|
228
|
+
outgoing.once('error', () => finish(true)); outgoing.end()
|
|
229
|
+
} catch { outgoing?.destroy(); finish(true) }
|
|
230
|
+
})
|
|
231
|
+
}
|
|
232
|
+
let proxyBytes = 0
|
|
233
|
+
function proxy(req, res, url, rid, reply, responseLimit = 20 * 1024 * 1024) {
|
|
234
|
+
if (stopped || active >= 8) throw new HttpError(503, 'Backend request capacity is unavailable')
|
|
235
|
+
if (!['GET','HEAD','POST','PUT','PATCH','DELETE','OPTIONS'].includes(req.method)) throw new HttpError(405, 'Method not allowed')
|
|
236
|
+
const limit = url.pathname === '/_brain/uploads' && req.method === 'POST' ? 5 * 1024 * 1024 + 65536 : 65536
|
|
237
|
+
if (Number(req.headers['content-length'] || 0) > limit) throw new HttpError(413, 'Request body is too large')
|
|
238
|
+
active++
|
|
239
|
+
return new Promise((resolve, reject) => {
|
|
240
|
+
let outgoing, timer, done = false, bytes = 0, requestBytes = 0, failure
|
|
241
|
+
const chunks = []
|
|
242
|
+
const headers = { 'x-request-id':rid }
|
|
243
|
+
for (const key of ['origin','authorization','content-type','content-length','x-brain-rpc','accept','x-brain-webhook-id','x-brain-webhook-timestamp','x-brain-webhook-signature']) {
|
|
244
|
+
const value = req.headers[key]
|
|
245
|
+
if (typeof value === 'string' && value.length <= 8192) headers[key] = value
|
|
246
|
+
}
|
|
247
|
+
const selected = []
|
|
248
|
+
for (const part of String(req.headers.cookie || '').split(';')) {
|
|
249
|
+
const name = part.trim().split('=')[0]
|
|
250
|
+
if (['__Host-brain','__Host-brain-access','__Host-brain-refresh','__Host-brain-google'].includes(name)) selected.push(part.trim())
|
|
251
|
+
}
|
|
252
|
+
if (selected.length) headers.cookie = selected.join('; ')
|
|
253
|
+
const finish = (error, backend) => {
|
|
254
|
+
if (done) return
|
|
255
|
+
done = true; clearTimeout(timer); active--; proxyBytes -= bytes; owned.delete(outgoing)
|
|
256
|
+
req.removeListener('aborted',cancel); req.removeListener('data',data); req.removeListener('end',end)
|
|
257
|
+
res.removeListener('close',cancel)
|
|
258
|
+
if (error) { if (!res.destroyed) reject(failure || new HttpError(503,'Backend request unavailable')); else resolve(); return }
|
|
259
|
+
const safe = { 'cache-control':'no-store' }
|
|
260
|
+
for (const key of ['content-type','content-disposition','allow','retry-after','www-authenticate','location','etag','last-modified','clear-site-data']) {
|
|
261
|
+
const value = backend.headers[key]
|
|
262
|
+
if (typeof value === 'string' && value.length <= 8192) safe[key] = value
|
|
263
|
+
}
|
|
264
|
+
const cookies = (backend.headers['set-cookie'] || []).filter(value => /^(?:__Host-brain(?:-access|-refresh|-google)?)=/.test(value) && value.length <= 8192)
|
|
265
|
+
if (cookies.length) safe['set-cookie'] = cookies.slice(0,8)
|
|
266
|
+
try { reply(res,backend.statusCode || 502,req.method === 'HEAD' ? '' : Buffer.concat(chunks,bytes),safe); resolve() } catch (e) { reject(e) }
|
|
267
|
+
}
|
|
268
|
+
const cancel = () => { outgoing?.destroy(); finish(true) }
|
|
269
|
+
const data = chunk => {
|
|
270
|
+
requestBytes += chunk.length
|
|
271
|
+
if (requestBytes > limit) { failure = new HttpError(413,'Request body is too large'); outgoing.destroy(); finish(true); return }
|
|
272
|
+
if (!outgoing.write(chunk)) { req.pause(); outgoing.once('drain',()=>req.resume()) }
|
|
273
|
+
}
|
|
274
|
+
const end = () => outgoing.end()
|
|
275
|
+
try {
|
|
276
|
+
outgoing = httpRequest(new URL(url.pathname + url.search,target),{agent,method:req.method,headers},backend => {
|
|
277
|
+
backend.on('data',chunk => {
|
|
278
|
+
bytes += chunk.length; proxyBytes += chunk.length
|
|
279
|
+
if (bytes > responseLimit || proxyBytes > 64 * 1024 * 1024) { failure = new HttpError(502,'Backend response exceeds its byte budget'); outgoing.destroy(); finish(true); return }
|
|
280
|
+
chunks.push(chunk)
|
|
281
|
+
})
|
|
282
|
+
backend.once('end',()=>finish(false,backend)); backend.once('error',()=>finish(true)); backend.once('aborted',()=>finish(true))
|
|
283
|
+
})
|
|
284
|
+
owned.add(outgoing)
|
|
285
|
+
timer = setTimeout(()=>{failure = new HttpError(504,'Backend request timed out');outgoing.destroy();finish(true)},url.pathname === '/_brain/uploads' || url.pathname.startsWith('/_brain/uploads/') ? 35000 : 10000)
|
|
286
|
+
outgoing.once('error',()=>finish(true)); req.once('aborted',cancel); res.once('close',cancel)
|
|
287
|
+
req.on('data',data); req.once('end',end)
|
|
288
|
+
if (req.readableEnded) end()
|
|
289
|
+
} catch { outgoing?.destroy(); finish(true) }
|
|
290
|
+
})
|
|
291
|
+
}
|
|
292
|
+
const resource = (incoming, response, rid, name) => {
|
|
293
|
+
if (typeof name !== 'string' || !/^[a-z][a-z0-9_]{0,47}$/.test(name)) throw new HttpError(400, 'Invalid native resource name')
|
|
294
|
+
const idPath = id => {
|
|
295
|
+
if (typeof id !== 'string' || !/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(id)) throw new HttpError(400, 'Invalid native record identifier')
|
|
296
|
+
return '/api/' + name + '/' + id
|
|
297
|
+
}
|
|
298
|
+
const request = async (method, path, body) => {
|
|
299
|
+
if (response.writableEnded || response.destroyed) throw new HttpError(409, 'The server request has ended')
|
|
300
|
+
if (method !== 'GET' && incoming.method !== 'POST') throw new HttpError(405, 'Native writes require a server function called through POST')
|
|
301
|
+
let bytes = Buffer.alloc(0)
|
|
302
|
+
if (body !== undefined) {
|
|
303
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) throw new HttpError(400, 'Native resource values must be an object')
|
|
304
|
+
try { bytes = Buffer.from(JSON.stringify(body)) } catch { throw new HttpError(400, 'Native resource values must be JSON serializable') }
|
|
305
|
+
if (bytes.length > 65536) throw new HttpError(413, 'Native resource values exceed their byte budget')
|
|
306
|
+
}
|
|
307
|
+
const req = Readable.from(bytes.length ? [bytes] : [])
|
|
308
|
+
req.method = method
|
|
309
|
+
req.headers = { cookie: cookies(incoming, false), accept: 'application/json', 'content-type':'application/json', 'content-length':String(bytes.length) }
|
|
310
|
+
if (method !== 'GET') {
|
|
311
|
+
let origin
|
|
312
|
+
try { origin = new URL(incoming.headers.origin); if (origin.origin !== incoming.headers.origin || origin.host !== (incoming.headers['x-forwarded-host'] || incoming.headers.host)) throw Error() } catch { throw new HttpError(403, 'Native writes require a same-origin server request') }
|
|
313
|
+
req.headers.origin = origin.origin
|
|
314
|
+
}
|
|
315
|
+
let value
|
|
316
|
+
try {
|
|
317
|
+
await proxy(req, response, new URL(path, target), rid, (_res, status, data, headers) => {
|
|
318
|
+
if (status < 200 || status >= 300) throw new HttpError(status >= 400 && status < 500 ? status : 503, 'Native resource request failed')
|
|
319
|
+
if (status === 204) { value = null; return }
|
|
320
|
+
if (!String(headers['content-type'] || '').startsWith('application/json')) throw new HttpError(502, 'Native resource response must be JSON')
|
|
321
|
+
try { value = JSON.parse(data.toString('utf8')) } catch { throw new HttpError(502, 'Native resource response is invalid') }
|
|
322
|
+
}, 2 * 1024 * 1024)
|
|
323
|
+
} finally { req.destroy() }
|
|
324
|
+
if (response.destroyed || response.writableEnded) throw new HttpError(409, 'The server request has ended')
|
|
325
|
+
return value
|
|
326
|
+
}
|
|
327
|
+
return Object.freeze({
|
|
328
|
+
list(options = {}) {
|
|
329
|
+
if (!options || typeof options !== 'object' || Array.isArray(options) || Object.keys(options).some(key => !['search','limit','offset'].includes(key))) throw new HttpError(400, 'Invalid native list options')
|
|
330
|
+
const {search = '', limit = 20, offset = 0} = options
|
|
331
|
+
if (typeof search !== 'string' || Buffer.byteLength(search) > 256 || !Number.isInteger(limit) || limit < 1 || limit > 100 || !Number.isInteger(offset) || offset < 0 || offset > 10000) throw new HttpError(400, 'Invalid native list pagination or search')
|
|
332
|
+
return request('GET', '/api/' + name + '?' + new URLSearchParams({search,limit:String(limit),offset:String(offset)}))
|
|
333
|
+
},
|
|
334
|
+
get: id => request('GET', idPath(id)),
|
|
335
|
+
create: values => request('POST', '/api/' + name, values),
|
|
336
|
+
update: (id, values) => request('PUT', idPath(id), values),
|
|
337
|
+
remove: id => request('DELETE', idPath(id)),
|
|
338
|
+
})
|
|
339
|
+
}
|
|
340
|
+
async function login(incoming,response,rid,credentials) {
|
|
341
|
+
if (incoming.method !== 'POST' || response.headersSent || response.writableEnded || response.destroyed) throw new HttpError(403,'Sign in through a same-origin POST server function')
|
|
342
|
+
if (incoming.$nativeLoginStarted || incoming.$nativeLogout) throw new HttpError(409,'Use one authentication change per server request')
|
|
343
|
+
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')
|
|
344
|
+
let origin
|
|
345
|
+
try { origin=new URL(incoming.headers.origin);if(origin.origin!==incoming.headers.origin || origin.host!==(incoming.headers['x-forwarded-host']||incoming.headers.host))throw Error() }catch{throw new HttpError(403,'Sign in through a same-origin POST server function')}
|
|
346
|
+
incoming.$nativeLoginStarted=true
|
|
347
|
+
const bytes=Buffer.from(JSON.stringify({email:credentials.email,password:credentials.password}))
|
|
348
|
+
const request=Readable.from([bytes]);request.method='POST';request.headers={origin:origin.origin,accept:'application/json','content-type':'application/json','content-length':String(bytes.length)}
|
|
349
|
+
let user
|
|
350
|
+
try {
|
|
351
|
+
await proxy(request,response,new URL('/_brain/auth/login',target),rid,(_res,status,data,headers)=>{
|
|
352
|
+
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<500?'Wrong email or password':'Sign-in is temporarily unavailable')
|
|
353
|
+
let payload
|
|
354
|
+
try {payload=JSON.parse(data.toString('utf8'))}catch{throw unavailable()}
|
|
355
|
+
user=safeUser(payload?.user)
|
|
356
|
+
const selected=headers['set-cookie']||[]
|
|
357
|
+
if(new Set(selected.map(value=>value.split('=',1)[0])).size!==selected.length)throw unavailable()
|
|
358
|
+
if(!selected.length||selected.length>3||selected.some(value=>!/^(?:__Host-brain|__Host-brain-access|__Host-brain-refresh)=/.test(value)||!/(?:^|;)\s*HttpOnly(?:;|$)/i.test(value)||!/(?:^|;)\s*Secure(?:;|$)/i.test(value)||!/(?:^|;)\s*Path=\/(?:;|$)/i.test(value)||!/(?:^|;)\s*SameSite=Lax(?:;|$)/i.test(value)))throw unavailable()
|
|
359
|
+
if(response.writableEnded||response.destroyed)throw unavailable()
|
|
360
|
+
incoming.$nativeLoginHeaders={'set-cookie':selected,'cache-control':'no-store'}
|
|
361
|
+
},16384)
|
|
362
|
+
} finally {request.destroy();bytes.fill(0)}
|
|
363
|
+
return {user}
|
|
364
|
+
}
|
|
365
|
+
return {
|
|
366
|
+
proxy,
|
|
367
|
+
resource,
|
|
368
|
+
login,
|
|
369
|
+
async prepare(req, res, rid) {
|
|
370
|
+
if (!cookies(req, false)) { req.$nativeUser = null; req.$nativeAuth ||= Promise.resolve(); return }
|
|
371
|
+
if (!req.$nativeAuth) req.$nativeAuth = call(req, res, '/_brain/auth/me', 'GET', rid).then(result => { req.$nativeUser = result.user })
|
|
372
|
+
await req.$nativeAuth
|
|
373
|
+
},
|
|
374
|
+
session(req, res, rid) {
|
|
375
|
+
return {
|
|
376
|
+
user: req.$nativeUser,
|
|
377
|
+
set() { throw new HttpError(409, 'Use the native authentication API to establish a session') },
|
|
378
|
+
clear() {
|
|
379
|
+
if (req.$nativeLoginStarted) throw new HttpError(409,'Use a separate server request to sign out after signing in')
|
|
380
|
+
if (req.method !== 'POST' || res.headersSent) throw new HttpError(403, 'Sign out through a same-origin POST request')
|
|
381
|
+
let origin
|
|
382
|
+
try { origin = new URL(req.headers.origin); if (origin.origin !== req.headers.origin || origin.host !== (req.headers['x-forwarded-host'] || req.headers.host)) throw Error() } catch { throw new HttpError(403, 'Sign out through a same-origin POST request') }
|
|
383
|
+
if (!req.$nativeLogout) {
|
|
384
|
+
req.$nativeLogout = call(req, res, '/_brain/auth/logout', 'POST', rid, origin.origin).then(headers => { req.$nativeLogoutDone = true; req.$nativeLogoutHeaders = headers }, error => { req.$nativeLogoutDone = true; req.$nativeLogoutError = true; throw error })
|
|
385
|
+
req.$nativeLogout.catch(() => {})
|
|
386
|
+
}
|
|
387
|
+
return req.$nativeLogout
|
|
388
|
+
},
|
|
389
|
+
headers: () => req.$nativeLoginHeaders || {},
|
|
390
|
+
}
|
|
391
|
+
},
|
|
392
|
+
close() { stopped = true; for (const request of owned) request.destroy(); agent.destroy() },
|
|
393
|
+
}
|
|
78
394
|
}
|
|
79
395
|
|
|
80
396
|
export function createServer(o = {}) {
|
|
81
397
|
const dist = resolve(o.dist || process.cwd())
|
|
82
398
|
const env = Object.freeze({ ...(o.env || ENV) }) // ctx.env: what the supervisor passed, read-only
|
|
399
|
+
const native = nativeAuthBridge(o.backendURL ?? env.BRAIN_BACKEND_URL)
|
|
83
400
|
const TIMEOUT = +(o.timeout ?? env.BRAIN_TIMEOUT_MS ?? 10000)
|
|
84
401
|
const LIMIT = +(o.rateLimit ?? env.BRAIN_RPC_LIMIT ?? 60) // per IP + function, per minute
|
|
85
402
|
const MAX_BODY = 64 * 1024
|
|
86
403
|
const secret = o.secret ?? env.BRAIN_SECRET ?? ''
|
|
87
|
-
const
|
|
404
|
+
const sink = o.log || ((level, msg, extra) => console[level === 'error' ? 'error' : 'log'](json({ t: new Date().toISOString(), level, msg, ...extra })))
|
|
405
|
+
const log = (level, msg, extra = {}) => {
|
|
406
|
+
const safe = { ...extra }
|
|
407
|
+
// Error messages may contain credentials or request data. Retain only bounded V8 source frames.
|
|
408
|
+
if (typeof safe.stack === 'string') {
|
|
409
|
+
const kind = /^(TypeError|ReferenceError|RangeError|SyntaxError|AggregateError|Error):/.exec(safe.stack)?.[1] || 'Error'
|
|
410
|
+
safe.errorType = kind
|
|
411
|
+
safe.code = kind === 'Error' ? 'NODE_INTERNAL_ERROR' : 'NODE_' + kind.replace(/Error$/, '').toUpperCase() + '_ERROR'
|
|
412
|
+
const truncated = safe.stack.length > 32768 || safe.stack.split('\n').length > 41
|
|
413
|
+
safe.traceTruncated = truncated
|
|
414
|
+
safe.stack = safe.stack.slice(0, 32768).split('\n').filter(line => /^\s+at (?:async )?(?:[A-Za-z0-9_.$<>]+(?: \[as [A-Za-z0-9_$]+\])? \()?(?:file:\/\/\/|\/|node:)[^\s()?#]*:\d+:\d+\)?$/.test(line) && line.length <= 1024).slice(0, 40).join('\n')
|
|
415
|
+
safe.private = true; safe.valuesCaptured = false
|
|
416
|
+
safe.fix = kind === 'TypeError' ? 'Check nullable values, argument types and the data contract at these source frames' : kind === 'ReferenceError' ? 'Check the missing binding or import at these source frames' : kind === 'RangeError' ? 'Check recursive calls, array sizes and configured resource budgets at these source frames' : kind === 'SyntaxError' ? 'Check module syntax or JSON input parsing at these source frames without logging input values' : 'Inspect these source frames and the request reference; reproduce with synthetic inputs'
|
|
417
|
+
}
|
|
418
|
+
if (typeof safe.path === 'string') safe.path = safe.path.split(/[?#]/, 1)[0].slice(0, 2048)
|
|
419
|
+
sink(level, msg, safe)
|
|
420
|
+
}
|
|
88
421
|
const mf = readManifest(dist)
|
|
89
422
|
// the build's single route table (#187): already in match order, each with its RegExp source (named groups = params)
|
|
90
423
|
const routes = mf.routes.map(r => ({ ...r, rx: r.rx ? new RegExp(r.rx) : exact(r.path) }))
|
|
@@ -110,9 +443,41 @@ export function createServer(o = {}) {
|
|
|
110
443
|
hook(distURL, BASE)
|
|
111
444
|
|
|
112
445
|
// ---------- helpers ----------
|
|
113
|
-
const send = (res, status, body, headers = {}) => {
|
|
446
|
+
const send = (res, status, body, headers = {}, configuredExternal = false) => {
|
|
447
|
+
if (native && res.req.$nativeLogout && !res.req.$nativeLogoutDone) {
|
|
448
|
+
res.req.$nativeLogout.then(() => send(res, status, body, headers, configuredExternal), () => send(res, 503, '', {}, false))
|
|
449
|
+
return
|
|
450
|
+
}
|
|
451
|
+
if (native && res.req.$nativeLogoutError) { status = 503; body = ''; headers = {}; res.req.$nativeLogoutError = false }
|
|
452
|
+
if (native && res.req.$nativeLogoutHeaders) {
|
|
453
|
+
const relay = res.req.$nativeLogoutHeaders, own = headers['set-cookie']
|
|
454
|
+
headers = { ...headers, ...relay, 'set-cookie': [...(own ? Array.isArray(own) ? own : [own] : []), ...relay['set-cookie']] }
|
|
455
|
+
}
|
|
456
|
+
if (!Number.isInteger(status) || status < 200 || status > 599 || !STATUS_CODES[status]) throw new HttpError(500, 'Invalid final response status')
|
|
457
|
+
if (headers.location != null) {
|
|
458
|
+
if (configuredExternal && /^https?:\/\//.test(headers.location)) {
|
|
459
|
+
const target = new URL(headers.location)
|
|
460
|
+
if (target.username || target.password || /[\\\x00-\x20\x7f]/.test(headers.location)) throw new HttpError(500, 'Invalid configured redirect location')
|
|
461
|
+
} else safeLocation(headers.location)
|
|
462
|
+
}
|
|
463
|
+
if (status >= 400) {
|
|
464
|
+
headers = { ...headers, 'cache-control': 'private, no-store', vary: [headers.vary, 'Accept'].filter(Boolean).join(', ') }
|
|
465
|
+
const kind = headers['content-type'] || ''
|
|
466
|
+
if (!kind || kind.startsWith('text/plain')) {
|
|
467
|
+
const rid = res.getHeader('x-request-id') || ''
|
|
468
|
+
if (wantsHtml(res.req)) {
|
|
469
|
+
body = defaultError(status, rid, BASE)
|
|
470
|
+
headers['content-type'] = MIME['.html']
|
|
471
|
+
headers['content-security-policy'] = mf.csp || CSP0
|
|
472
|
+
} else {
|
|
473
|
+
body = json({ schemaVersion: 1, status, rid, error: STATUS_CODES[status] || 'Request failed' })
|
|
474
|
+
headers['content-type'] = MIME['.json']
|
|
475
|
+
}
|
|
476
|
+
delete headers['content-length']; delete headers['content-encoding']; delete headers.etag
|
|
477
|
+
}
|
|
478
|
+
}
|
|
114
479
|
res.writeHead(status, { ...SECURITY, ...(server.draining ? { connection: 'close' } : {}), ...headers })
|
|
115
|
-
res.end(body)
|
|
480
|
+
res.end(res.req.method === 'HEAD' || status === 204 || status === 205 || status === 304 ? undefined : body)
|
|
116
481
|
}
|
|
117
482
|
// HTML: `no-cache` + ETag (strong for files, weak for SSR) → a repeat visit gets 304 with no body (§35); the ETag is
|
|
118
483
|
// taken before the per-response nonce is injected, so it is stable
|
|
@@ -124,20 +489,21 @@ export function createServer(o = {}) {
|
|
|
124
489
|
}
|
|
125
490
|
const nonce = randomBytes(16).toString('base64')
|
|
126
491
|
const body = page.replace(/<script(?![^>]*\bnonce=)/g, `<script nonce="${nonce}"`)
|
|
127
|
-
send(res, status, body, { 'content-type': MIME['.html'], 'content-security-policy': withNonce(mf.csp || CSP0, nonce), 'cache-control': 'no-cache', ...(mf.build ? { 'x-brain-build': mf.build } : {}), ...headers })
|
|
492
|
+
send(res, status, body, { 'content-type': MIME['.html'], 'content-security-policy': withNonce(mf.csp || CSP0, nonce), 'cache-control': 'no-cache', ...(googleSignInPage(page) ? { 'cross-origin-opener-policy': 'same-origin-allow-popups' } : {}), ...(mf.build ? { 'x-brain-build': mf.build } : {}), ...headers })
|
|
128
493
|
}
|
|
129
|
-
const notFound = (req, res) =>
|
|
130
|
-
|
|
131
|
-
if (
|
|
132
|
-
|
|
494
|
+
const notFound = (req, res) => failPage(res, 404, res.getHeader('x-request-id'))
|
|
495
|
+
const failPage = (res, status, rid) => {
|
|
496
|
+
if (!wantsHtml(res.req)) return send(res, status, json({ schemaVersion: 1, status, rid, error: STATUS_CODES[status] || 'Request failed' }), { 'content-type': MIME['.json'] })
|
|
497
|
+
const custom = (status === 404 && mf.notFound) || (mf.errors && mf.errors[status]) || `${status}.html`
|
|
498
|
+
const path = typeof custom === 'string' && file(custom)
|
|
499
|
+
return html(res, status, path ? readFileSync(path, 'utf8') : defaultError(status, rid, BASE))
|
|
133
500
|
}
|
|
134
|
-
const failPage = (res, status, rid) =>
|
|
135
|
-
html(res, status, `<!doctype html><title>Error</title><main><h1>${status === 504 ? 'The page took too long' : 'Something went wrong'}</h1><p>Reference: ${esc(rid)}</p></main>`)
|
|
136
501
|
// a file inside dist/ (never outside: no `..`, no absolute paths)
|
|
137
502
|
function file(rel) {
|
|
138
503
|
const p = normalize(join(dist, rel))
|
|
139
504
|
if (p !== dist && !p.startsWith(dist + sep)) return null
|
|
140
505
|
try {
|
|
506
|
+
for (let cursor = p; cursor !== dist; cursor = dirname(cursor)) if (lstatSync(cursor).isSymbolicLink()) return null
|
|
141
507
|
return statSync(p).isFile() ? p : null
|
|
142
508
|
} catch {
|
|
143
509
|
return null
|
|
@@ -152,9 +518,12 @@ export function createServer(o = {}) {
|
|
|
152
518
|
|
|
153
519
|
const context = (req, res, url, extra) => {
|
|
154
520
|
const cookies = parseCookies(req.headers.cookie)
|
|
155
|
-
const { user, session: ses } = sessionFrom(cookies, secret)
|
|
521
|
+
const { user, session: ses } = native ? { user: req.$nativeUser ?? null, session: native.session(req, res, res.getHeader('x-request-id')) } : sessionFrom(cookies, secret)
|
|
156
522
|
return {
|
|
157
523
|
url, query: Object.fromEntries(url.searchParams), cookies, user, session: ses, env,
|
|
524
|
+
...(native ? { backend: Object.freeze({resource: name => native.resource(req, res, res.getHeader('x-request-id'), name),login:credentials=>native.login(req,res,res.getHeader('x-request-id'),credentials)}) } : {}),
|
|
525
|
+
// `prefetch`: the router fetched this page ahead of a click (§19); a `load` with side effects can skip them
|
|
526
|
+
prefetch: req.headers['x-brain-prefetch'] === '1',
|
|
158
527
|
req: { method: req.method, headers: req.headers, ip: ipOf(req) }, ...extra,
|
|
159
528
|
}
|
|
160
529
|
}
|
|
@@ -217,7 +586,12 @@ export function createServer(o = {}) {
|
|
|
217
586
|
} catch {}
|
|
218
587
|
}
|
|
219
588
|
if (!nocache && route.cache > 0 && !forms && !fixed && status === 200 && anonymous(req)) return cached(req, res, route, params, url, rid)
|
|
589
|
+
if (native) await native.prepare(req, res, rid)
|
|
220
590
|
const ctx = context(req, res, url, { params })
|
|
591
|
+
// a page rendered for a signed-in user is never stored (§35): after logout, Back must not show the previous
|
|
592
|
+
// user's page from the HTTP cache or bfcache. Anonymous pages keep `no-cache` + ETag.
|
|
593
|
+
const own = ctx.user != null || ctx.cookies[COOKIE] !== undefined || native && /(?:^|;\s*)__Host-brain(?:-access|-refresh)?=/.test(req.headers.cookie || '')
|
|
594
|
+
const keep = h => (own ? { ...h, 'cache-control': 'private, no-store' } : h)
|
|
221
595
|
let props = fixed || {}, extra = {}
|
|
222
596
|
if (!fixed && ((route.guards && route.guards.length) || (route.middleware && route.middleware.length))) {
|
|
223
597
|
let r
|
|
@@ -230,24 +604,25 @@ export function createServer(o = {}) {
|
|
|
230
604
|
log('error', 'guard failed', { rid, path: url.pathname, stack: String((e && e.stack) || e) })
|
|
231
605
|
return errorPage(req, res, route, params, url, rid)
|
|
232
606
|
}
|
|
233
|
-
if (r.redirect) return send(res, r.status, '', { location: withBase(r.redirect), 'cache-control': 'no-store', ...ctx.session.headers() })
|
|
607
|
+
if (r.redirect) return send(res, r.status, '', { location: withBase(redirect(r.redirect, r.status ?? 303).redirect), 'cache-control': 'no-store', ...ctx.session.headers() })
|
|
234
608
|
if (r.deny !== undefined) return forbidden(req, res, url, rid, r.deny)
|
|
235
609
|
extra = r.headers
|
|
236
610
|
}
|
|
237
|
-
if (route.stream && route.server && !fixed && !forms && status === 200 && !res.$capture) return streamed(req, res, route, params, url, rid, ctx, extra)
|
|
611
|
+
if (route.stream && route.server && !fixed && !forms && status === 200 && !res.$capture) return streamed(req, res, route, params, url, rid, ctx, keep(extra || {}))
|
|
238
612
|
try {
|
|
239
613
|
if (!fixed && route.server) {
|
|
240
614
|
const m = await load(route.server)
|
|
241
615
|
if (typeof m.load === 'function') props = (await withTimeout(Promise.resolve(m.load(ctx)), TIMEOUT)) ?? {}
|
|
242
616
|
}
|
|
243
617
|
} catch (e) {
|
|
244
|
-
if (e && e.redirect) return send(res, e.status || 303, '', { location: withBase(e.redirect), ...ctx.session.headers() })
|
|
245
|
-
if (e instanceof HttpError && e.status === 404) return notFound(req, res)
|
|
246
|
-
const status = e
|
|
618
|
+
if (e && e.redirect) return send(res, e.status || 303, '', { location: withBase(redirect(e.redirect, e.status ?? 303).redirect), ...ctx.session.headers() })
|
|
619
|
+
if ((e instanceof HttpError && e.status === 404) || (e && e.notFound === true)) return notFound(req, res) // `throw notFound()` (F11)
|
|
620
|
+
const status = intendedStatus(e) || 500
|
|
247
621
|
log('error', 'load failed', { rid, path: url.pathname, status, stack: String((e && e.stack) || e) })
|
|
248
622
|
return status === 500 ? errorPage(req, res, route, params, url, rid) : failPage(res, status, rid)
|
|
249
623
|
}
|
|
250
|
-
if (props && props.redirect) return send(res, props.status || 303, '', { location: withBase(props.redirect), ...ctx.session.headers() })
|
|
624
|
+
if (props && props.redirect) return send(res, props.status || 303, '', { location: withBase(redirect(props.redirect, props.status ?? 303).redirect), ...ctx.session.headers() })
|
|
625
|
+
if (props && props.notFound === true) return notFound(req, res) // `return notFound()` (F11)
|
|
251
626
|
let body, seo, failed
|
|
252
627
|
try {
|
|
253
628
|
const at = { path: url.pathname, params: params || {}, query: Object.fromEntries(url.searchParams) } // route() (§41.5)
|
|
@@ -261,12 +636,12 @@ export function createServer(o = {}) {
|
|
|
261
636
|
const page = readFileSync(file(route.page), 'utf8')
|
|
262
637
|
.replace('<!--brain:ssr-->', () => body)
|
|
263
638
|
.replace('<!--brain:props-->', () => jsonInHtml(props))
|
|
264
|
-
let out = head(page, seo)
|
|
639
|
+
let out = head(page, seo, url.pathname)
|
|
265
640
|
// route params for route() in the browser (the client has no route table)
|
|
266
641
|
if (params && Object.keys(params).length) out = out.replace('</head>', () => `<meta name="brain-params" content="${esc(JSON.stringify(params))}"></head>`)
|
|
267
642
|
if (forms) out = out.replace('</body>', () => `<script type="application/json" id="brain-forms">${jsonInHtml(forms)}</script></body>`)
|
|
268
|
-
if (res.$capture) return res.$capture(out, { ...extra, ...ctx.session.headers() }, status)
|
|
269
|
-
html(res, status, out, { ...extra, ...ctx.session.headers() }, 'W/"' + tag(out) + '"')
|
|
643
|
+
if (res.$capture) return res.$capture(out, keep({ ...extra, ...ctx.session.headers() }), status)
|
|
644
|
+
html(res, status, out, keep({ ...extra, ...ctx.session.headers() }), own ? undefined : 'W/"' + tag(out) + '"')
|
|
270
645
|
}
|
|
271
646
|
// Streaming SSR (#232, `route.stream`: the build sets it when the view's <meta seo> is static and it has a load()):
|
|
272
647
|
// the page up to the region (head, CSS, layout shell) is sent at once, the view when load() ends. The status and
|
|
@@ -293,7 +668,7 @@ export function createServer(o = {}) {
|
|
|
293
668
|
const loadP = typeof sm.load === 'function' ? withTimeout(Promise.resolve().then(() => sm.load(ctx)), TIMEOUT) : Promise.resolve(null)
|
|
294
669
|
loadP.catch(() => {}) // handled below; never an unhandled rejection while the loading view renders
|
|
295
670
|
const at = { path: url.pathname, params: params || {}, query: Object.fromEntries(url.searchParams) }
|
|
296
|
-
res.write(n(head(top, seo0)))
|
|
671
|
+
res.write(n(head(top, seo0, url.pathname)))
|
|
297
672
|
// QA #253: the folder's _loading.brain goes in the region with the first chunk (SPEC: shows while load runs),
|
|
298
673
|
// between two marker comments; the last chunk starts with a script that removes them and what is between
|
|
299
674
|
let clear = ''
|
|
@@ -322,9 +697,11 @@ export function createServer(o = {}) {
|
|
|
322
697
|
let props
|
|
323
698
|
try {
|
|
324
699
|
props = (await loadP) ?? {}
|
|
325
|
-
if (props.redirect) throw props
|
|
700
|
+
if (props.redirect || props.notFound === true) throw props
|
|
326
701
|
} catch (e) {
|
|
327
|
-
if (e && e.redirect) return end(`<script>location.replace(${jsonInHtml(withBase(e.redirect))})</script>`)
|
|
702
|
+
if (e && e.redirect) return end(`<script>location.replace(${jsonInHtml(withBase(redirect(e.redirect, e.status ?? 303).redirect))})</script>`)
|
|
703
|
+
// a streamed page already sent its 200 head: the region gets the not-found notice and the page is noindex
|
|
704
|
+
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, ''))
|
|
328
705
|
log('error', 'load failed (streamed)', { rid, path: url.pathname, status: 200, stack: String((e && e.stack) || e) }) // headers already out
|
|
329
706
|
return fail()
|
|
330
707
|
}
|
|
@@ -351,14 +728,15 @@ export function createServer(o = {}) {
|
|
|
351
728
|
}
|
|
352
729
|
// deny → views/403.brain (manifest `forbidden`, props.reason) or the static 403.html, always status 403
|
|
353
730
|
function forbidden(req, res, url, rid, reason) {
|
|
731
|
+
if (!wantsHtml(req)) return failPage(res, 403, rid)
|
|
354
732
|
if (mf.forbidden) return ssr(req, res, mf.forbidden, {}, url, rid, null, 403, { reason })
|
|
355
733
|
const f = file('403.html')
|
|
356
734
|
if (f) return html(res, 403, readFileSync(f, 'utf8'))
|
|
357
|
-
|
|
735
|
+
return failPage(res, 403, rid)
|
|
358
736
|
}
|
|
359
737
|
// a failing load or guard → the folder's _error.brain (route `error`, props { message, rid }), else the generic page
|
|
360
738
|
function errorPage(req, res, route, params, url, rid) {
|
|
361
|
-
if (route.error) return ssr(req, res, route.error, params, url, rid, null, 500, { message: 'Something went wrong.', rid })
|
|
739
|
+
if (wantsHtml(req) && route.error) return ssr(req, res, route.error, params, url, rid, null, 500, { message: 'Something went wrong.', rid })
|
|
362
740
|
return failPage(res, 500, rid)
|
|
363
741
|
}
|
|
364
742
|
async function render(route, props, forms, at = null) {
|
|
@@ -426,6 +804,7 @@ export function createServer(o = {}) {
|
|
|
426
804
|
} catch {
|
|
427
805
|
return fail(413, 'Body larger than 64 KB')
|
|
428
806
|
}
|
|
807
|
+
if (native) await native.prepare(req, res, rid)
|
|
429
808
|
const ctx = context(req, res, url, { params: {} })
|
|
430
809
|
let f
|
|
431
810
|
try {
|
|
@@ -467,6 +846,7 @@ export function createServer(o = {}) {
|
|
|
467
846
|
}
|
|
468
847
|
const id = new URLSearchParams(raw).get('_brain') || '', modPath = mf.rpc[id.split('/')[0]]
|
|
469
848
|
if (!modPath) return plain(400, 'Unknown form')
|
|
849
|
+
if (native) await native.prepare(req, res, rid)
|
|
470
850
|
const ctx = context(req, res, url, { params })
|
|
471
851
|
let module, rules
|
|
472
852
|
try {
|
|
@@ -483,18 +863,118 @@ export function createServer(o = {}) {
|
|
|
483
863
|
plain(r.status, r.body.error, r.headers)
|
|
484
864
|
}
|
|
485
865
|
|
|
866
|
+
// ---------- dynamic sitemap (§32) ----------
|
|
867
|
+
// A dynamic view without paths() can export `sitemap()` (manifest route `sitemap: true`): it returns the params of its
|
|
868
|
+
// pages, plus an optional `lastmod`, at request time. /sitemap.xml is the build's file with those URLs added (a sitemap
|
|
869
|
+
// index gets one more child, /sitemap-dynamic.xml). A file holds at most 50 000 URLs (sitemaps.org): when the build's
|
|
870
|
+
// URLs plus the dynamic ones pass that, /sitemap.xml becomes an index of /sitemap-static.xml (the build's file) and
|
|
871
|
+
// /sitemap-dynamic.xml; dynamic URLs past 50 000 are dropped with a warning. Absolute URLs take the origin of the
|
|
872
|
+
// build's sitemap (brain.json `site`); a build without a sitemap serves none. The list is kept 5 min (one sitemap() run
|
|
873
|
+
// at a time when it expires) and the response says max-age=300; a sitemap() that throws is logged and skipped.
|
|
874
|
+
const DYN = routes.filter(r => r.sitemap && r.server)
|
|
875
|
+
let dynList = null
|
|
876
|
+
const fill = (pattern, p) => {
|
|
877
|
+
let ok = true
|
|
878
|
+
const out = pattern.replace(/([:*])(\w+)/g, (_, k, n) => {
|
|
879
|
+
const v = p && p[n]
|
|
880
|
+
if (v == null || v === '') return (ok = false), ''
|
|
881
|
+
return k === '*' ? String(v).split('/').map(encodeURIComponent).join('/') : encodeURIComponent(String(v))
|
|
882
|
+
})
|
|
883
|
+
return ok ? out : null
|
|
884
|
+
}
|
|
885
|
+
const day = v => (v instanceof Date ? v.toISOString().slice(0, 10) : typeof v === 'string' && /^\d{4}-\d{2}-\d{2}/.test(v) ? v : null)
|
|
886
|
+
const MAX_URLS = 50000
|
|
887
|
+
let dynRun = null
|
|
888
|
+
async function collect(origin) {
|
|
889
|
+
const urls = []
|
|
890
|
+
let dropped = 0
|
|
891
|
+
for (const r of DYN) {
|
|
892
|
+
try {
|
|
893
|
+
const sm = await load(r.server)
|
|
894
|
+
const list = typeof sm.sitemap === 'function' ? await withTimeout(Promise.resolve().then(() => sm.sitemap({ env })), TIMEOUT) : []
|
|
895
|
+
for (const p of Array.isArray(list) ? list : []) {
|
|
896
|
+
const path = fill(r.path, p)
|
|
897
|
+
if (!path) continue
|
|
898
|
+
if (urls.length < MAX_URLS) urls.push({ loc: origin + withBase(path), lastmod: day(p.lastmod) })
|
|
899
|
+
else dropped++
|
|
900
|
+
}
|
|
901
|
+
} catch (e) {
|
|
902
|
+
log('error', 'sitemap() failed', { view: r.view, stack: String((e && e.stack) || e) })
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
if (dropped) log('warn', 'sitemap(): more than 50000 URLs, the rest are not listed', { dropped })
|
|
906
|
+
dynList = { at: Date.now(), urls }
|
|
907
|
+
return urls
|
|
908
|
+
}
|
|
909
|
+
async function dynamicUrls(origin) {
|
|
910
|
+
if (dynList && Date.now() - dynList.at < 300000) return dynList.urls
|
|
911
|
+
// concurrent requests after expiry share one run of every sitemap()
|
|
912
|
+
if (!dynRun) dynRun = collect(origin).finally(() => (dynRun = null))
|
|
913
|
+
return dynRun
|
|
914
|
+
}
|
|
915
|
+
async function serveSitemap(req, res, url) {
|
|
916
|
+
const f = file('/sitemap.xml')
|
|
917
|
+
if (!f) return false
|
|
918
|
+
const text = readFileSync(f, 'utf8')
|
|
919
|
+
const origin = [...text.matchAll(/<loc>(https?:\/\/[^<]+)<\/loc>/g)].map(m => URL.canParse(m[1]) && new URL(m[1]).origin).find(Boolean)
|
|
920
|
+
if (!origin) return false
|
|
921
|
+
const index = text.includes('<sitemapindex')
|
|
922
|
+
const urls = await dynamicUrls(origin)
|
|
923
|
+
// a urlset that would pass 50 000 URLs with the dynamic ones is split: index → static file + dynamic file
|
|
924
|
+
const split = !index && (text.match(/<url>/g) || []).length + urls.length > MAX_URLS
|
|
925
|
+
const xml = body => send(res, 200, req.method === 'HEAD' ? '' : body, { 'content-type': MIME['.xml'], 'cache-control': 'public, max-age=300' })
|
|
926
|
+
if (url.pathname === '/sitemap-static.xml') return split ? (xml(text), true) : false
|
|
927
|
+
if (url.pathname === '/sitemap-dynamic.xml' && !index && !split) return false
|
|
928
|
+
const child = p => ` <sitemap><loc>${esc(origin + withBase(p))}</loc></sitemap>\n`
|
|
929
|
+
const set = urls.map(u => ` <url><loc>${esc(u.loc)}</loc>${u.lastmod ? `<lastmod>${esc(u.lastmod)}</lastmod>` : ''}</url>\n`).join('')
|
|
930
|
+
xml(
|
|
931
|
+
url.pathname === '/sitemap-dynamic.xml'
|
|
932
|
+
? `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${set}</urlset>\n`
|
|
933
|
+
: index
|
|
934
|
+
? text.replace('</sitemapindex>', `${child('/sitemap-dynamic.xml')}</sitemapindex>`)
|
|
935
|
+
: split
|
|
936
|
+
? `<?xml version="1.0" encoding="UTF-8"?>\n<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${child('/sitemap-static.xml')}${child('/sitemap-dynamic.xml')}</sitemapindex>\n`
|
|
937
|
+
: text.replace('</urlset>', set + '</urlset>'),
|
|
938
|
+
)
|
|
939
|
+
return true
|
|
940
|
+
}
|
|
941
|
+
|
|
486
942
|
// ---------- static ----------
|
|
943
|
+
// §33 content negotiation: `Accept` preferring text/markdown gets the page's Markdown twin (`/pricing` → pricing.md,
|
|
944
|
+
// `/` → index.md) when the build published one (`seo.markdown`, default true; never for noindex pages).
|
|
945
|
+
function publicStaticPath(pathname) {
|
|
946
|
+
let path
|
|
947
|
+
try { path = decodeURIComponent(pathname) } catch { return null }
|
|
948
|
+
if (/[\\\x00]/.test(path)) return null
|
|
949
|
+
const clean = normalize(path).replaceAll('\\', '/')
|
|
950
|
+
const rel = clean.replace(/^\/+/, '')
|
|
951
|
+
if (rel.startsWith('_server') || rel.startsWith('_brain') || rel.startsWith('server.mjs') || rel === '_headers' || rel === '_redirects' || clean.split('/').some(part => part.startsWith('.') && part !== '.well-known')) return null
|
|
952
|
+
return path
|
|
953
|
+
}
|
|
954
|
+
function serveTwin(req, res, url) {
|
|
955
|
+
if (!prefersMarkdown(req.headers.accept)) return false
|
|
956
|
+
const path = publicStaticPath(url.pathname)
|
|
957
|
+
if (path === null) return false
|
|
958
|
+
const md = file(mdPath(path))
|
|
959
|
+
if (!md) return false
|
|
960
|
+
send(res, 200, req.method === 'HEAD' ? '' : readFileSync(md), { 'content-type': MIME['.md'], 'cache-control': 'public, max-age=3600', vary: 'accept' })
|
|
961
|
+
return true
|
|
962
|
+
}
|
|
487
963
|
function serveStatic(req, res, url) {
|
|
488
|
-
|
|
964
|
+
const p = publicStaticPath(url.pathname)
|
|
965
|
+
if (p === null) return false
|
|
489
966
|
const candidates = p.endsWith('/') ? [p + 'index.html'] : [p, p + '.html', p + '/index.html']
|
|
490
967
|
for (const c of candidates) {
|
|
491
968
|
const f = file(c)
|
|
492
969
|
if (!f) continue
|
|
493
970
|
const ext = extname(f), type = FEEDS[basename(f)] || MIME[ext] || 'application/octet-stream'
|
|
494
971
|
if (ext === '.html') {
|
|
972
|
+
// §33: the page's Markdown twin (`/pricing` → pricing.md) when the client prefers text/markdown
|
|
973
|
+
if (serveTwin(req, res, url)) return true
|
|
974
|
+
const md = file(mdPath(p))
|
|
495
975
|
const page = readFileSync(f, 'utf8')
|
|
496
976
|
// static HTML changes only with a deploy: its ETag is the build signature (content hash without a manifest)
|
|
497
|
-
return html(res, 200, page, {}, '"' + (mf.build || tag(page)) + '"'), true
|
|
977
|
+
return html(res, 200, page, md ? { vary: 'accept', 'x-brain-static': '1' } : { 'x-brain-static': '1' }, '"' + (mf.build || tag(page)) + '"'), true
|
|
498
978
|
}
|
|
499
979
|
const headers = {
|
|
500
980
|
'content-type': type,
|
|
@@ -502,10 +982,11 @@ export function createServer(o = {}) {
|
|
|
502
982
|
'cache-control': p.startsWith('/_b/') ? 'public, max-age=31536000, immutable' : p === '/sw.js' ? 'no-cache' : 'public, max-age=3600',
|
|
503
983
|
vary: 'accept-encoding',
|
|
504
984
|
}
|
|
505
|
-
const
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
985
|
+
for (const enc of acceptedEncodings(req.headers['accept-encoding'])) {
|
|
986
|
+
const suffix = enc === 'br' ? '.br' : '.gz'
|
|
987
|
+
const encoded = file(c + suffix)
|
|
988
|
+
if (encoded) {
|
|
989
|
+
send(res, 200, req.method === 'HEAD' ? '' : readFileSync(encoded), { ...headers, 'content-encoding': enc })
|
|
509
990
|
return true
|
|
510
991
|
}
|
|
511
992
|
}
|
|
@@ -522,7 +1003,7 @@ export function createServer(o = {}) {
|
|
|
522
1003
|
const t0 = performance.now()
|
|
523
1004
|
res.on('finish', () => {
|
|
524
1005
|
const ms = performance.now() - t0
|
|
525
|
-
if (ms > 1000) log('warn', 'slow request', { rid, path: req.url, status: res.statusCode, ms: Math.round(ms) })
|
|
1006
|
+
if (ms > 1000) log('warn', 'slow request', { rid, path: req.$brainPath || String(req.url || '').split('?')[0], status: res.statusCode, ms: Math.round(ms) })
|
|
526
1007
|
})
|
|
527
1008
|
try {
|
|
528
1009
|
const full = new URL(req.url, 'http://' + (req.headers.host || 'localhost'))
|
|
@@ -534,12 +1015,14 @@ export function createServer(o = {}) {
|
|
|
534
1015
|
if (!full.pathname.startsWith(BASE)) return notFound(req, res)
|
|
535
1016
|
url = new URL('/' + full.pathname.slice(BASE.length) + full.search, full) // the app path
|
|
536
1017
|
}
|
|
1018
|
+
req.$brainPath = url.pathname
|
|
1019
|
+
if (native && /^\/(?:api(?:\/|$)|_brain(?:\/|$)|s\/)/.test(url.pathname)) return await native.proxy(req,res,url,rid,send)
|
|
537
1020
|
// redirects.json (manifest `redirects`, brain-seo server_json): exact sources first, then patterns, on the app path;
|
|
538
1021
|
// same-site targets get the base, the query is kept when the target has none
|
|
539
1022
|
const to = redirectOf(url.pathname)
|
|
540
1023
|
if (to && (req.method === 'GET' || req.method === 'HEAD')) {
|
|
541
1024
|
const loc = /^https?:\/\//.test(to[0]) ? to[0] : withBase(to[0]) + (to[0].includes('?') ? '' : url.search)
|
|
542
|
-
return send(res, to[1], '', { location: loc, 'cache-control': to[1] === 301 || to[1] === 308 ? 'public, max-age=3600' : 'no-store' })
|
|
1025
|
+
return send(res, to[1], '', { location: loc, 'cache-control': to[1] === 301 || to[1] === 308 ? 'public, max-age=3600' : 'no-store' }, true)
|
|
543
1026
|
}
|
|
544
1027
|
const r = /^\/_r\/([\w-]+)\/([\w$]+)$/.exec(url.pathname)
|
|
545
1028
|
if (r) return await rpc(req, res, r[1], r[2], url, rid)
|
|
@@ -552,17 +1035,20 @@ export function createServer(o = {}) {
|
|
|
552
1035
|
if (!m) continue
|
|
553
1036
|
// a form page without load is prerendered: GET serves that file (build ETag); only its POST needs the server
|
|
554
1037
|
if (!post && route.forms && serveStatic(req, res, url)) return
|
|
1038
|
+
if (!post && serveTwin(req, res, url)) return
|
|
555
1039
|
return await (post ? formPost : ssr)(req, res, route, m.groups ? { ...m.groups } : {}, url, rid)
|
|
556
1040
|
}
|
|
557
1041
|
if (post) return send(res, 405, 'Method not allowed', { allow: 'GET, HEAD' })
|
|
1042
|
+
if (DYN.length && (url.pathname === '/sitemap.xml' || url.pathname === '/sitemap-dynamic.xml' || url.pathname === '/sitemap-static.xml') && (await serveSitemap(req, res, url))) return
|
|
558
1043
|
if (serveStatic(req, res, url)) return
|
|
559
1044
|
notFound(req, res)
|
|
560
1045
|
} catch (e) {
|
|
561
1046
|
log('error', 'unhandled', { rid, path: req.url, stack: String((e && e.stack) || e) })
|
|
562
|
-
if (!res.headersSent) failPage(res, 500, rid)
|
|
1047
|
+
if (!res.headersSent) failPage(res, intendedStatus(e) || 500, rid)
|
|
563
1048
|
else res.end()
|
|
564
1049
|
}
|
|
565
1050
|
})
|
|
1051
|
+
if (native) server.once('close', () => native.close())
|
|
566
1052
|
server.headersTimeout = 15000
|
|
567
1053
|
server.requestTimeout = 30000
|
|
568
1054
|
return server
|
|
@@ -604,8 +1090,11 @@ export function sessionFrom(cookies, secret) {
|
|
|
604
1090
|
set.push(cookie('', 0))
|
|
605
1091
|
cleared = true
|
|
606
1092
|
},
|
|
607
|
-
// logout wipes this origin's
|
|
608
|
-
|
|
1093
|
+
// logout wipes this origin's storage on the device: localStorage, IndexedDB, the Cache API and service workers
|
|
1094
|
+
// (§35: the next user sees nothing). Not "cache": Chrome clears the whole HTTP cache before it finishes the
|
|
1095
|
+
// response, so the logout call took ~18 s (F16). Nothing private is in that cache: RPC is no-store and SSR
|
|
1096
|
+
// HTML rendered with a session cookie is `private, no-store` (ssr() → keep).
|
|
1097
|
+
headers: () => ({ ...(set.length ? { 'set-cookie': set } : {}), ...(cleared ? { 'clear-site-data': '"storage"' } : {}) }),
|
|
609
1098
|
},
|
|
610
1099
|
}
|
|
611
1100
|
}
|
|
@@ -615,14 +1104,14 @@ export function sessionFrom(cookies, secret) {
|
|
|
615
1104
|
// `_middleware.jsbrain`: async (ctx, next) => …; may set ctx.headers (response headers) or stop with redirect/deny.
|
|
616
1105
|
// The same shapes come out of brain-router's redirect()/deny() in the browser.
|
|
617
1106
|
export const deny = (reason = 'Forbidden') => ({ deny: String(reason) })
|
|
618
|
-
const samePath = u =>
|
|
1107
|
+
const samePath = u => { try { safeLocation(u); return true } catch { return false } }
|
|
619
1108
|
function verdict(v) {
|
|
620
1109
|
if (v === false) return { deny: 'Forbidden' }
|
|
621
1110
|
if (typeof v === 'string') v = { redirect: v }
|
|
622
1111
|
if (!v || typeof v !== 'object') return null
|
|
623
1112
|
if (v.redirect !== undefined) {
|
|
624
1113
|
if (!samePath(v.redirect)) throw new HttpError(500, 'redirect to another origin is not allowed: ' + v.redirect) // §31 open redirect
|
|
625
|
-
return
|
|
1114
|
+
return redirect(v.redirect, v.status ?? 303)
|
|
626
1115
|
}
|
|
627
1116
|
return v.deny !== undefined ? { deny: String(v.deny) } : null
|
|
628
1117
|
}
|
|
@@ -707,7 +1196,9 @@ export async function callRpc({ fn, args, ctx, key, limit = 60, timeout = 10000
|
|
|
707
1196
|
} catch (e) {
|
|
708
1197
|
if (e && e.errors && typeof e.errors === 'object') return { status: 422, body: { error: e.message || 'Invalid input', errors: e.errors } }
|
|
709
1198
|
if (e instanceof HttpError && e.status === 504) return { status: 504, body: { error: 'Timeout' }, cause: e }
|
|
710
|
-
|
|
1199
|
+
const deliberate = intendedStatus(e)
|
|
1200
|
+
if (deliberate && deliberate < 500) return { status: deliberate, body: { error: e instanceof HttpError ? e.message : STATUS_CODES[deliberate] } }
|
|
1201
|
+
if (deliberate) return {status:deliberate,body:{error:STATUS_CODES[deliberate]},cause:e}
|
|
711
1202
|
return { status: 500, body: { error: 'Internal error' }, cause: e }
|
|
712
1203
|
}
|
|
713
1204
|
}
|
|
@@ -731,10 +1222,13 @@ export async function callForm({ module, raw, ctx, rules, key, limit = 60, timeo
|
|
|
731
1222
|
} catch (e) {
|
|
732
1223
|
if (e && e.errors && typeof e.errors === 'object') return again(e.errors, '')
|
|
733
1224
|
if (e instanceof HttpError && e.status >= 400 && e.status < 500) return again({}, e.message)
|
|
1225
|
+
const deliberate = intendedStatus(e)
|
|
1226
|
+
if (deliberate && deliberate < 500) return {status:deliberate,body:{error:STATUS_CODES[deliberate]}}
|
|
1227
|
+
if (deliberate) return {status:deliberate,body:{error:STATUS_CODES[deliberate]},cause:e}
|
|
734
1228
|
return again({}, 'Something went wrong. Try again.', e)
|
|
735
1229
|
}
|
|
736
1230
|
if (r && r.errors) return again(r.errors, '')
|
|
737
|
-
const to = r &&
|
|
1231
|
+
const to = r && r.redirect != null ? safeLocation(r.redirect) : ctx.url.pathname + ctx.url.search
|
|
738
1232
|
return { status: 303, location: to }
|
|
739
1233
|
}
|
|
740
1234
|
|
|
@@ -748,7 +1242,15 @@ function coerce(F, schema, q, base = '', values = {}, errors = {}) {
|
|
|
748
1242
|
coerce(F, r.s, q, path, values[k], errors)
|
|
749
1243
|
continue
|
|
750
1244
|
}
|
|
751
|
-
const one = v =>
|
|
1245
|
+
const one = v => {
|
|
1246
|
+
if (r.t === 'num') {
|
|
1247
|
+
if (v == null || v.trim() === '') return null
|
|
1248
|
+
// Validate the original decimal token before conversion; Number('0x10') hides invalid form input.
|
|
1249
|
+
return F.check({ ...r, list: false, req: false, min: null, max: null }, v) ? v : Number(v)
|
|
1250
|
+
}
|
|
1251
|
+
if (r.t === 'bool') return v == null || v === '' || v === 'false' || v === '0' ? false : v === 'on' || v === 'true' || v === '1' ? true : v
|
|
1252
|
+
return v == null ? '' : v
|
|
1253
|
+
}
|
|
752
1254
|
const v = r.list ? q.getAll(path).map(one) : r.t === 'bool' ? one(q.get(path)) : one(q.get(path) ?? '')
|
|
753
1255
|
values[k] = v
|
|
754
1256
|
const msg = F.check(r, r.t === 'num' && !r.list && v === null ? '' : r.t === 'num' && !r.list && Number.isNaN(v) ? q.get(path) : v)
|
|
@@ -798,30 +1300,90 @@ function fillValues(n) {
|
|
|
798
1300
|
else if (t === 'input' && c.type !== 'password') {
|
|
799
1301
|
if (c.value != null && c.value !== '') c.setAttribute('value', String(c.value))
|
|
800
1302
|
if (c.checked === true) c.setAttribute('checked', '')
|
|
1303
|
+
} else if (t === 'option' && c.selected === true) c.setAttribute('selected', '')
|
|
1304
|
+
// <select value={x}>: the option with that value starts selected (an option's value is its attribute or text)
|
|
1305
|
+
else if (t === 'select' && c.value != null && c.value !== '') {
|
|
1306
|
+
const pick = o => {
|
|
1307
|
+
for (const x of o.childNodes || []) {
|
|
1308
|
+
if (x.nodeType !== 1) continue
|
|
1309
|
+
if (x.tagName === 'OPTION' && (x.getAttribute('value') ?? x.textContent) === String(c.value)) x.setAttribute('selected', '')
|
|
1310
|
+
else pick(x)
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
pick(c)
|
|
801
1314
|
}
|
|
802
1315
|
fillValues(c)
|
|
803
1316
|
}
|
|
804
1317
|
}
|
|
1318
|
+
// `/` → index.md, `/docs/setup/` → docs/setup.md (brain-seo files::md_path)
|
|
1319
|
+
const mdPath = p => (p.replace(/^\/+|\/+$/g, '') || 'index') + '.md'
|
|
1320
|
+
// true when `Accept` ranks text/markdown above text/html (an equal weight goes to whichever is listed first);
|
|
1321
|
+
// wildcards alone never select Markdown, so browsers keep getting HTML
|
|
1322
|
+
export function prefersMarkdown(accept) {
|
|
1323
|
+
if (!accept) return false
|
|
1324
|
+
let md = -1, html = -1, mdAt = 0, htmlAt = 0, any = -1
|
|
1325
|
+
accept.split(',').forEach((part, i) => {
|
|
1326
|
+
const [type, ...params] = part.trim().toLowerCase().split(';')
|
|
1327
|
+
const qp = params.map(x => x.trim()).find(x => x.startsWith('q='))
|
|
1328
|
+
const q = qp ? Number(qp.slice(2)) : 1
|
|
1329
|
+
if (!(q >= 0)) return
|
|
1330
|
+
if (type === 'text/markdown' || type === 'text/x-markdown') (md = Math.max(md, q)), (mdAt = mdAt || i + 1)
|
|
1331
|
+
else if (type === 'text/html' || type === 'application/xhtml+xml') (html = Math.max(html, q)), (htmlAt = htmlAt || i + 1)
|
|
1332
|
+
else if (type === 'text/*' || type === '*/*') any = Math.max(any, q)
|
|
1333
|
+
})
|
|
1334
|
+
if (html < 0) (html = any), (htmlAt = Infinity) // an explicit type beats a wildcard of the same weight
|
|
1335
|
+
return md > 0 && (md > html || (md === html && mdAt < htmlAt))
|
|
1336
|
+
}
|
|
805
1337
|
// SEO head from View.seo(props): title, description, canonical, og:*/twitter:*, JSON-LD and the view's <head> block
|
|
806
|
-
|
|
807
|
-
|
|
1338
|
+
// F5: the build writes an SSR route's canonical/og:url from its pattern (/jobs/:slug); the request has the real path.
|
|
1339
|
+
// A view's own <head> tags replace the generated ones with the same name/property (one tag per key, SPEC «SEO»).
|
|
1340
|
+
function head(page, seo, path) {
|
|
808
1341
|
let h = page
|
|
1342
|
+
if (path) {
|
|
1343
|
+
// the attribute is already escaped HTML: only the request path that replaces a pattern is escaped
|
|
1344
|
+
const real = v => v.replace(/^(https?:\/\/[^/]+)?(\/[^"]*)$/, (m, o = '', p) => (/\/[:*]/.test(p) ? o + esc(path) : m))
|
|
1345
|
+
h = h.replace(/(<link[^>]*rel=["']canonical["'][^>]*href=")([^"]*)(")/i, (m, a, v, b) => a + real(v) + b)
|
|
1346
|
+
h = h.replace(/(<meta[^>]*property=["']og:url["'][^>]*content=")([^"]*)(")/i, (m, a, v, b) => a + real(v) + b)
|
|
1347
|
+
}
|
|
1348
|
+
if (!seo) return h
|
|
809
1349
|
const meta = (attr, key, val) => {
|
|
810
1350
|
const tag = `<meta ${attr}="${esc(key)}" content="${esc(val)}">`
|
|
811
1351
|
const rx = new RegExp(`<meta[^>]*\\b${attr}=["']${key.replace(/[.*+?^${}()|[\]\\:]/g, '\\$&')}["'][^>]*>`, 'i')
|
|
812
1352
|
h = rx.test(h) ? h.replace(rx, () => tag) : h.replace('</head>', () => tag + '</head>')
|
|
813
1353
|
}
|
|
814
1354
|
for (const [k, v] of Object.entries(seo)) {
|
|
815
|
-
|
|
1355
|
+
// `schema` becomes JSON-LD below and `lastmod` feeds it: neither is a meta tag (prerender seo_head does the same)
|
|
1356
|
+
if (v == null || k === '$head' || k === 'schema' || k === 'lastmod') continue
|
|
816
1357
|
if (k === 'title') h = /<title>[\s\S]*?<\/title>/i.test(h) ? h.replace(/<title>[\s\S]*?<\/title>/i, () => `<title>${esc(v)}</title>`) : h.replace('</head>', () => `<title>${esc(v)}</title></head>`)
|
|
817
1358
|
else if (k === 'canonical') {
|
|
818
1359
|
const tag = `<link rel="canonical" href="${esc(v)}">`
|
|
819
1360
|
h = /<link[^>]*rel=["']canonical["'][^>]*>/i.test(h) ? h.replace(/<link[^>]*rel=["']canonical["'][^>]*>/i, () => tag) : h.replace('</head>', () => tag + '</head>')
|
|
1361
|
+
if (seo['og:url'] == null) meta('property', 'og:url', v) // og:url follows the canonical unless the view sets its own
|
|
820
1362
|
} else if (k === 'jsonld') h = h.replace('</head>', () => `<script type="application/ld+json">${jsonInHtml(v)}</script></head>`)
|
|
821
1363
|
else if (/^(og|twitter|article):/.test(k)) meta('property', k, v)
|
|
822
1364
|
else if (typeof v === 'string') meta('name', k, v)
|
|
823
1365
|
}
|
|
824
|
-
|
|
1366
|
+
// `<meta seo schema="Article">` without `jsonld` (§32): one node with this request's values. It replaces the node
|
|
1367
|
+
// the build wrote into the template from the static attributes (same @type), so the page carries exactly one.
|
|
1368
|
+
if (typeof seo.schema === 'string' && seo.jsonld == null && /^[A-Za-z]+$/.test(seo.schema)) {
|
|
1369
|
+
const T = seo.schema
|
|
1370
|
+
const canon = /<link[^>]*rel=["']canonical["'][^>]*href="([^"]*)"/i.exec(h)
|
|
1371
|
+
const node = { '@context': 'https://schema.org', '@type': T }
|
|
1372
|
+
if (seo.title != null) node[/^(Article|BlogPosting|NewsArticle|TechArticle|Report)$/.test(T) ? 'headline' : 'name'] = String(seo.title).slice(0, 110)
|
|
1373
|
+
if (seo.description != null) node.description = String(seo.description)
|
|
1374
|
+
if (canon) node.url = canon[1].replace(/&/g, '&')
|
|
1375
|
+
if (seo['og:image'] != null) node.image = String(seo['og:image']).replace(/^\/(?!\/)/, () => (canon && /^https?:\/\/[^/]+/.exec(node.url) || [''])[0] + '/')
|
|
1376
|
+
if (seo.lastmod != null) node.dateModified = String(seo.lastmod)
|
|
1377
|
+
const own = new RegExp(`<script[^>]*type=["']application/ld\\+json["'][^>]*>\\s*\\{"@context":"https://schema\\.org","@type":"${T}"[\\s\\S]*?</script>`, 'i')
|
|
1378
|
+
const tag = `<script type="application/ld+json">${jsonInHtml(node)}</script>`
|
|
1379
|
+
h = own.test(h) ? h.replace(own, () => tag) : h.replace('</head>', () => tag + '</head>')
|
|
1380
|
+
}
|
|
1381
|
+
if (seo.$head) {
|
|
1382
|
+
for (const [, attr, key] of seo.$head.matchAll(/<meta[^>]*\b(property|name)=["']([^"']+)["'][^>]*>/gi))
|
|
1383
|
+
h = h.replace(new RegExp(`<meta[^>]*\\b${attr}=["']${key.replace(/[.*+?^${}()|[\]\\:]/g, '\\$&')}["'][^>]*>`, 'gi'), '')
|
|
1384
|
+
if (/<link[^>]*rel=["']canonical["']/i.test(seo.$head)) h = h.replace(/<link[^>]*rel=["']canonical["'][^>]*>/gi, '')
|
|
1385
|
+
h = h.replace('</head>', () => seo.$head + '</head>')
|
|
1386
|
+
}
|
|
825
1387
|
return h
|
|
826
1388
|
}
|
|
827
1389
|
// `/_b/<hash>.js` imports inside the chunks resolve to files in dist/ (same hook as the build's prerender)
|
|
@@ -851,6 +1413,7 @@ function real(p) {
|
|
|
851
1413
|
}
|
|
852
1414
|
}
|
|
853
1415
|
if (process.argv[1] && real(process.argv[1]) === real(fileURLToPath(import.meta.url))) {
|
|
1416
|
+
process.env.NODE_ENV = 'production'
|
|
854
1417
|
const s = createServer({ dist: fileURLToPath(new URL('.', import.meta.url)) })
|
|
855
1418
|
const fd = ENV.BRAIN_LISTEN_FD
|
|
856
1419
|
const where = fd ? { fd: Number(fd) } : { port: +(ENV.PORT || 3000), host: ENV.HOST || '127.0.0.1' }
|