@christianirack/brainllm-runtime 0.3.1 → 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/THIRD_PARTY_NOTICES.txt +211 -0
- package/brain-budget.js +142 -0
- package/brain-data.js +13 -3
- package/brain-dev.js +191 -28
- package/brain-err.js +168 -43
- package/brain-form.js +58 -23
- package/brain-hash.js +17 -0
- package/brain-inspect.js +6 -4
- package/brain-interop.js +50 -0
- 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 +144 -78
- package/brain-schema.js +69 -18
- 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 +5 -4
- package/package.json +45 -1
- package/server/server.mjs +440 -41
- 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 = {
|
|
@@ -72,21 +73,351 @@ export class HttpError extends Error {
|
|
|
72
73
|
Object.assign(this, extra)
|
|
73
74
|
}
|
|
74
75
|
}
|
|
75
|
-
//
|
|
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.
|
|
76
90
|
export const redirect = (url, status = 303) => {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
+
}
|
|
80
394
|
}
|
|
81
395
|
|
|
82
396
|
export function createServer(o = {}) {
|
|
83
397
|
const dist = resolve(o.dist || process.cwd())
|
|
84
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)
|
|
85
400
|
const TIMEOUT = +(o.timeout ?? env.BRAIN_TIMEOUT_MS ?? 10000)
|
|
86
401
|
const LIMIT = +(o.rateLimit ?? env.BRAIN_RPC_LIMIT ?? 60) // per IP + function, per minute
|
|
87
402
|
const MAX_BODY = 64 * 1024
|
|
88
403
|
const secret = o.secret ?? env.BRAIN_SECRET ?? ''
|
|
89
|
-
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
|
+
}
|
|
90
421
|
const mf = readManifest(dist)
|
|
91
422
|
// the build's single route table (#187): already in match order, each with its RegExp source (named groups = params)
|
|
92
423
|
const routes = mf.routes.map(r => ({ ...r, rx: r.rx ? new RegExp(r.rx) : exact(r.path) }))
|
|
@@ -112,9 +443,41 @@ export function createServer(o = {}) {
|
|
|
112
443
|
hook(distURL, BASE)
|
|
113
444
|
|
|
114
445
|
// ---------- helpers ----------
|
|
115
|
-
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
|
+
}
|
|
116
479
|
res.writeHead(status, { ...SECURITY, ...(server.draining ? { connection: 'close' } : {}), ...headers })
|
|
117
|
-
res.end(body)
|
|
480
|
+
res.end(res.req.method === 'HEAD' || status === 204 || status === 205 || status === 304 ? undefined : body)
|
|
118
481
|
}
|
|
119
482
|
// HTML: `no-cache` + ETag (strong for files, weak for SSR) → a repeat visit gets 304 with no body (§35); the ETag is
|
|
120
483
|
// taken before the per-response nonce is injected, so it is stable
|
|
@@ -126,20 +489,21 @@ export function createServer(o = {}) {
|
|
|
126
489
|
}
|
|
127
490
|
const nonce = randomBytes(16).toString('base64')
|
|
128
491
|
const body = page.replace(/<script(?![^>]*\bnonce=)/g, `<script nonce="${nonce}"`)
|
|
129
|
-
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 })
|
|
130
493
|
}
|
|
131
|
-
const notFound = (req, res) =>
|
|
132
|
-
|
|
133
|
-
if (
|
|
134
|
-
|
|
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))
|
|
135
500
|
}
|
|
136
|
-
const failPage = (res, status, rid) =>
|
|
137
|
-
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>`)
|
|
138
501
|
// a file inside dist/ (never outside: no `..`, no absolute paths)
|
|
139
502
|
function file(rel) {
|
|
140
503
|
const p = normalize(join(dist, rel))
|
|
141
504
|
if (p !== dist && !p.startsWith(dist + sep)) return null
|
|
142
505
|
try {
|
|
506
|
+
for (let cursor = p; cursor !== dist; cursor = dirname(cursor)) if (lstatSync(cursor).isSymbolicLink()) return null
|
|
143
507
|
return statSync(p).isFile() ? p : null
|
|
144
508
|
} catch {
|
|
145
509
|
return null
|
|
@@ -154,9 +518,10 @@ export function createServer(o = {}) {
|
|
|
154
518
|
|
|
155
519
|
const context = (req, res, url, extra) => {
|
|
156
520
|
const cookies = parseCookies(req.headers.cookie)
|
|
157
|
-
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)
|
|
158
522
|
return {
|
|
159
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)}) } : {}),
|
|
160
525
|
// `prefetch`: the router fetched this page ahead of a click (§19); a `load` with side effects can skip them
|
|
161
526
|
prefetch: req.headers['x-brain-prefetch'] === '1',
|
|
162
527
|
req: { method: req.method, headers: req.headers, ip: ipOf(req) }, ...extra,
|
|
@@ -221,10 +586,11 @@ export function createServer(o = {}) {
|
|
|
221
586
|
} catch {}
|
|
222
587
|
}
|
|
223
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)
|
|
224
590
|
const ctx = context(req, res, url, { params })
|
|
225
591
|
// a page rendered for a signed-in user is never stored (§35): after logout, Back must not show the previous
|
|
226
592
|
// user's page from the HTTP cache or bfcache. Anonymous pages keep `no-cache` + ETag.
|
|
227
|
-
const own = ctx.user != null || ctx.cookies[COOKIE] !== undefined
|
|
593
|
+
const own = ctx.user != null || ctx.cookies[COOKIE] !== undefined || native && /(?:^|;\s*)__Host-brain(?:-access|-refresh)?=/.test(req.headers.cookie || '')
|
|
228
594
|
const keep = h => (own ? { ...h, 'cache-control': 'private, no-store' } : h)
|
|
229
595
|
let props = fixed || {}, extra = {}
|
|
230
596
|
if (!fixed && ((route.guards && route.guards.length) || (route.middleware && route.middleware.length))) {
|
|
@@ -238,7 +604,7 @@ export function createServer(o = {}) {
|
|
|
238
604
|
log('error', 'guard failed', { rid, path: url.pathname, stack: String((e && e.stack) || e) })
|
|
239
605
|
return errorPage(req, res, route, params, url, rid)
|
|
240
606
|
}
|
|
241
|
-
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() })
|
|
242
608
|
if (r.deny !== undefined) return forbidden(req, res, url, rid, r.deny)
|
|
243
609
|
extra = r.headers
|
|
244
610
|
}
|
|
@@ -249,13 +615,13 @@ export function createServer(o = {}) {
|
|
|
249
615
|
if (typeof m.load === 'function') props = (await withTimeout(Promise.resolve(m.load(ctx)), TIMEOUT)) ?? {}
|
|
250
616
|
}
|
|
251
617
|
} catch (e) {
|
|
252
|
-
if (e && e.redirect) return send(res, e.status || 303, '', { location: withBase(e.redirect), ...ctx.session.headers() })
|
|
618
|
+
if (e && e.redirect) return send(res, e.status || 303, '', { location: withBase(redirect(e.redirect, e.status ?? 303).redirect), ...ctx.session.headers() })
|
|
253
619
|
if ((e instanceof HttpError && e.status === 404) || (e && e.notFound === true)) return notFound(req, res) // `throw notFound()` (F11)
|
|
254
|
-
const status = e
|
|
620
|
+
const status = intendedStatus(e) || 500
|
|
255
621
|
log('error', 'load failed', { rid, path: url.pathname, status, stack: String((e && e.stack) || e) })
|
|
256
622
|
return status === 500 ? errorPage(req, res, route, params, url, rid) : failPage(res, status, rid)
|
|
257
623
|
}
|
|
258
|
-
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() })
|
|
259
625
|
if (props && props.notFound === true) return notFound(req, res) // `return notFound()` (F11)
|
|
260
626
|
let body, seo, failed
|
|
261
627
|
try {
|
|
@@ -333,7 +699,7 @@ export function createServer(o = {}) {
|
|
|
333
699
|
props = (await loadP) ?? {}
|
|
334
700
|
if (props.redirect || props.notFound === true) throw props
|
|
335
701
|
} catch (e) {
|
|
336
|
-
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>`)
|
|
337
703
|
// a streamed page already sent its 200 head: the region gets the not-found notice and the page is noindex
|
|
338
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, ''))
|
|
339
705
|
log('error', 'load failed (streamed)', { rid, path: url.pathname, status: 200, stack: String((e && e.stack) || e) }) // headers already out
|
|
@@ -362,14 +728,15 @@ export function createServer(o = {}) {
|
|
|
362
728
|
}
|
|
363
729
|
// deny → views/403.brain (manifest `forbidden`, props.reason) or the static 403.html, always status 403
|
|
364
730
|
function forbidden(req, res, url, rid, reason) {
|
|
731
|
+
if (!wantsHtml(req)) return failPage(res, 403, rid)
|
|
365
732
|
if (mf.forbidden) return ssr(req, res, mf.forbidden, {}, url, rid, null, 403, { reason })
|
|
366
733
|
const f = file('403.html')
|
|
367
734
|
if (f) return html(res, 403, readFileSync(f, 'utf8'))
|
|
368
|
-
|
|
735
|
+
return failPage(res, 403, rid)
|
|
369
736
|
}
|
|
370
737
|
// a failing load or guard → the folder's _error.brain (route `error`, props { message, rid }), else the generic page
|
|
371
738
|
function errorPage(req, res, route, params, url, rid) {
|
|
372
|
-
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 })
|
|
373
740
|
return failPage(res, 500, rid)
|
|
374
741
|
}
|
|
375
742
|
async function render(route, props, forms, at = null) {
|
|
@@ -437,6 +804,7 @@ export function createServer(o = {}) {
|
|
|
437
804
|
} catch {
|
|
438
805
|
return fail(413, 'Body larger than 64 KB')
|
|
439
806
|
}
|
|
807
|
+
if (native) await native.prepare(req, res, rid)
|
|
440
808
|
const ctx = context(req, res, url, { params: {} })
|
|
441
809
|
let f
|
|
442
810
|
try {
|
|
@@ -478,6 +846,7 @@ export function createServer(o = {}) {
|
|
|
478
846
|
}
|
|
479
847
|
const id = new URLSearchParams(raw).get('_brain') || '', modPath = mf.rpc[id.split('/')[0]]
|
|
480
848
|
if (!modPath) return plain(400, 'Unknown form')
|
|
849
|
+
if (native) await native.prepare(req, res, rid)
|
|
481
850
|
const ctx = context(req, res, url, { params })
|
|
482
851
|
let module, rules
|
|
483
852
|
try {
|
|
@@ -573,15 +942,27 @@ export function createServer(o = {}) {
|
|
|
573
942
|
// ---------- static ----------
|
|
574
943
|
// §33 content negotiation: `Accept` preferring text/markdown gets the page's Markdown twin (`/pricing` → pricing.md,
|
|
575
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
|
+
}
|
|
576
954
|
function serveTwin(req, res, url) {
|
|
577
955
|
if (!prefersMarkdown(req.headers.accept)) return false
|
|
578
|
-
const
|
|
956
|
+
const path = publicStaticPath(url.pathname)
|
|
957
|
+
if (path === null) return false
|
|
958
|
+
const md = file(mdPath(path))
|
|
579
959
|
if (!md) return false
|
|
580
960
|
send(res, 200, req.method === 'HEAD' ? '' : readFileSync(md), { 'content-type': MIME['.md'], 'cache-control': 'public, max-age=3600', vary: 'accept' })
|
|
581
961
|
return true
|
|
582
962
|
}
|
|
583
963
|
function serveStatic(req, res, url) {
|
|
584
|
-
|
|
964
|
+
const p = publicStaticPath(url.pathname)
|
|
965
|
+
if (p === null) return false
|
|
585
966
|
const candidates = p.endsWith('/') ? [p + 'index.html'] : [p, p + '.html', p + '/index.html']
|
|
586
967
|
for (const c of candidates) {
|
|
587
968
|
const f = file(c)
|
|
@@ -593,7 +974,7 @@ export function createServer(o = {}) {
|
|
|
593
974
|
const md = file(mdPath(p))
|
|
594
975
|
const page = readFileSync(f, 'utf8')
|
|
595
976
|
// static HTML changes only with a deploy: its ETag is the build signature (content hash without a manifest)
|
|
596
|
-
return html(res, 200, page, md ? { vary: 'accept' } : {}, '"' + (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
|
|
597
978
|
}
|
|
598
979
|
const headers = {
|
|
599
980
|
'content-type': type,
|
|
@@ -601,10 +982,11 @@ export function createServer(o = {}) {
|
|
|
601
982
|
'cache-control': p.startsWith('/_b/') ? 'public, max-age=31536000, immutable' : p === '/sw.js' ? 'no-cache' : 'public, max-age=3600',
|
|
602
983
|
vary: 'accept-encoding',
|
|
603
984
|
}
|
|
604
|
-
const
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
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 })
|
|
608
990
|
return true
|
|
609
991
|
}
|
|
610
992
|
}
|
|
@@ -621,7 +1003,7 @@ export function createServer(o = {}) {
|
|
|
621
1003
|
const t0 = performance.now()
|
|
622
1004
|
res.on('finish', () => {
|
|
623
1005
|
const ms = performance.now() - t0
|
|
624
|
-
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) })
|
|
625
1007
|
})
|
|
626
1008
|
try {
|
|
627
1009
|
const full = new URL(req.url, 'http://' + (req.headers.host || 'localhost'))
|
|
@@ -633,12 +1015,14 @@ export function createServer(o = {}) {
|
|
|
633
1015
|
if (!full.pathname.startsWith(BASE)) return notFound(req, res)
|
|
634
1016
|
url = new URL('/' + full.pathname.slice(BASE.length) + full.search, full) // the app path
|
|
635
1017
|
}
|
|
1018
|
+
req.$brainPath = url.pathname
|
|
1019
|
+
if (native && /^\/(?:api(?:\/|$)|_brain(?:\/|$)|s\/)/.test(url.pathname)) return await native.proxy(req,res,url,rid,send)
|
|
636
1020
|
// redirects.json (manifest `redirects`, brain-seo server_json): exact sources first, then patterns, on the app path;
|
|
637
1021
|
// same-site targets get the base, the query is kept when the target has none
|
|
638
1022
|
const to = redirectOf(url.pathname)
|
|
639
1023
|
if (to && (req.method === 'GET' || req.method === 'HEAD')) {
|
|
640
1024
|
const loc = /^https?:\/\//.test(to[0]) ? to[0] : withBase(to[0]) + (to[0].includes('?') ? '' : url.search)
|
|
641
|
-
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)
|
|
642
1026
|
}
|
|
643
1027
|
const r = /^\/_r\/([\w-]+)\/([\w$]+)$/.exec(url.pathname)
|
|
644
1028
|
if (r) return await rpc(req, res, r[1], r[2], url, rid)
|
|
@@ -660,10 +1044,11 @@ export function createServer(o = {}) {
|
|
|
660
1044
|
notFound(req, res)
|
|
661
1045
|
} catch (e) {
|
|
662
1046
|
log('error', 'unhandled', { rid, path: req.url, stack: String((e && e.stack) || e) })
|
|
663
|
-
if (!res.headersSent) failPage(res, 500, rid)
|
|
1047
|
+
if (!res.headersSent) failPage(res, intendedStatus(e) || 500, rid)
|
|
664
1048
|
else res.end()
|
|
665
1049
|
}
|
|
666
1050
|
})
|
|
1051
|
+
if (native) server.once('close', () => native.close())
|
|
667
1052
|
server.headersTimeout = 15000
|
|
668
1053
|
server.requestTimeout = 30000
|
|
669
1054
|
return server
|
|
@@ -719,14 +1104,14 @@ export function sessionFrom(cookies, secret) {
|
|
|
719
1104
|
// `_middleware.jsbrain`: async (ctx, next) => …; may set ctx.headers (response headers) or stop with redirect/deny.
|
|
720
1105
|
// The same shapes come out of brain-router's redirect()/deny() in the browser.
|
|
721
1106
|
export const deny = (reason = 'Forbidden') => ({ deny: String(reason) })
|
|
722
|
-
const samePath = u =>
|
|
1107
|
+
const samePath = u => { try { safeLocation(u); return true } catch { return false } }
|
|
723
1108
|
function verdict(v) {
|
|
724
1109
|
if (v === false) return { deny: 'Forbidden' }
|
|
725
1110
|
if (typeof v === 'string') v = { redirect: v }
|
|
726
1111
|
if (!v || typeof v !== 'object') return null
|
|
727
1112
|
if (v.redirect !== undefined) {
|
|
728
1113
|
if (!samePath(v.redirect)) throw new HttpError(500, 'redirect to another origin is not allowed: ' + v.redirect) // §31 open redirect
|
|
729
|
-
return
|
|
1114
|
+
return redirect(v.redirect, v.status ?? 303)
|
|
730
1115
|
}
|
|
731
1116
|
return v.deny !== undefined ? { deny: String(v.deny) } : null
|
|
732
1117
|
}
|
|
@@ -811,7 +1196,9 @@ export async function callRpc({ fn, args, ctx, key, limit = 60, timeout = 10000
|
|
|
811
1196
|
} catch (e) {
|
|
812
1197
|
if (e && e.errors && typeof e.errors === 'object') return { status: 422, body: { error: e.message || 'Invalid input', errors: e.errors } }
|
|
813
1198
|
if (e instanceof HttpError && e.status === 504) return { status: 504, body: { error: 'Timeout' }, cause: e }
|
|
814
|
-
|
|
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}
|
|
815
1202
|
return { status: 500, body: { error: 'Internal error' }, cause: e }
|
|
816
1203
|
}
|
|
817
1204
|
}
|
|
@@ -835,10 +1222,13 @@ export async function callForm({ module, raw, ctx, rules, key, limit = 60, timeo
|
|
|
835
1222
|
} catch (e) {
|
|
836
1223
|
if (e && e.errors && typeof e.errors === 'object') return again(e.errors, '')
|
|
837
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}
|
|
838
1228
|
return again({}, 'Something went wrong. Try again.', e)
|
|
839
1229
|
}
|
|
840
1230
|
if (r && r.errors) return again(r.errors, '')
|
|
841
|
-
const to = r &&
|
|
1231
|
+
const to = r && r.redirect != null ? safeLocation(r.redirect) : ctx.url.pathname + ctx.url.search
|
|
842
1232
|
return { status: 303, location: to }
|
|
843
1233
|
}
|
|
844
1234
|
|
|
@@ -852,7 +1242,15 @@ function coerce(F, schema, q, base = '', values = {}, errors = {}) {
|
|
|
852
1242
|
coerce(F, r.s, q, path, values[k], errors)
|
|
853
1243
|
continue
|
|
854
1244
|
}
|
|
855
|
-
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
|
+
}
|
|
856
1254
|
const v = r.list ? q.getAll(path).map(one) : r.t === 'bool' ? one(q.get(path)) : one(q.get(path) ?? '')
|
|
857
1255
|
values[k] = v
|
|
858
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)
|
|
@@ -1015,6 +1413,7 @@ function real(p) {
|
|
|
1015
1413
|
}
|
|
1016
1414
|
}
|
|
1017
1415
|
if (process.argv[1] && real(process.argv[1]) === real(fileURLToPath(import.meta.url))) {
|
|
1416
|
+
process.env.NODE_ENV = 'production'
|
|
1018
1417
|
const s = createServer({ dist: fileURLToPath(new URL('.', import.meta.url)) })
|
|
1019
1418
|
const fd = ENV.BRAIN_LISTEN_FD
|
|
1020
1419
|
const where = fd ? { fd: Number(fd) } : { port: +(ENV.PORT || 3000), host: ENV.HOST || '127.0.0.1' }
|