@christianirack/brainllm-runtime 0.6.0 → 0.7.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/brain-data.js +4 -2
- package/brain-env.js +8 -6
- package/brain-form.js +37 -29
- package/brain-inspect.js +19 -6
- package/brain-island.js +2 -1
- package/brain-lazy.js +2 -0
- package/brain-links.js +37 -0
- package/brain-live.js +21 -5
- package/brain-router.js +223 -178
- package/brain-trace.js +269 -0
- package/package.json +1 -1
- package/server/native-sockets.mjs +13 -10
- package/server/server.mjs +593 -86
- package/shim/dom.js +11 -2
- package/sw/brain-sw.js +17 -9
- package/ui/error-state.brain +1 -1
package/server/server.mjs
CHANGED
|
@@ -18,11 +18,13 @@
|
|
|
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
20
|
import { createServer as http, STATUS_CODES, request as httpRequest, Agent as HttpAgent } from 'node:http'
|
|
21
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
21
22
|
import net from 'node:net'
|
|
22
23
|
import { installNativeSockets } from './native-sockets.mjs'
|
|
24
|
+
import { BlockList, isIP } from 'node:net'
|
|
23
25
|
import { Readable } from 'node:stream'
|
|
24
26
|
import zlib from 'node:zlib'
|
|
25
|
-
import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'
|
|
27
|
+
import { createCipheriv, createDecipheriv, createHash, createHmac, hkdfSync, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'
|
|
26
28
|
import { existsSync, readFileSync, realpathSync, statSync, lstatSync } from 'node:fs'
|
|
27
29
|
import * as Module from 'node:module'
|
|
28
30
|
import { basename, dirname, extname, join, normalize, resolve, sep } from 'node:path'
|
|
@@ -47,7 +49,7 @@ const SECURITY = {
|
|
|
47
49
|
'permissions-policy': 'camera=(), microphone=(), geolocation=(), payment=(), usb=()',
|
|
48
50
|
'cross-origin-opener-policy': 'same-origin',
|
|
49
51
|
// what serves the app and where it lives, for crawlers and LLMs that read headers (no version: that stays in the HTML)
|
|
50
|
-
'x-
|
|
52
|
+
'x-generated-by': 'BRAINLLM (https://chrisifm.github.io/BRAINLLM/; https://github.com/chrisifm/BRAINLLM)',
|
|
51
53
|
}
|
|
52
54
|
// The build's policy (manifest `csp`, without hashes: the same one static hosting uses) plus this response's nonce in
|
|
53
55
|
// script-src; without it, a default that lets api() reach HTTPS APIs of other origins.
|
|
@@ -68,7 +70,7 @@ const b64u = b => Buffer.from(b).toString('base64url')
|
|
|
68
70
|
const tag = s => createHash('sha1').update(s).digest('base64url').slice(0, 22)
|
|
69
71
|
|
|
70
72
|
// Errors a server function may throw on purpose: { errors: { field: msg } } → 422; { status, message } with 4xx → that
|
|
71
|
-
const LIST_OPTIONS = ['search','limit','offset','filter','sort','cursor','after','include','aggregate','count']
|
|
73
|
+
const LIST_OPTIONS = ['search','limit','offset','filter','sort','cursor','after','include','aggregate','count','fields','owner']
|
|
72
74
|
const FILTER_OPS = ['eq','ne','gt','gte','lt','lte','between','in','exists','prefix','iprefix','has']
|
|
73
75
|
export class HttpError extends Error {
|
|
74
76
|
constructor(status, message, extra) {
|
|
@@ -103,6 +105,24 @@ function safeLocation(value) {
|
|
|
103
105
|
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')
|
|
104
106
|
return value
|
|
105
107
|
}
|
|
108
|
+
// Internal links keep the page's language (§37): `route.hrefs` is the table of a page outside the default language, the
|
|
109
|
+
// one brain-links.js applies in the browser; `b` is the base path, added later here.
|
|
110
|
+
function linkTo(route, path) {
|
|
111
|
+
const t = route && route.hrefs
|
|
112
|
+
if (!t || typeof path !== 'string' || path[0] !== '/' || path[1] === '/') return path
|
|
113
|
+
const m = /^[^?#]*/.exec(path)[0]
|
|
114
|
+
const n = t.s[m] || (new RegExp(t.re).test(m) && (m === '/' ? t.p : t.p + m))
|
|
115
|
+
return n ? n + path.slice(m.length) : path
|
|
116
|
+
}
|
|
117
|
+
function localizeLinks(route, html) {
|
|
118
|
+
if (!route.hrefs) return html
|
|
119
|
+
const b = (globalThis.__brainBase || '/').slice(0, -1) // render() set it for this page
|
|
120
|
+
return html.replace(/<(?:a|form)\s[^>]*>/g, tag => /\shreflang=/.test(tag) ? tag : tag.replace(/(\s(?:href|action)=")(\/[^"]*)"/, (all, at, h) => {
|
|
121
|
+
const a = b && (h + '/').startsWith(b + '/') ? h.slice(b.length) || '/' : h
|
|
122
|
+
return at + (a === h ? linkTo(route, a) : b + linkTo(route, a)) + '"'
|
|
123
|
+
}))
|
|
124
|
+
}
|
|
125
|
+
|
|
106
126
|
// `throw redirect('/login')` (or return it) from load/guards; same-site paths only.
|
|
107
127
|
export const redirect = (url, status = 303) => {
|
|
108
128
|
if (!REDIRECT_CODES.has(status)) throw new HttpError(500, 'Invalid redirect status')
|
|
@@ -187,8 +207,64 @@ function googleSignInPage(page) {
|
|
|
187
207
|
}
|
|
188
208
|
|
|
189
209
|
// One configured loopback authority. No user-controlled target, proxy, redirect, auth cache or pending queue.
|
|
190
|
-
|
|
210
|
+
// ctx.backend.files(bucket): the same file calls as job and route handlers (docs/backend/files.md).
|
|
211
|
+
function bucketClient(target, key, bucket) {
|
|
212
|
+
const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i
|
|
213
|
+
if (typeof bucket !== 'string' || !/^[a-z][a-z0-9_]{0,47}$/.test(bucket)) throw new HttpError(400, 'Invalid native bucket name')
|
|
214
|
+
const base = '/_brain/files/' + bucket
|
|
215
|
+
const idPath = id => { if (typeof id !== 'string' || !UUID.test(id)) throw new HttpError(404, 'No file has this id; file ids are UUIDs'); return base + '/' + id }
|
|
216
|
+
const send = async (method, path, body, timeout) => {
|
|
217
|
+
let response
|
|
218
|
+
try { response = await fetch(new URL(path, target), { method, redirect: 'error', signal: AbortSignal.timeout(timeout), headers: { 'x-api-key': key }, body }) } catch { throw new HttpError(503, 'Native backend is unavailable') }
|
|
219
|
+
if (response.ok) return response
|
|
220
|
+
await response.body?.cancel()
|
|
221
|
+
throw new HttpError(response.status >= 400 && response.status < 500 ? response.status : 503, 'Native file request failed')
|
|
222
|
+
}
|
|
223
|
+
const json = async response => { try { return await response.json() } catch { throw new HttpError(502, 'Native file response is invalid') } }
|
|
224
|
+
return Object.freeze({
|
|
225
|
+
async list(options = {}) {
|
|
226
|
+
if (!options || typeof options !== 'object' || Array.isArray(options) || Object.keys(options).some(k => !['limit', 'offset', 'record'].includes(k))) throw new HttpError(400, 'Invalid native file list options')
|
|
227
|
+
const { limit, offset, record } = options
|
|
228
|
+
if ((limit !== undefined && (!Number.isInteger(limit) || limit < 1 || limit > 100)) || (offset !== undefined && (!Number.isInteger(offset) || offset < 0 || offset > 10000)) || (record !== undefined && !UUID.test(String(record)))) throw new HttpError(400, 'Invalid native file list options')
|
|
229
|
+
const q = new URLSearchParams(Object.entries({ limit, offset, record }).filter(([, v]) => v !== undefined))
|
|
230
|
+
return json(await send('GET', base + (q.size ? '?' + q : ''), undefined, 10000))
|
|
231
|
+
},
|
|
232
|
+
async meta(id) { return json(await send('GET', idPath(id) + '/meta', undefined, 10000)) },
|
|
233
|
+
async read(id, { maxBytes = 16 * 1024 * 1024 } = {}) {
|
|
234
|
+
if (!Number.isInteger(maxBytes) || maxBytes < 1 || maxBytes > 64 * 1024 * 1024) throw new HttpError(400, 'Invalid native file read limit')
|
|
235
|
+
const response = await send('GET', idPath(id), undefined, 30000)
|
|
236
|
+
if (Number(response.headers.get('content-length')) > maxBytes) { await response.body?.cancel(); throw new HttpError(413, 'The file exceeds the read limit; raise maxBytes up to 64 MiB') }
|
|
237
|
+
const bytes = Buffer.from(await response.arrayBuffer())
|
|
238
|
+
if (bytes.length > maxBytes) throw new HttpError(413, 'The file exceeds the read limit; raise maxBytes up to 64 MiB')
|
|
239
|
+
return { bytes, contentType: response.headers.get('content-type'), etag: response.headers.get('etag') }
|
|
240
|
+
},
|
|
241
|
+
async upload({ filename, contentType = 'application/octet-stream', data, record } = {}) {
|
|
242
|
+
if (typeof filename !== 'string' || !filename || Buffer.byteLength(filename) > 255 || typeof contentType !== 'string' || (record !== undefined && !UUID.test(String(record)))) throw new HttpError(400, 'Invalid native file upload')
|
|
243
|
+
if (typeof data !== 'string' && !(data instanceof Uint8Array)) throw new HttpError(400, 'File data must be a string, Buffer or Uint8Array')
|
|
244
|
+
const form = new FormData()
|
|
245
|
+
form.set('file', new Blob([data], { type: contentType }), filename)
|
|
246
|
+
return json(await send('POST', base + (record === undefined ? '' : '?record=' + record), form, 60000))
|
|
247
|
+
},
|
|
248
|
+
async remove(id) { await send('DELETE', idPath(id), undefined, 10000); return null },
|
|
249
|
+
})
|
|
250
|
+
}
|
|
251
|
+
export function nativeAuthBridge(raw, refused, serviceKey, report) {
|
|
252
|
+
// How to fix the configuration is for the operator: a visitor of a production app reads a generic message.
|
|
253
|
+
const configuration = (status, detail, rid, shown = 'This service is not available right now') => {
|
|
254
|
+
if (process.env.NODE_ENV !== 'production') return new HttpError(status, detail)
|
|
255
|
+
report?.('error', 'native backend configuration', { rid, fix: detail })
|
|
256
|
+
return new HttpError(status, shown)
|
|
257
|
+
}
|
|
191
258
|
if (!raw) return null
|
|
259
|
+
// The visitor's cookies only travel for a request from the site itself; the caller's log gets the reason (B0742).
|
|
260
|
+
const bridgeOrigin = (incoming, rid, message) => {
|
|
261
|
+
try {
|
|
262
|
+
const origin = new URL(incoming.headers.origin)
|
|
263
|
+
if (origin.origin === incoming.headers.origin && origin.host === (incoming.headers['x-forwarded-host'] || incoming.headers.host)) return origin.origin
|
|
264
|
+
} catch {}
|
|
265
|
+
if (refused) refused('B0742', incoming, rid, 'native')
|
|
266
|
+
throw new HttpError(403, 'B0742: ' + message)
|
|
267
|
+
}
|
|
192
268
|
if (typeof raw !== 'string' || raw.length > 2048) throw Error('BRAIN_BACKEND_URL must be an explicit loopback HTTP origin')
|
|
193
269
|
let target
|
|
194
270
|
try { target = new URL(raw) } catch { throw Error('BRAIN_BACKEND_URL must be an explicit loopback HTTP origin') }
|
|
@@ -218,6 +294,7 @@ export function nativeAuthBridge(raw) {
|
|
|
218
294
|
const call = (incoming, response, path, method, rid, origin) => {
|
|
219
295
|
if (stopped || active >= 8) return Promise.reject(unavailable())
|
|
220
296
|
const cookie = cookies(incoming, method === 'POST')
|
|
297
|
+
const client = forwardedFor(incoming)
|
|
221
298
|
active++
|
|
222
299
|
return new Promise((resolve, reject) => {
|
|
223
300
|
let outgoing, timer, finished = false
|
|
@@ -231,7 +308,7 @@ export function nativeAuthBridge(raw) {
|
|
|
231
308
|
const cancel = () => outgoing?.destroy(unavailable())
|
|
232
309
|
const disconnected = () => { if (!response.writableEnded) cancel() }
|
|
233
310
|
try {
|
|
234
|
-
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 => {
|
|
311
|
+
outgoing = httpRequest(new URL(path, target), { method, agent, headers: { accept: 'application/json', ...(cookie ? { cookie } : {}), 'x-request-id': rid, ...(client ? { 'x-forwarded-for': client } : {}), ...(origin ? { origin } : {}), ...(method === 'POST' ? { 'content-length': '0' } : {}) } }, backend => {
|
|
235
312
|
let bytes = 0; const chunks = []
|
|
236
313
|
if (backend.headers['content-encoding'] || Number(backend.headers['content-length']) > 16384) { backend.destroy(); return finish(true) }
|
|
237
314
|
backend.on('data', chunk => { bytes += chunk.length; if (bytes > 16384) { backend.destroy(); finish(true) } else chunks.push(chunk) })
|
|
@@ -260,27 +337,136 @@ export function nativeAuthBridge(raw) {
|
|
|
260
337
|
} catch { outgoing?.destroy(); finish(true) }
|
|
261
338
|
})
|
|
262
339
|
}
|
|
263
|
-
|
|
264
|
-
|
|
340
|
+
// The access cookie lives 5 minutes and the session far longer: a password JWT session would read as anonymous on the
|
|
341
|
+
// next page. The backend answers concurrent refreshes with the same token inside its grace window, so tabs and
|
|
342
|
+
// prefetches cannot revoke the family. A denied refresh is anonymous; an unavailable backend stays an error.
|
|
343
|
+
const renew = (req, res, rid) => {
|
|
344
|
+
const selected = cookies(req, true)
|
|
345
|
+
let origin
|
|
346
|
+
try { origin = new URL(process.env.PUBLIC_SITE_URL).origin } catch { origin = 'https://' + (req.headers['x-forwarded-host'] || req.headers.host) }
|
|
347
|
+
return new Promise((resolve, reject) => {
|
|
348
|
+
if (stopped || active >= 8) return reject(unavailable())
|
|
349
|
+
active++
|
|
350
|
+
let settled = false
|
|
351
|
+
const done = (error, value) => { if (settled) return; settled = true; active--; clearTimeout(timer); error ? reject(unavailable()) : resolve(value) }
|
|
352
|
+
const outgoing = httpRequest(new URL('/_brain/auth/refresh', target), { method: 'POST', agent, headers: { accept: 'application/json', cookie: selected, origin, 'x-request-id': rid, 'content-length': '0' } }, backend => {
|
|
353
|
+
let bytes = 0; const chunks = []
|
|
354
|
+
backend.on('data', chunk => { bytes += chunk.length; if (bytes > 16384) { backend.destroy(); done(true) } else chunks.push(chunk) })
|
|
355
|
+
backend.on('error', () => done(true))
|
|
356
|
+
backend.on('end', () => {
|
|
357
|
+
if (backend.statusCode === 401 || backend.statusCode === 403) return done(false)
|
|
358
|
+
if (backend.statusCode !== 200) return done(true)
|
|
359
|
+
try {
|
|
360
|
+
const user = safeUser(JSON.parse(Buffer.concat(chunks).toString('utf8'))?.user)
|
|
361
|
+
const set = (backend.headers['set-cookie'] || []).filter(value => /^__Host-brain-(?:access|refresh)=/.test(value) && value.length <= 8192 && !/[\r\n]/.test(value))
|
|
362
|
+
if (!set.some(value => value.startsWith('__Host-brain-access='))) return done(true)
|
|
363
|
+
req.$nativeUser = user
|
|
364
|
+
req.$nativeLoginHeaders = { 'set-cookie': set, 'cache-control': 'no-store' }
|
|
365
|
+
// later backend calls of this same request use the new tokens
|
|
366
|
+
const fresh = new Map(set.map(value => { const pair = value.split(';', 1)[0], at = pair.indexOf('='); return [pair.slice(0, at), pair.slice(at + 1)] }))
|
|
367
|
+
const kept = (req.headers.cookie || '').split(';').map(part => part.trim()).filter(part => part && !fresh.has(part.split('=', 1)[0]))
|
|
368
|
+
req.headers.cookie = [...kept, ...[...fresh].map(([name, value]) => name + '=' + value)].join('; ')
|
|
369
|
+
done(false)
|
|
370
|
+
} catch { done(true) }
|
|
371
|
+
})
|
|
372
|
+
})
|
|
373
|
+
const timer = setTimeout(() => outgoing.destroy(unavailable()), 2000)
|
|
374
|
+
owned.add(outgoing); outgoing.once('close', () => owned.delete(outgoing))
|
|
375
|
+
outgoing.once('error', () => done(true)); outgoing.end()
|
|
376
|
+
})
|
|
377
|
+
}
|
|
378
|
+
let proxyBytes = 0, streams = 0
|
|
379
|
+
// A file transfer lasts as long as the client needs, so streams have their own sockets and budget and never hold API slots.
|
|
380
|
+
const streamAgent = new HttpAgent({ keepAlive: false, maxSockets: 32, maxTotalSockets: 32 })
|
|
381
|
+
const REQUEST_HEADERS = ['origin','authorization','x-api-key','content-type','content-length','x-brain-rpc','accept','if-match','if-none-match','x-brain-webhook-id','x-brain-webhook-timestamp','x-brain-webhook-signature','user-agent','dnt','sec-gpc','cf-ipcountry']
|
|
382
|
+
const FILE_HEADERS = ['range','if-range','if-modified-since','if-unmodified-since']
|
|
383
|
+
const RESPONSE_HEADERS = ['content-type','content-disposition','allow','retry-after','www-authenticate','location','etag','last-modified','clear-site-data','cache-control']
|
|
384
|
+
const FILE_RESPONSE_HEADERS = ['content-length','content-range','accept-ranges','vary','content-security-policy','x-content-type-options','cross-origin-resource-policy','referrer-policy']
|
|
385
|
+
// The identity the backend resolves through trustProxy: the hops already received plus the peer this gateway observed.
|
|
386
|
+
// Hops to the left of an untrusted peer are ignored by the backend, so a client header can never choose the identity.
|
|
387
|
+
const forwardedFor = req => {
|
|
388
|
+
if (req.$brainForwardedFor) return req.$brainForwardedFor
|
|
389
|
+
const peer = req.socket?.remoteAddress
|
|
390
|
+
if (!peer) return undefined
|
|
391
|
+
const prior = req.headers['x-forwarded-for']
|
|
392
|
+
return typeof prior === 'string' && prior.length <= 4096 ? prior + ', ' + peer : peer
|
|
393
|
+
}
|
|
394
|
+
const requestHeaders = (req, rid, extra = []) => {
|
|
395
|
+
const headers = { 'x-request-id':rid }
|
|
396
|
+
for (const key of [...REQUEST_HEADERS, ...extra]) {
|
|
397
|
+
const value = req.headers[key]
|
|
398
|
+
if (typeof value === 'string' && value.length <= 8192) headers[key] = value
|
|
399
|
+
}
|
|
400
|
+
const selected = []
|
|
401
|
+
for (const part of String(req.headers.cookie || '').split(';')) {
|
|
402
|
+
const name = part.trim().split('=')[0]
|
|
403
|
+
if (['__Host-brain','__Host-brain-access','__Host-brain-refresh','__Host-brain-google'].includes(name)) selected.push(part.trim())
|
|
404
|
+
}
|
|
405
|
+
if (selected.length) headers.cookie = selected.join('; ')
|
|
406
|
+
const client = forwardedFor(req)
|
|
407
|
+
if (client) headers['x-forwarded-for'] = client
|
|
408
|
+
return headers
|
|
409
|
+
}
|
|
410
|
+
const nativeCookies = values => (values || []).filter(value => /^(?:__Host-brain(?:-access|-refresh|-google)?)=/.test(value) && value.length <= 8192).slice(0,8)
|
|
411
|
+
// /_brain/files: both directions stream with backpressure. The bucket enforces its own maxBytes and access checks.
|
|
412
|
+
function fileStream(req, res, url, rid) {
|
|
413
|
+
if (stopped || streams >= 32) throw new HttpError(503, 'Backend stream capacity is unavailable')
|
|
414
|
+
if (!['GET','HEAD','POST','DELETE'].includes(req.method)) throw new HttpError(405, 'Method not allowed')
|
|
415
|
+
const ceiling = 2 * 1024 * 1024 * 1024 + 1024 * 1024
|
|
416
|
+
if (Number(req.headers['content-length'] || 0) > ceiling) throw new HttpError(413, 'Request body is too large')
|
|
417
|
+
clearTimeout(req.$brainBodyGuard)
|
|
418
|
+
streams++
|
|
419
|
+
return new Promise((resolve, reject) => {
|
|
420
|
+
let outgoing, deadline, done = false, sent = 0
|
|
421
|
+
const finish = failure => {
|
|
422
|
+
if (done) return
|
|
423
|
+
done = true; streams--; clearTimeout(deadline); owned.delete(outgoing)
|
|
424
|
+
req.unpipe(outgoing); req.removeListener('data', count); res.removeListener('close', closed)
|
|
425
|
+
if (failure && !res.headersSent && !res.destroyed) return reject(failure)
|
|
426
|
+
if (failure && !res.destroyed) res.destroy()
|
|
427
|
+
outgoing?.destroy(); resolve()
|
|
428
|
+
}
|
|
429
|
+
const closed = () => finish()
|
|
430
|
+
const count = chunk => { sent += chunk.length; if (sent > ceiling) finish(new HttpError(413, 'Request body is too large')) }
|
|
431
|
+
const unavailable = () => new HttpError(503, 'Backend request unavailable')
|
|
432
|
+
try {
|
|
433
|
+
outgoing = httpRequest(new URL(url.pathname + url.search, target), { agent:streamAgent, method:req.method, headers:requestHeaders(req, rid, FILE_HEADERS) }, backend => {
|
|
434
|
+
const safe = { 'cache-control':'no-store' }
|
|
435
|
+
for (const key of [...RESPONSE_HEADERS, ...FILE_RESPONSE_HEADERS]) {
|
|
436
|
+
const value = backend.headers[key]
|
|
437
|
+
if (typeof value === 'string' && value.length <= 8192) safe[key] = value
|
|
438
|
+
}
|
|
439
|
+
const cookies = nativeCookies(backend.headers['set-cookie'])
|
|
440
|
+
if (cookies.length) safe['set-cookie'] = cookies
|
|
441
|
+
res.writeHead(backend.statusCode || 502, safe)
|
|
442
|
+
backend.once('error', () => finish(unavailable()))
|
|
443
|
+
backend.once('aborted', () => finish(unavailable()))
|
|
444
|
+
backend.once('end', () => { res.end(() => finish()) })
|
|
445
|
+
backend.pipe(res)
|
|
446
|
+
})
|
|
447
|
+
owned.add(outgoing)
|
|
448
|
+
// No progress in either direction (a stalled backend, or a client that stopped reading and paused the socket).
|
|
449
|
+
outgoing.setTimeout(60000, () => finish(new HttpError(504, 'Backend request timed out')))
|
|
450
|
+
// The backend's own upload deadline is 300 s; nothing else needs a total limit after the headers.
|
|
451
|
+
if (req.method === 'POST') deadline = setTimeout(() => finish(new HttpError(504, 'Backend request timed out')), 310000)
|
|
452
|
+
outgoing.once('error', () => finish(unavailable()))
|
|
453
|
+
res.once('close', closed)
|
|
454
|
+
req.on('data', count)
|
|
455
|
+
req.pipe(outgoing)
|
|
456
|
+
} catch { finish(unavailable()) }
|
|
457
|
+
})
|
|
458
|
+
}
|
|
459
|
+
function proxy(req, res, url, rid, reply, responseLimit = 20 * 1024 * 1024, declared) {
|
|
265
460
|
if (stopped || active >= 8) throw new HttpError(503, 'Backend request capacity is unavailable')
|
|
266
461
|
if (!['GET','HEAD','POST','PUT','PATCH','DELETE','OPTIONS'].includes(req.method)) throw new HttpError(405, 'Method not allowed')
|
|
267
|
-
const
|
|
462
|
+
const post = req.method === 'POST'
|
|
463
|
+
const limit = url.pathname === '/_brain/uploads' && post ? 5 * 1024 * 1024 + 65536 : declared || post && /^\/api\/[^/]+\/batch$/.test(url.pathname) ? 1024 * 1024 : 65536
|
|
268
464
|
if (Number(req.headers['content-length'] || 0) > limit) throw new HttpError(413, 'Request body is too large')
|
|
269
465
|
active++
|
|
270
466
|
return new Promise((resolve, reject) => {
|
|
271
467
|
let outgoing, timer, done = false, bytes = 0, requestBytes = 0, failure
|
|
272
468
|
const chunks = []
|
|
273
|
-
const headers =
|
|
274
|
-
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','user-agent','dnt','sec-gpc','cf-ipcountry']) {
|
|
275
|
-
const value = req.headers[key]
|
|
276
|
-
if (typeof value === 'string' && value.length <= 8192) headers[key] = value
|
|
277
|
-
}
|
|
278
|
-
const selected = []
|
|
279
|
-
for (const part of String(req.headers.cookie || '').split(';')) {
|
|
280
|
-
const name = part.trim().split('=')[0]
|
|
281
|
-
if (['__Host-brain','__Host-brain-access','__Host-brain-refresh','__Host-brain-google'].includes(name)) selected.push(part.trim())
|
|
282
|
-
}
|
|
283
|
-
if (selected.length) headers.cookie = selected.join('; ')
|
|
469
|
+
const headers = requestHeaders(req, rid, declared?.headers)
|
|
284
470
|
const finish = (error, backend) => {
|
|
285
471
|
if (done) return
|
|
286
472
|
done = true; clearTimeout(timer); active--; proxyBytes -= bytes; owned.delete(outgoing)
|
|
@@ -288,12 +474,16 @@ export function nativeAuthBridge(raw) {
|
|
|
288
474
|
res.removeListener('close',cancel)
|
|
289
475
|
if (error) { if (!res.destroyed) reject(failure || new HttpError(503,'Backend request unavailable')); else resolve(); return }
|
|
290
476
|
const safe = { 'cache-control':'no-store' }
|
|
291
|
-
for (const key of
|
|
477
|
+
for (const key of RESPONSE_HEADERS) {
|
|
292
478
|
const value = backend.headers[key]
|
|
293
479
|
if (typeof value === 'string' && value.length <= 8192) safe[key] = value
|
|
294
480
|
}
|
|
295
|
-
|
|
296
|
-
if (
|
|
481
|
+
// the response headers a route contract allows its handler to set (docs/backend/routes.md)
|
|
482
|
+
if (declared) for (const [key, value] of Object.entries(backend.headers)) {
|
|
483
|
+
if ((key === 'link' || key === 'content-language' || key.startsWith('x-') && key !== 'x-request-id') && typeof value === 'string' && value.length <= 8192) safe[key] = value
|
|
484
|
+
}
|
|
485
|
+
const cookies = nativeCookies(backend.headers['set-cookie'])
|
|
486
|
+
if (cookies.length) safe['set-cookie'] = cookies
|
|
297
487
|
try { reply(res,backend.statusCode || 502,req.method === 'HEAD' ? '' : Buffer.concat(chunks,bytes),safe); resolve() } catch (e) { reject(e) }
|
|
298
488
|
}
|
|
299
489
|
const cancel = () => { outgoing?.destroy(); finish(true) }
|
|
@@ -313,17 +503,17 @@ export function nativeAuthBridge(raw) {
|
|
|
313
503
|
backend.once('end',()=>finish(false,backend)); backend.once('error',()=>finish(true)); backend.once('aborted',()=>finish(true))
|
|
314
504
|
})
|
|
315
505
|
owned.add(outgoing)
|
|
316
|
-
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)
|
|
506
|
+
timer = setTimeout(()=>{failure = new HttpError(504,'Backend request timed out');outgoing.destroy();finish(true)},declared || url.pathname === '/_brain/uploads' || url.pathname.startsWith('/_brain/uploads/') ? 35000 : 10000)
|
|
317
507
|
outgoing.once('error',()=>finish(true)); req.once('aborted',cancel); res.once('close',cancel)
|
|
318
508
|
req.on('data',data); req.once('end',end)
|
|
319
509
|
if (req.readableEnded) end()
|
|
320
510
|
} catch { outgoing?.destroy(); finish(true) }
|
|
321
511
|
})
|
|
322
512
|
}
|
|
323
|
-
const resource = (incoming, response, rid, name) => {
|
|
513
|
+
const resource = (incoming, response, rid, name, serviceKey) => {
|
|
324
514
|
if (typeof name !== 'string' || !/^[a-z][a-z0-9_]{0,47}$/.test(name)) throw new HttpError(400, 'Invalid native resource name')
|
|
325
515
|
const idPath = id => {
|
|
326
|
-
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(
|
|
516
|
+
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(404, 'No record has this id; record ids are UUIDs')
|
|
327
517
|
return '/api/' + name + '/' + id
|
|
328
518
|
}
|
|
329
519
|
const request = async (method, path, body, notFound) => {
|
|
@@ -337,11 +527,11 @@ export function nativeAuthBridge(raw) {
|
|
|
337
527
|
}
|
|
338
528
|
const req = Readable.from(bytes.length ? [bytes] : [])
|
|
339
529
|
req.method = method
|
|
340
|
-
req
|
|
530
|
+
req.$brainForwardedFor = forwardedFor(incoming)
|
|
531
|
+
// A service call acts as the key's owner, never as the visitor: it sends no session.
|
|
532
|
+
req.headers = { ...(serviceKey ? { authorization: 'Bearer ' + serviceKey } : { cookie: cookies(incoming, false) }), accept: 'application/json', 'content-type':'application/json', 'content-length':String(bytes.length) }
|
|
341
533
|
if (method !== 'GET') {
|
|
342
|
-
|
|
343
|
-
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') }
|
|
344
|
-
req.headers.origin = origin.origin
|
|
534
|
+
req.headers.origin = bridgeOrigin(incoming, rid, 'Native writes require a same-origin server request')
|
|
345
535
|
}
|
|
346
536
|
let value
|
|
347
537
|
try {
|
|
@@ -354,7 +544,14 @@ export function nativeAuthBridge(raw) {
|
|
|
354
544
|
throw new HttpError(422, 'Invalid input', { errors })
|
|
355
545
|
}
|
|
356
546
|
if (status === 404 && notFound) { value = notFound(); return }
|
|
357
|
-
if (status < 200 || status >= 300)
|
|
547
|
+
if (status < 200 || status >= 300) {
|
|
548
|
+
// the message stays generic; the backend's own code and fix (never driver text) let server code branch on it
|
|
549
|
+
let answer
|
|
550
|
+
try { answer = JSON.parse(data.toString('utf8'))?.error } catch {}
|
|
551
|
+
const code = typeof answer?.code === 'string' && /^[A-Z][A-Z0-9_]{0,63}$/.test(answer.code) ? answer.code : undefined
|
|
552
|
+
const fix = code && typeof answer.fix === 'string' ? answer.fix.slice(0, 512) : undefined
|
|
553
|
+
throw new HttpError(status >= 400 && status < 500 ? status : 503, 'Native resource request failed', { code, fix })
|
|
554
|
+
}
|
|
358
555
|
if (status === 204) { value = null; return }
|
|
359
556
|
if (!String(headers['content-type'] || '').startsWith('application/json')) throw new HttpError(502, 'Native resource response must be JSON')
|
|
360
557
|
try { value = JSON.parse(data.toString('utf8')) } catch { throw new HttpError(502, 'Native resource response is invalid') }
|
|
@@ -365,7 +562,8 @@ export function nativeAuthBridge(raw) {
|
|
|
365
562
|
}
|
|
366
563
|
const names = (value, label) => {
|
|
367
564
|
const list = typeof value === 'string' ? value.split(',') : value
|
|
368
|
-
|
|
565
|
+
const shape = label === 'include' ? /^[a-z][a-z0-9_]{0,47}(?:\.[a-z][a-z0-9_]{0,47}){0,2}$/ : /^[a-z][a-z0-9_]{0,47}$/
|
|
566
|
+
if (!Array.isArray(list) || list.length > 16 || list.some(item => typeof item !== 'string' || !shape.test(item))) throw new HttpError(400, `Invalid native ${label} names`)
|
|
369
567
|
return list.join(',')
|
|
370
568
|
}
|
|
371
569
|
return Object.freeze({
|
|
@@ -373,7 +571,7 @@ export function nativeAuthBridge(raw) {
|
|
|
373
571
|
// filters and sorts and answers 422 {errors}; this only rejects shapes the query string cannot express
|
|
374
572
|
list(options = {}) {
|
|
375
573
|
if (!options || typeof options !== 'object' || Array.isArray(options) || Object.keys(options).some(key => !LIST_OPTIONS.includes(key))) throw new HttpError(400, 'Invalid native list options')
|
|
376
|
-
const {search, limit, offset, filter, sort, cursor, after, include, aggregate, count} = options
|
|
574
|
+
const {search, limit, offset, filter, sort, cursor, after, include, aggregate, count, fields} = options
|
|
377
575
|
if ((search !== undefined && (typeof search !== 'string' || Buffer.byteLength(search) > 256)) || (limit !== undefined && (!Number.isInteger(limit) || limit < 1 || limit > 100)) || (offset !== undefined && (!Number.isInteger(offset) || offset < 0 || offset > 10000))) throw new HttpError(400, 'Invalid native list pagination or search')
|
|
378
576
|
const q = new URLSearchParams()
|
|
379
577
|
if (filter !== undefined) {
|
|
@@ -415,7 +613,11 @@ export function nativeAuthBridge(raw) {
|
|
|
415
613
|
if (typeof value !== 'string' || !/^[A-Za-z0-9_-]{1,2048}$/.test(value)) throw new HttpError(400, 'Invalid native list cursor')
|
|
416
614
|
q.set(key, value)
|
|
417
615
|
}
|
|
418
|
-
for (const [key, value] of [['include', include], ['aggregate', aggregate], ['count', count]]) if (value !== undefined) q.set(key, names(value, key))
|
|
616
|
+
for (const [key, value] of [['include', include], ['aggregate', aggregate], ['count', count], ['fields', fields]]) if (value !== undefined) q.set(key, names(value, key))
|
|
617
|
+
if (options.owner !== undefined) {
|
|
618
|
+
if (options.owner !== 'me') throw new HttpError(400, "Invalid native list owner; use 'me'")
|
|
619
|
+
q.set('owner', 'me')
|
|
620
|
+
}
|
|
419
621
|
const query = q.toString()
|
|
420
622
|
return request('GET', '/api/' + name + (query ? '?' + query : ''))
|
|
421
623
|
},
|
|
@@ -431,27 +633,30 @@ export function nativeAuthBridge(raw) {
|
|
|
431
633
|
if (values && typeof values === 'object' && !Array.isArray(values) && !Object.keys(values).length) throw new HttpError(422, 'Send at least one field', { errors: { body: 'Send at least one field to change' } })
|
|
432
634
|
return request('PATCH', idPath(id), values)
|
|
433
635
|
},
|
|
636
|
+
// a declared atomic action or state transition (docs/backend/atomic-updates.md); the key still needs `<resource>:write`
|
|
637
|
+
action: (id, action, input = {}) => request('POST', idPath(id) + '/actions/' + encodeURIComponent(action), input),
|
|
638
|
+
transition: (id, transition, input = {}) => request('POST', idPath(id) + '/transitions/' + encodeURIComponent(transition), input),
|
|
434
639
|
remove: id => request('DELETE', idPath(id)),
|
|
435
640
|
// an earlier slug of a readable record → its current slug; live, unknown or unreadable slugs → null
|
|
436
641
|
slugRedirect(slug) {
|
|
437
|
-
if (typeof slug !== 'string' || !/^[A-Za-z0-9._~-]{1,512}$/.test(slug))
|
|
642
|
+
if (typeof slug !== 'string' || !/^[A-Za-z0-9._~-]{1,512}$/.test(slug)) return Promise.resolve(null)
|
|
438
643
|
return request('GET', '/api/' + name + '/redirects/' + slug, undefined, () => null).then(value => typeof value?.slug === 'string' ? value.slug : null)
|
|
439
644
|
},
|
|
440
645
|
})
|
|
441
646
|
}
|
|
442
647
|
async function login(incoming,response,rid,credentials) {
|
|
443
|
-
if (incoming.method !== 'POST' || response.headersSent || response.writableEnded || response.destroyed) throw new HttpError(403,'Sign in through a same-origin POST server function')
|
|
648
|
+
if (incoming.method !== 'POST' || response.headersSent || response.writableEnded || response.destroyed) throw new HttpError(403,'B0742: Sign in through a same-origin POST server function')
|
|
444
649
|
if (incoming.$nativeLoginStarted || incoming.$nativeLogout) throw new HttpError(409,'Use one authentication change per server request')
|
|
445
650
|
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')
|
|
446
|
-
|
|
447
|
-
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')}
|
|
651
|
+
const origin=bridgeOrigin(incoming,rid,'Sign in through a same-origin POST server function')
|
|
448
652
|
incoming.$nativeLoginStarted=true
|
|
449
653
|
const bytes=Buffer.from(JSON.stringify({email:credentials.email,password:credentials.password}))
|
|
450
|
-
const request=Readable.from([bytes]);request.method='POST';request.headers={origin
|
|
654
|
+
const request=Readable.from([bytes]);request.method='POST';request.$brainForwardedFor=forwardedFor(incoming);request.headers={origin,accept:'application/json','content-type':'application/json','content-length':String(bytes.length)}
|
|
451
655
|
let user
|
|
452
656
|
try {
|
|
453
657
|
await proxy(request,response,new URL('/_brain/auth/login',target),rid,(_res,status,data,headers)=>{
|
|
454
|
-
if(status
|
|
658
|
+
if(status===403)throw new HttpError(403,"B0740: the backend refused this site's origin; fix: add the site origin to backend.origins in brain.json. The backend log has the seen and allowed origins. Run brain explain B0740")
|
|
659
|
+
if(status!==200)throw new HttpError(status===400||status===401||status===422?401:status===429?429:503,status===429?'Too many attempts. Wait before trying again.':status===400||status===401||status===422?'Wrong email or password':'Sign-in is temporarily unavailable')
|
|
455
660
|
let payload
|
|
456
661
|
try {payload=JSON.parse(data.toString('utf8'))}catch{throw unavailable()}
|
|
457
662
|
user=safeUser(payload?.user)
|
|
@@ -464,11 +669,88 @@ export function nativeAuthBridge(raw) {
|
|
|
464
669
|
} finally {request.destroy();bytes.fill(0)}
|
|
465
670
|
return {user}
|
|
466
671
|
}
|
|
672
|
+
// Creates the account and leaves the session alone: `await ctx.backend.signup(v); return ctx.backend.login(v)` signs in
|
|
673
|
+
// too. A refused address is the email field's error: the same 422 for an invalid one and for one already taken, and no
|
|
674
|
+
// more than any signup form reveals by succeeding.
|
|
675
|
+
async function signup(incoming,response,rid,credentials) {
|
|
676
|
+
if (incoming.method !== 'POST' || response.headersSent || response.writableEnded || response.destroyed) throw new HttpError(403,'B0742: Sign up through a same-origin POST server function')
|
|
677
|
+
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')
|
|
678
|
+
if (Buffer.byteLength(credentials.password) < 12 || Buffer.byteLength(credentials.password) > 1024) throw new HttpError(422,'Use a password of 12 to 1024 bytes',{errors:{password:'Use a password of 12 to 1024 bytes'}})
|
|
679
|
+
const origin=bridgeOrigin(incoming,rid,'Sign up through a same-origin POST server function')
|
|
680
|
+
const bytes=Buffer.from(JSON.stringify({email:credentials.email,password:credentials.password}))
|
|
681
|
+
const request=Readable.from([bytes]);request.method='POST';request.$brainForwardedFor=forwardedFor(incoming);request.headers={origin,accept:'application/json','content-type':'application/json','content-length':String(bytes.length)}
|
|
682
|
+
let user
|
|
683
|
+
try {
|
|
684
|
+
await proxy(request,response,new URL('/_brain/auth/signup',target),rid,(_res,status,data)=>{
|
|
685
|
+
if(status===403)throw new HttpError(403,"B0740: the backend refused this site's origin; fix: add the site origin to backend.origins in brain.json. Run brain explain B0740")
|
|
686
|
+
if(status===422)throw new HttpError(422,'This email cannot be used',{errors:{email:'This email cannot be used'}})
|
|
687
|
+
if(status===429)throw new HttpError(429,'Too many attempts. Wait before trying again.')
|
|
688
|
+
if(status!==200&&status!==201)throw unavailable()
|
|
689
|
+
try {user=safeUser(JSON.parse(data.toString('utf8'))?.user)}catch{throw unavailable()}
|
|
690
|
+
},16384)
|
|
691
|
+
} finally {request.destroy();bytes.fill(0)}
|
|
692
|
+
return {user}
|
|
693
|
+
}
|
|
694
|
+
// Google needs its nonce bound to the browser that asked for it, so both steps relay the HttpOnly nonce cookie. The page
|
|
695
|
+
// only ever sees the public client id and nonce it hands to Google.
|
|
696
|
+
const sessionCookie = /^(?:__Host-brain|__Host-brain-access|__Host-brain-refresh|__Host-brain-google)=/
|
|
697
|
+
const strictCookie = value => /(?:^|;)\s*HttpOnly(?:;|$)/i.test(value) && /(?:^|;)\s*Secure(?:;|$)/i.test(value) && /(?:^|;)\s*Path=\/(?:;|$)/i.test(value) && /(?:^|;)\s*SameSite=Lax(?:;|$)/i.test(value)
|
|
698
|
+
async function googleStep(incoming,response,rid,path,payload,cookie,read) {
|
|
699
|
+
if (incoming.method !== 'POST' || response.headersSent || response.writableEnded || response.destroyed) throw new HttpError(403,'Sign in through a same-origin POST server function')
|
|
700
|
+
if (incoming.$nativeLoginStarted || incoming.$nativeLogout) throw new HttpError(409,'Use one authentication change per server request')
|
|
701
|
+
let origin
|
|
702
|
+
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')}
|
|
703
|
+
incoming.$nativeLoginStarted=true
|
|
704
|
+
const bytes=Buffer.from(JSON.stringify(payload))
|
|
705
|
+
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),...(cookie?{cookie}:{})}
|
|
706
|
+
let result
|
|
707
|
+
try {
|
|
708
|
+
await proxy(request,response,new URL(path,target),rid,(_res,status,data,headers)=>{
|
|
709
|
+
if(status!==200)throw new HttpError(status===429?429:status<500?401:503,status===429?'Too many attempts. Wait before trying again.':status<500?'Google sign-in was not accepted':'Sign-in is temporarily unavailable')
|
|
710
|
+
let parsed
|
|
711
|
+
try {parsed=JSON.parse(data.toString('utf8'))}catch{throw unavailable()}
|
|
712
|
+
const selected=headers['set-cookie']||[]
|
|
713
|
+
if(new Set(selected.map(value=>value.split('=',1)[0])).size!==selected.length||!selected.length||selected.length>4||selected.some(value=>!sessionCookie.test(value)||!strictCookie(value)))throw unavailable()
|
|
714
|
+
result=read(parsed)
|
|
715
|
+
if(response.writableEnded||response.destroyed)throw unavailable()
|
|
716
|
+
incoming.$nativeLoginHeaders={'set-cookie':selected,'cache-control':'no-store'}
|
|
717
|
+
},16384)
|
|
718
|
+
} finally {request.destroy();bytes.fill(0)}
|
|
719
|
+
return result
|
|
720
|
+
}
|
|
721
|
+
const googleChallenge = (incoming,response,rid) => googleStep(incoming,response,rid,'/_brain/auth/google/challenge',{},null,payload=>{
|
|
722
|
+
if(typeof payload?.client_id!=='string'||typeof payload?.nonce!=='string'||!/^[a-f0-9]{64}$/.test(payload.nonce)||!Number.isInteger(payload?.expires_in))throw unavailable()
|
|
723
|
+
return {client_id:payload.client_id,nonce:payload.nonce,expires_in:payload.expires_in}
|
|
724
|
+
})
|
|
725
|
+
const googleLogin = (incoming,response,rid,values) => {
|
|
726
|
+
if(!values||typeof values!=='object'||Array.isArray(values)||Object.keys(values).some(key=>key!=='credential')||typeof values.credential!=='string'||!values.credential||Buffer.byteLength(values.credential)>16384)throw new HttpError(422,'Send the credential Google returned')
|
|
727
|
+
const nonce=String(incoming.headers.cookie||'').split(';').map(part=>part.trim()).find(part=>part.startsWith('__Host-brain-google='))?.slice('__Host-brain-google='.length)
|
|
728
|
+
if(!nonce||!/^[a-f0-9]{64}$/.test(nonce))throw new HttpError(401,'Google sign-in was not accepted')
|
|
729
|
+
return googleStep(incoming,response,rid,'/_brain/auth/google',{credential:values.credential},'__Host-brain-google='+nonce,payload=>{
|
|
730
|
+
const user=safeUser(payload?.user)
|
|
731
|
+
return {user}
|
|
732
|
+
})
|
|
733
|
+
}
|
|
467
734
|
// ctx.backend for one server request (load, actions, RPC, dev sidecar): the same object everywhere
|
|
468
735
|
const backend = (incoming, response, rid) => Object.freeze({
|
|
469
736
|
resource: name => resource(incoming, response, rid, name),
|
|
737
|
+
// A server-only resource (`api: false`) from trusted server code, with BRAIN_SERVICE_API_KEY
|
|
738
|
+
service: name => {
|
|
739
|
+
if (!serviceKey) throw configuration(503, 'ctx.backend.service needs BRAIN_SERVICE_API_KEY: create an API key with a scope naming the resource')
|
|
740
|
+
return resource(incoming, response, rid, name, serviceKey)
|
|
741
|
+
},
|
|
742
|
+
// A declared file bucket from trusted server code, with BRAIN_SERVICE_API_KEY and its files.<bucket> scope
|
|
743
|
+
files: bucket => {
|
|
744
|
+
if (!serviceKey) throw configuration(503, 'ctx.backend.files needs BRAIN_SERVICE_API_KEY: create an API key with a files.<bucket> scope')
|
|
745
|
+
return bucketClient(target, serviceKey, bucket)
|
|
746
|
+
},
|
|
470
747
|
slugRedirect: (name, slug) => resource(incoming, response, rid, name).slugRedirect(slug),
|
|
471
748
|
login: credentials => login(incoming, response, rid, credentials),
|
|
749
|
+
signup: credentials => signup(incoming, response, rid, credentials),
|
|
750
|
+
google: Object.freeze({
|
|
751
|
+
challenge: () => googleChallenge(incoming, response, rid),
|
|
752
|
+
login: values => googleLogin(incoming, response, rid, values),
|
|
753
|
+
}),
|
|
472
754
|
})
|
|
473
755
|
return {
|
|
474
756
|
proxy,
|
|
@@ -476,21 +758,23 @@ export function nativeAuthBridge(raw) {
|
|
|
476
758
|
backend,
|
|
477
759
|
login,
|
|
478
760
|
async prepare(req, res, rid) {
|
|
479
|
-
if (!cookies(req,
|
|
480
|
-
if (!req.$nativeAuth) req.$nativeAuth =
|
|
761
|
+
if (!cookies(req, true)) { req.$nativeUser = null; req.$nativeAuth ||= Promise.resolve(); return }
|
|
762
|
+
if (!req.$nativeAuth) req.$nativeAuth = (async () => {
|
|
763
|
+
req.$nativeUser = cookies(req, false) ? (await call(req, res, '/_brain/auth/me', 'GET', rid)).user : null
|
|
764
|
+
if (req.$nativeUser === null && /(?:^|;\s*)__Host-brain-refresh=/.test(req.headers.cookie || '')) await renew(req, res, rid)
|
|
765
|
+
})()
|
|
481
766
|
await req.$nativeAuth
|
|
482
767
|
},
|
|
483
768
|
session(req, res, rid) {
|
|
484
769
|
return {
|
|
485
770
|
user: req.$nativeUser,
|
|
486
|
-
set() { throw
|
|
771
|
+
set() { throw configuration(409, 'With BRAIN_BACKEND_URL the backend owns the session: sign in with ctx.backend.login(credentials), or set BRAIN_BACKEND_SESSION=app so this app keeps its own signed session, and keep a server-only value with ctx.session.setSecret(value)', rid, 'Sign-in is not available right now') },
|
|
487
772
|
clear() {
|
|
488
773
|
if (req.$nativeLoginStarted) throw new HttpError(409,'Use a separate server request to sign out after signing in')
|
|
489
|
-
if (req.method !== 'POST' || res.headersSent) throw new HttpError(403, 'Sign out through a same-origin POST request')
|
|
490
|
-
|
|
491
|
-
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') }
|
|
774
|
+
if (req.method !== 'POST' || res.headersSent) throw new HttpError(403, 'B0742: Sign out through a same-origin POST request')
|
|
775
|
+
const origin = bridgeOrigin(req, rid, 'Sign out through a same-origin POST request')
|
|
492
776
|
if (!req.$nativeLogout) {
|
|
493
|
-
req.$nativeLogout = call(req, res, '/_brain/auth/logout', 'POST', rid, origin
|
|
777
|
+
req.$nativeLogout = call(req, res, '/_brain/auth/logout', 'POST', rid, origin).then(headers => { req.$nativeLogoutDone = true; req.$nativeLogoutHeaders = headers }, error => { req.$nativeLogoutDone = true; req.$nativeLogoutError = true; throw error })
|
|
494
778
|
req.$nativeLogout.catch(() => {})
|
|
495
779
|
}
|
|
496
780
|
return req.$nativeLogout
|
|
@@ -498,15 +782,23 @@ export function nativeAuthBridge(raw) {
|
|
|
498
782
|
headers: () => req.$nativeLoginHeaders || {},
|
|
499
783
|
}
|
|
500
784
|
},
|
|
785
|
+
fileStream,
|
|
501
786
|
_socketCookie: req => cookies(req, false),
|
|
502
|
-
|
|
787
|
+
_forwardedFor: forwardedFor,
|
|
788
|
+
close() { stopped = true; for (const request of owned) request.destroy(); agent.destroy(); streamAgent.destroy() },
|
|
503
789
|
}
|
|
504
790
|
}
|
|
505
791
|
|
|
506
792
|
export function createServer(o = {}) {
|
|
507
793
|
const dist = resolve(o.dist || process.cwd())
|
|
508
|
-
|
|
509
|
-
const
|
|
794
|
+
// ctx.env: what the supervisor passed, read-only; the service key stays with ctx.backend.service
|
|
795
|
+
const { BRAIN_SERVICE_API_KEY: serviceKey, BRAIN_JOBS_API_KEY: _jobs, BRAIN_JOBS_SYSTEM_KEY: _jobsSystem, BRAIN_ROUTES_API_KEY: _routes, BRAIN_ROUTES_SYSTEM_KEY: _routesSystem, ...visibleEnv } = o.env || ENV
|
|
796
|
+
const env = Object.freeze(visibleEnv)
|
|
797
|
+
const native = nativeAuthBridge(o.backendURL ?? env.BRAIN_BACKEND_URL, (...why) => originRefused(...why), serviceKey, (...entry) => log(...entry))
|
|
798
|
+
// Using the native backend does not have to mean it owns the visitor's session: `app` keeps the signed cookie.
|
|
799
|
+
const backendSession = o.backendSession ?? env.BRAIN_BACKEND_SESSION
|
|
800
|
+
if (backendSession && backendSession !== 'app') throw Error("BRAIN_BACKEND_SESSION must be app, or unset for the backend's own session")
|
|
801
|
+
const nativeSession = native && backendSession !== 'app'
|
|
510
802
|
if (o.nativeSockets !== undefined && typeof o.nativeSockets !== 'boolean') throw Error('nativeSockets must be an explicit boolean')
|
|
511
803
|
const SITEMAP_BYTES = +(o.sitemapBytes ?? 50 * 1024 * 1024) // protocol: 50 MB uncompressed per sitemap file
|
|
512
804
|
const secret = o.secret ?? env.BRAIN_SECRET ?? ''
|
|
@@ -532,6 +824,20 @@ export function createServer(o = {}) {
|
|
|
532
824
|
if (typeof safe.path === 'string') safe.path = safe.path.split(/[?#]/, 1)[0].slice(0, 2048)
|
|
533
825
|
sink(level, msg, safe)
|
|
534
826
|
}
|
|
827
|
+
// An origin refusal (B0741, B0742): the answer stays minimal and the operator's log gets the reason. It is bounded per
|
|
828
|
+
// minute because anyone can trigger it, and it never names an allowlist: this server only compares against its own host.
|
|
829
|
+
let originWindow = 0, originLogs = 0
|
|
830
|
+
const originRefused = (code, req, rid, kind) => {
|
|
831
|
+
const minute = Math.floor(Date.now() / 60000)
|
|
832
|
+
if (originWindow !== minute) { originWindow = minute; originLogs = 0 }
|
|
833
|
+
if (originLogs++ >= 16) return
|
|
834
|
+
const bound = v => typeof v === 'string' ? v.slice(0, 256) : null
|
|
835
|
+
const seenOrigin = bound(req.headers.origin), host = bound(req.headers.host), forwardedHost = bound(req.headers['x-forwarded-host'])
|
|
836
|
+
const fix = seenOrigin
|
|
837
|
+
? `Origin ${seenOrigin} does not match the host this server sees (${forwardedHost || host}). Call it from the site itself; behind a proxy forward the host (nginx: proxy_set_header Host $host;). There is no allowlist to widen. Run brain explain ${code}`
|
|
838
|
+
: `The request has no Origin${code === 'B0741' ? ' and no Sec-Fetch-Site' : ''}, so its site cannot be proven. Send it from the page's own origin in a browser; a script sets Origin to the site origin. Run brain explain ${code}`
|
|
839
|
+
log('error', 'origin refused', { code, kind, rid, seenOrigin, secFetchSite: bound(req.headers['sec-fetch-site']), host, forwardedHost, fix })
|
|
840
|
+
}
|
|
535
841
|
// log fields for a thrown value: its stack (sanitized by log) plus, for B09xx budget failures, the typed fields only
|
|
536
842
|
const trace = e => {
|
|
537
843
|
const out = { stack: String((e && e.stack) || e) }
|
|
@@ -560,6 +866,22 @@ export function createServer(o = {}) {
|
|
|
560
866
|
if (nativeSockets && !native) throw Error('Native sockets require a configured native backend')
|
|
561
867
|
// the build's single route table (#187): already in match order, each with its RegExp source (named groups = params)
|
|
562
868
|
const routes = mf.routes.map(r => ({ ...r, rx: r.rx ? new RegExp(r.rx) : exact(r.path) }))
|
|
869
|
+
// The address RPC and form limits key on, ctx.req.ip and the logs: a client header counts only through a trusted proxy.
|
|
870
|
+
const proxies = trustedProxies(o.trustProxy ?? (env.BRAIN_TRUST_PROXY ? env.BRAIN_TRUST_PROXY.split(',').map(entry => entry.trim()) : mf.trustProxy) ?? [])
|
|
871
|
+
let proxyWarned = false
|
|
872
|
+
const ipOf = req => {
|
|
873
|
+
const forwarded = req.headers['x-forwarded-for']
|
|
874
|
+
if (forwarded && !proxies && !proxyWarned && /^(?:127\.|::1$|::ffff:127\.)/.test(req.socket.remoteAddress || '')) {
|
|
875
|
+
proxyWarned = true
|
|
876
|
+
log('warn', 'trust-proxy-not-configured', { fix: 'a local proxy is forwarding client addresses and they are ignored, so all clients share one rate-limit key; set trustProxy in server/platform.jsbrain or BRAIN_TRUST_PROXY=127.0.0.1,::1' })
|
|
877
|
+
}
|
|
878
|
+
return resolveClient(req.socket.remoteAddress, forwarded, proxies)
|
|
879
|
+
}
|
|
880
|
+
// server/routes/* of the native backend, listed by the build: only these exact shapes cross the bridge
|
|
881
|
+
const nativeRoutes = (Array.isArray(mf.nativeRoutes) ? mf.nativeRoutes : []).map(route => {
|
|
882
|
+
if (typeof route?.path !== 'string' || !Array.isArray(route.headers)) throw Error('Manifest nativeRoutes must list path and headers')
|
|
883
|
+
return { headers: route.headers, rx: new RegExp('^' + route.path.split('/').map(part => /^\{.+\}$/.test(part) ? '[^/]+' : part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('/') + '$') }
|
|
884
|
+
})
|
|
563
885
|
// the real path: Node imports file URLs through realpath, and the /_b/ hook must name the same modules (a symlinked
|
|
564
886
|
// dist such as /var → /private/var on macOS would otherwise load the core twice)
|
|
565
887
|
const distURL = pathToFileURL(real(dist) + sep).href
|
|
@@ -675,6 +997,13 @@ export function createServer(o = {}) {
|
|
|
675
997
|
const path = typeof custom === 'string' && file(custom)
|
|
676
998
|
return html(res, status, path ? readFileSync(path, 'utf8') : defaultError(status, rid, BASE))
|
|
677
999
|
}
|
|
1000
|
+
// `/403`, `/404.html`, `/503`…: the status when the app has that page, else 0 and the path is an ordinary one
|
|
1001
|
+
const reservedStatus = pathname => {
|
|
1002
|
+
const m = /^\/(403|404|410|5\d\d)(?:\.html)?$/.exec(pathname)
|
|
1003
|
+
if (!m) return 0
|
|
1004
|
+
const n = +m[1]
|
|
1005
|
+
return file(`${n}.html`) || (mf.errors && mf.errors[n]) || (n === 403 && mf.forbidden) || (n === 404 && mf.notFound) ? n : 0
|
|
1006
|
+
}
|
|
678
1007
|
// a file inside dist/ (never outside: no `..`, no absolute paths)
|
|
679
1008
|
function file(rel) {
|
|
680
1009
|
const p = normalize(join(dist, rel))
|
|
@@ -695,13 +1024,13 @@ export function createServer(o = {}) {
|
|
|
695
1024
|
|
|
696
1025
|
const context = (req, res, url, extra) => {
|
|
697
1026
|
const cookies = parseCookies(req.headers.cookie)
|
|
698
|
-
const { user, session: ses } =
|
|
1027
|
+
const { user, session: ses } = nativeSession ? { user: req.$nativeUser ?? null, session: sessionSecrets(native.session(req, res, res.getHeader('x-request-id')), cookies, secret) } : sessionFrom(cookies, secret)
|
|
699
1028
|
return {
|
|
700
1029
|
url, query: Object.fromEntries(url.searchParams), cookies, user, session: ses, env,
|
|
701
1030
|
...(native ? { backend: native.backend(req, res, res.getHeader('x-request-id')) } : {}),
|
|
702
1031
|
// `prefetch`: the router fetched this page ahead of a click (§19); a `load` with side effects can skip them
|
|
703
1032
|
prefetch: req.headers['x-brain-prefetch'] === '1',
|
|
704
|
-
req: { method: req.method, headers: req.headers, ip: ipOf(req) }, ...extra,
|
|
1033
|
+
req: { method: req.method, headers: req.headers, ip: ipOf(req) }, signal: lifetime(res), ...extra,
|
|
705
1034
|
}
|
|
706
1035
|
}
|
|
707
1036
|
|
|
@@ -767,7 +1096,7 @@ export function createServer(o = {}) {
|
|
|
767
1096
|
const ctx = context(req, res, url, { params })
|
|
768
1097
|
// a page rendered for a signed-in user is never stored (§35): after logout, Back must not show the previous
|
|
769
1098
|
// user's page from the HTTP cache or bfcache. Anonymous pages keep `no-cache` + ETag.
|
|
770
|
-
const own = ctx.user != null || ctx.cookies[COOKIE] !== undefined || native && /(?:^|;\s*)__Host-brain(?:-access|-refresh)?=/.test(req.headers.cookie || '')
|
|
1099
|
+
const own = ctx.user != null || ctx.cookies[COOKIE] !== undefined || ctx.cookies[SECRET_COOKIE] !== undefined || native && /(?:^|;\s*)__Host-brain(?:-access|-refresh)?=/.test(req.headers.cookie || '')
|
|
771
1100
|
const keep = h => (own ? { ...h, 'cache-control': 'private, no-store' } : h)
|
|
772
1101
|
let props = fixed || {}, extra = {}
|
|
773
1102
|
if (!fixed && ((route.guards && route.guards.length) || (route.middleware && route.middleware.length))) {
|
|
@@ -776,19 +1105,19 @@ export function createServer(o = {}) {
|
|
|
776
1105
|
const guards = [], middleware = []
|
|
777
1106
|
for (const g of route.guards || []) guards.push(await load(g))
|
|
778
1107
|
for (const m of route.middleware || []) middleware.push(await load(m))
|
|
779
|
-
r = await runChain({ guards, middleware, ctx })
|
|
1108
|
+
r = await pageLocale.run(route.locale, () => runChain({ guards, middleware, ctx }))
|
|
780
1109
|
} catch (e) {
|
|
781
1110
|
log('error', 'guard failed', { rid, path: url.pathname, ...trace(e) })
|
|
782
1111
|
return errorPage(req, res, route, params, url, rid)
|
|
783
1112
|
}
|
|
784
1113
|
// a guarded page must not reach the index through its login redirect (§33): the redirect itself says noindex
|
|
785
|
-
if (r.redirect) return send(res, r.status, '', { location: withBase(redirect(r.redirect, r.status ?? 303).redirect), 'cache-control': 'no-store', 'x-robots-tag': NOINDEX, ...ctx.session.headers() })
|
|
1114
|
+
if (r.redirect) return send(res, r.status, '', { location: withBase(linkTo(route, redirect(r.redirect, r.status ?? 303).redirect)), 'cache-control': 'no-store', 'x-robots-tag': NOINDEX, ...ctx.session.headers() })
|
|
786
1115
|
if (r.deny !== undefined) return forbidden(req, res, url, rid, r.deny)
|
|
787
1116
|
extra = r.headers
|
|
788
1117
|
}
|
|
789
1118
|
// what load() thrown or returned instead of props answers: redirect, notFound() (F11), deliberate status, failure
|
|
790
1119
|
const loadOutcome = e => {
|
|
791
|
-
if (e && e.redirect) return send(res, e.status || 303, '', { location: withBase(redirect(e.redirect, e.status ?? 303).redirect), ...ctx.session.headers() })
|
|
1120
|
+
if (e && e.redirect) return send(res, e.status || 303, '', { location: withBase(linkTo(route, redirect(e.redirect, e.status ?? 303).redirect)), ...ctx.session.headers() })
|
|
792
1121
|
if ((e instanceof HttpError && e.status === 404) || (e && e.notFound === true)) return missing(req, res, e) // `notFound()` 404 / `gone()` 410
|
|
793
1122
|
const status = intendedStatus(e) || 500
|
|
794
1123
|
log('error', 'load failed', { rid, path: url.pathname, status, ...trace(e) })
|
|
@@ -798,7 +1127,8 @@ export function createServer(o = {}) {
|
|
|
798
1127
|
try {
|
|
799
1128
|
if (!fixed && route.server) {
|
|
800
1129
|
const m = await load(route.server)
|
|
801
|
-
if (
|
|
1130
|
+
if (route.i18n) await load(route.i18n) // t() inside load(): the dictionary is there before it runs
|
|
1131
|
+
if (typeof m.load === 'function') props = (await withTimeout(pageLocale.run(route.locale, () => Promise.resolve(m.load(ctx))), TIMEOUT, ctx)) ?? {}
|
|
802
1132
|
}
|
|
803
1133
|
} catch (e) {
|
|
804
1134
|
return loadOutcome(e)
|
|
@@ -833,7 +1163,8 @@ export function createServer(o = {}) {
|
|
|
833
1163
|
async function streamed(req, res, route, params, url, rid, ctx, extra, outcome) {
|
|
834
1164
|
const [top, bottom = ''] = readFileSync(file(route.page), 'utf8').split('<!--brain:ssr-->')
|
|
835
1165
|
const sm = await load(route.server)
|
|
836
|
-
|
|
1166
|
+
if (route.i18n) await load(route.i18n)
|
|
1167
|
+
const loadP = typeof sm.load === 'function' ? withTimeout(pageLocale.run(route.locale, () => Promise.resolve().then(() => sm.load(ctx))), TIMEOUT, ctx) : Promise.resolve(null)
|
|
837
1168
|
loadP.catch(() => {}) // handled below; never an unhandled rejection while the loading view renders
|
|
838
1169
|
let timer
|
|
839
1170
|
const early = await Promise.race([loadP.then(v => ({ v }), e => ({ e })), new Promise(r => (timer = setTimeout(r, STREAM_HOLD)))])
|
|
@@ -892,7 +1223,7 @@ export function createServer(o = {}) {
|
|
|
892
1223
|
if (props.redirect || props.notFound === true) throw props
|
|
893
1224
|
} catch (e) {
|
|
894
1225
|
// too late for a real redirect: the page says noindex so the stub never enters the index
|
|
895
|
-
if (e && e.redirect) return end(`<meta name="robots" content="noindex"><script>location.replace(${jsonInHtml(withBase(redirect(e.redirect, e.status ?? 303).redirect))})</script>`)
|
|
1226
|
+
if (e && e.redirect) return end(`<meta name="robots" content="noindex"><script>location.replace(${jsonInHtml(withBase(linkTo(route, redirect(e.redirect, e.status ?? 303).redirect)))})</script>`)
|
|
896
1227
|
// a streamed page already sent its 200 head: the region gets the not-found notice and the page is noindex
|
|
897
1228
|
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, ''))
|
|
898
1229
|
log('error', 'load failed (streamed)', { rid, path: url.pathname, status: 200, ...trace(e) }) // headers already out
|
|
@@ -966,7 +1297,7 @@ export function createServer(o = {}) {
|
|
|
966
1297
|
await new Promise(r => setTimeout(r, 0))
|
|
967
1298
|
globalThis.__brainSSR = null
|
|
968
1299
|
fillValues(root)
|
|
969
|
-
const body = root.innerHTML
|
|
1300
|
+
const body = localizeLinks(route, root.innerHTML)
|
|
970
1301
|
stop()
|
|
971
1302
|
// SEO head: the codegen emits `export const seo` / `head` in the view's server module; the client chunk's V.seo
|
|
972
1303
|
// (older builds) is the fallback. Still inside the page's locale: `<meta seo title={t('…')}>` is translated.
|
|
@@ -987,7 +1318,7 @@ export function createServer(o = {}) {
|
|
|
987
1318
|
const fail = (status, error, { $h, ...extra } = {}) => send(res, status, json({ error, ...extra }), { 'content-type': MIME['.json'], 'cache-control': 'no-store', ...$h })
|
|
988
1319
|
if (req.method !== 'POST') return fail(405, 'Use POST', { $h: { allow: 'POST' } })
|
|
989
1320
|
if (req.headers['x-brain-rpc'] !== '1') return fail(403, 'Missing X-Brain-RPC header')
|
|
990
|
-
if (!sameOrigin(req)) return fail(403, 'Cross-origin request')
|
|
1321
|
+
if (!sameOrigin(req)) return originRefused('B0741', req, rid, 'rpc'), fail(403, 'Cross-origin request', { brainCode: 'B0741' })
|
|
991
1322
|
const modPath = mf.rpc[hash]
|
|
992
1323
|
if (!modPath || !/^[A-Za-z_$][\w$]*$/.test(fn)) return fail(404, 'Unknown function')
|
|
993
1324
|
const ra = overLimit(ipOf(req) + ' ' + hash + '/' + fn, LIMIT) // before reading the body
|
|
@@ -1030,7 +1361,7 @@ export function createServer(o = {}) {
|
|
|
1030
1361
|
// ---------- form posted without JS (§38; transport, the logic is callForm, shared with the dev server) ----------
|
|
1031
1362
|
async function formPost(req, res, route, params, url, rid) {
|
|
1032
1363
|
const plain = (status, text, h = {}) => send(res, status, text, { 'content-type': MIME['.txt'], 'cache-control': 'no-store', ...h })
|
|
1033
|
-
if (!sameOrigin(req, true)) return plain(403, 'Cross-origin form post')
|
|
1364
|
+
if (!sameOrigin(req, true)) return originRefused('B0741', req, rid, 'form'), plain(403, 'Cross-origin form post', { 'x-brain-code': 'B0741' })
|
|
1034
1365
|
if (!/^application\/x-www-form-urlencoded/i.test(req.headers['content-type'] || '')) return plain(415, 'Forms are sent as application/x-www-form-urlencoded')
|
|
1035
1366
|
let raw
|
|
1036
1367
|
try {
|
|
@@ -1217,6 +1548,16 @@ export function createServer(o = {}) {
|
|
|
1217
1548
|
|
|
1218
1549
|
// ---------- dispatch ----------
|
|
1219
1550
|
const server = http(async (req, res) => {
|
|
1551
|
+
// Only a file upload may outlast the 30-second body budget; the dispatcher lifts this guard for that path alone.
|
|
1552
|
+
if (native) {
|
|
1553
|
+
req.$brainBodyGuard = setTimeout(() => {
|
|
1554
|
+
if (req.complete) return
|
|
1555
|
+
if (!res.headersSent) res.writeHead(408, { connection:'close' })
|
|
1556
|
+
res.end(); req.socket?.destroy()
|
|
1557
|
+
}, 30000)
|
|
1558
|
+
req.$brainBodyGuard.unref?.()
|
|
1559
|
+
res.once('close', () => clearTimeout(req.$brainBodyGuard))
|
|
1560
|
+
}
|
|
1220
1561
|
const rid = req.headers['x-request-id'] && /^[\w-]{1,64}$/.test(req.headers['x-request-id']) ? req.headers['x-request-id'] : randomUUID()
|
|
1221
1562
|
res.setHeader('x-request-id', rid)
|
|
1222
1563
|
const t0 = performance.now()
|
|
@@ -1225,6 +1566,8 @@ export function createServer(o = {}) {
|
|
|
1225
1566
|
if (ms > 1000) log('warn', 'slow request', { rid, path: req.$brainPath || String(req.url || '').split('?')[0], status: res.statusCode, ms: Math.round(ms) })
|
|
1226
1567
|
})
|
|
1227
1568
|
try {
|
|
1569
|
+
// `//` makes new URL throw and `//host/x` would be read as another host
|
|
1570
|
+
if (req.url.startsWith('//')) return notFound(req, res)
|
|
1228
1571
|
const full = new URL(req.url, 'http://' + (req.headers.host || 'localhost'))
|
|
1229
1572
|
// /_health stays at the root: the supervisor calls it there whatever the base
|
|
1230
1573
|
if (full.pathname === '/_health' || full.pathname === BASE + '_health') return send(res, 200, json({ ok: true, build: mf.build || null }), { 'content-type': MIME['.json'], 'cache-control': 'no-store' })
|
|
@@ -1235,7 +1578,10 @@ export function createServer(o = {}) {
|
|
|
1235
1578
|
url = new URL('/' + full.pathname.slice(BASE.length) + full.search, full) // the app path
|
|
1236
1579
|
}
|
|
1237
1580
|
req.$brainPath = url.pathname
|
|
1581
|
+
if (native && /^\/_brain\/files(?:\/|$)/.test(url.pathname)) return await native.fileStream(req,res,url,rid)
|
|
1238
1582
|
if (native && /^\/(?:api(?:\/|$)|_brain(?:\/|$)|s\/)/.test(url.pathname)) return await native.proxy(req,res,url,rid,send)
|
|
1583
|
+
const declaredRoute = native && nativeRoutes.find(route => route.rx.test(url.pathname))
|
|
1584
|
+
if (declaredRoute) return await native.proxy(req,res,url,rid,send,undefined,declaredRoute)
|
|
1239
1585
|
// redirects.json (manifest `redirects`, brain-seo server_json): exact sources first, then patterns, on the app path;
|
|
1240
1586
|
// same-site targets get the base, the query is kept when the target has none; slash policy and host in the same hop
|
|
1241
1587
|
const to = (req.method === 'GET' || req.method === 'HEAD') && canonicalOf(req, url)
|
|
@@ -1246,6 +1592,10 @@ export function createServer(o = {}) {
|
|
|
1246
1592
|
if (url.pathname.startsWith('/_server/')) return notFound(req, res) // server code is never served
|
|
1247
1593
|
const post = req.method === 'POST'
|
|
1248
1594
|
if (!post && req.method !== 'GET' && req.method !== 'HEAD') return send(res, 405, 'Method not allowed', { allow: 'GET, HEAD, POST' })
|
|
1595
|
+
// a status page opened directly answers its own status: `/403` is never a 200 that a crawler or a probe can trust
|
|
1596
|
+
const reserved = post ? 0 : reservedStatus(url.pathname)
|
|
1597
|
+
if (reserved === 403) return forbidden(req, res, url, rid, '')
|
|
1598
|
+
if (reserved) return failPage(res, reserved, rid)
|
|
1249
1599
|
for (const route of routes) {
|
|
1250
1600
|
const m = route.rx.exec(url.pathname)
|
|
1251
1601
|
if (!m) continue
|
|
@@ -1257,6 +1607,8 @@ export function createServer(o = {}) {
|
|
|
1257
1607
|
if (post) return send(res, 405, 'Method not allowed', { allow: 'GET, HEAD' })
|
|
1258
1608
|
if (DYN.length && /^\/sitemap(-static|-dynamic(-\d+)?)?\.xml$/.test(url.pathname) && (await serveSitemap(req, res, url))) return
|
|
1259
1609
|
if (serveStatic(req, res, url)) return
|
|
1610
|
+
// browsers still ask for /favicon.ico: the app's SVG icon answers it
|
|
1611
|
+
if (url.pathname === '/favicon.ico' && file('favicon.svg')) return send(res, 301, '', { location: withBase('/favicon.svg'), 'cache-control': 'public, max-age=86400' })
|
|
1260
1612
|
notFound(req, res)
|
|
1261
1613
|
} catch (e) {
|
|
1262
1614
|
log('error', 'unhandled', { rid, path: req.url, ...trace(e) })
|
|
@@ -1265,29 +1617,114 @@ export function createServer(o = {}) {
|
|
|
1265
1617
|
}
|
|
1266
1618
|
})
|
|
1267
1619
|
if (native) server.once('close', () => native.close())
|
|
1268
|
-
if (native && nativeSockets) server.$brainSocketStop = installNativeSockets(server,{target:o.backendURL ?? env.BRAIN_BACKEND_URL,base:BASE,cookie:native._socketCookie,log})
|
|
1620
|
+
if (native && nativeSockets) server.$brainSocketStop = installNativeSockets(server,{target:o.backendURL ?? env.BRAIN_BACKEND_URL,base:BASE,cookie:native._socketCookie,client:native._forwardedFor,log})
|
|
1269
1621
|
server.headersTimeout = 15000
|
|
1270
|
-
|
|
1622
|
+
// With a native backend the file upload deadline is enforced per request (above); 30 s stays for every other body.
|
|
1623
|
+
server.requestTimeout = native ? 320000 : 30000
|
|
1271
1624
|
return server
|
|
1272
1625
|
}
|
|
1273
1626
|
|
|
1274
1627
|
// ---------- session (signed cookie; shared with the dev server) ----------
|
|
1275
|
-
const COOKIE = '__Host-session'
|
|
1276
|
-
//
|
|
1277
|
-
|
|
1628
|
+
const COOKIE = '__Host-session', SECRET_COOKIE = '__Host-session-secret', SECRET_MAX = 3500, DEFAULT_SESSION_SECONDS = 7 * 24 * 3600
|
|
1629
|
+
// Keys that still verify and decrypt during a rotation; new cookies always use the current key.
|
|
1630
|
+
const previousKeys = () => (process.env.BRAIN_SECRET_PREVIOUS || '').split(',').map(k => k.trim()).filter(Boolean)
|
|
1631
|
+
const sessionVersion = () => process.env.BRAIN_SESSION_VERSION || ''
|
|
1632
|
+
const sessionSeconds = () => { const n = Number(process.env.BRAIN_SESSION_SECONDS); return Number.isInteger(n) && n > 0 ? n : DEFAULT_SESSION_SECONDS }
|
|
1633
|
+
// Cookies of this process signed out before their expiry (mac -> exp). One process only: a shared store does not exist
|
|
1634
|
+
// here, so a deployment with several processes signs everybody out with BRAIN_SESSION_VERSION instead.
|
|
1635
|
+
const REVOKED = new Map(), REVOKED_MAX = 10000
|
|
1636
|
+
const revoke = (mac, exp) => {
|
|
1637
|
+
const now = Date.now()
|
|
1638
|
+
for (const [k, e] of REVOKED) if (e <= now) REVOKED.delete(k)
|
|
1639
|
+
while (REVOKED.size >= REVOKED_MAX) REVOKED.delete(REVOKED.keys().next().value)
|
|
1640
|
+
REVOKED.set(mac, exp)
|
|
1641
|
+
}
|
|
1642
|
+
const secretKey = secret => Buffer.from(hkdfSync('sha256', secret, '', 'brain session secret v1', 32))
|
|
1643
|
+
const seal = (secret, text) => {
|
|
1644
|
+
const iv = randomBytes(12), cipher = createCipheriv('aes-256-gcm', secretKey(secret), iv)
|
|
1645
|
+
cipher.setAAD(Buffer.from(SECRET_COOKIE))
|
|
1646
|
+
const body = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()])
|
|
1647
|
+
return Buffer.concat([iv, cipher.getAuthTag(), body]).toString('base64url')
|
|
1648
|
+
}
|
|
1649
|
+
const unseal = (secret, value) => {
|
|
1650
|
+
try {
|
|
1651
|
+
const raw = Buffer.from(value, 'base64url')
|
|
1652
|
+
if (raw.length < 29) return null
|
|
1653
|
+
const decipher = createDecipheriv('aes-256-gcm', secretKey(secret), raw.subarray(0, 12))
|
|
1654
|
+
decipher.setAAD(Buffer.from(SECRET_COOKIE))
|
|
1655
|
+
decipher.setAuthTag(raw.subarray(12, 28))
|
|
1656
|
+
return Buffer.concat([decipher.update(raw.subarray(28)), decipher.final()]).toString('utf8')
|
|
1657
|
+
} catch { return null }
|
|
1658
|
+
}
|
|
1659
|
+
// `ctx.session.setSecret(value, { maxAge })`, `.secret()` and `.clearSecret()` on any session handle (signed cookie or
|
|
1660
|
+
// native bridge): server-only values such as a provider token, AES-256-GCM encrypted in their own cookie. They never
|
|
1661
|
+
// fill `ctx.user`, and `session.clear()` removes them too.
|
|
1662
|
+
const missingSecret = what => new Error(`B0782: BRAIN_SECRET is not set: ${what} need it; fix: set 32+ random bytes in the host environment or in .brain_env (openssl rand -hex 32)`)
|
|
1663
|
+
export function sessionSecrets(session, cookies, secret) {
|
|
1664
|
+
const out = []
|
|
1665
|
+
const write = (value, maxAge) => { out.length = 0; out.push(`${SECRET_COOKIE}=${value}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${Math.max(0, maxAge)}`) }
|
|
1666
|
+
let current
|
|
1667
|
+
const read = () => {
|
|
1668
|
+
if (current !== undefined) return current
|
|
1669
|
+
current = null
|
|
1670
|
+
const v = cookies && cookies[SECRET_COOKIE]
|
|
1671
|
+
if (!v || !secret) return current
|
|
1672
|
+
for (const key of [secret, ...previousKeys()]) {
|
|
1673
|
+
const text = unseal(key, v)
|
|
1674
|
+
if (text === null) continue
|
|
1675
|
+
try {
|
|
1676
|
+
const s = JSON.parse(text)
|
|
1677
|
+
if (s && s.exp > Date.now()) current = s.v
|
|
1678
|
+
} catch {}
|
|
1679
|
+
break
|
|
1680
|
+
}
|
|
1681
|
+
return current
|
|
1682
|
+
}
|
|
1683
|
+
const { clear, headers } = session
|
|
1684
|
+
const clearSecret = () => { write('', 0); current = null }
|
|
1685
|
+
return Object.assign(session, {
|
|
1686
|
+
secret: read,
|
|
1687
|
+
setSecret(value, { maxAge = sessionSeconds() } = {}) {
|
|
1688
|
+
if (!secret) throw missingSecret('session secrets')
|
|
1689
|
+
const text = JSON.stringify({ v: value, exp: Date.now() + maxAge * 1000 })
|
|
1690
|
+
if (value === undefined || text === undefined || JSON.parse(text).v === undefined) throw new Error('session.setSecret needs a JSON value')
|
|
1691
|
+
const sealed = seal(secret, text)
|
|
1692
|
+
if (sealed.length > SECRET_MAX) throw new Error(`session secret too large (${sealed.length} bytes, limit ${SECRET_MAX}): keep it server-side and store a reference`)
|
|
1693
|
+
write(sealed, maxAge)
|
|
1694
|
+
current = value
|
|
1695
|
+
},
|
|
1696
|
+
clearSecret,
|
|
1697
|
+
clear(...args) { const result = clear.apply(session, args); if (out.length || (cookies && cookies[SECRET_COOKIE] !== undefined)) clearSecret(); return result },
|
|
1698
|
+
headers() {
|
|
1699
|
+
const base = headers.call(session)
|
|
1700
|
+
if (!out.length) return base
|
|
1701
|
+
const own = base['set-cookie']
|
|
1702
|
+
return { ...base, 'set-cookie': [...(Array.isArray(own) ? own : own ? [own] : []), ...out] }
|
|
1703
|
+
},
|
|
1704
|
+
})
|
|
1705
|
+
}
|
|
1706
|
+
// The user of a request: the signed `__Host-session` cookie (HMAC-SHA256 with BRAIN_SECRET; BRAIN_SECRET_PREVIOUS still
|
|
1707
|
+
// verifies during a rotation, BRAIN_SESSION_VERSION signs everybody out), plus the session handle to set/clear it. Same
|
|
1708
|
+
// in `node server.mjs` and in `brain run` (the dev sidecar imports it).
|
|
1278
1709
|
export function sessionFrom(cookies, secret) {
|
|
1279
|
-
const
|
|
1280
|
-
|
|
1710
|
+
const signWith = (key, data) => b64u(createHmac('sha256', key).update(data).digest())
|
|
1711
|
+
const sign = data => signWith(secret, data)
|
|
1712
|
+
let cur = null, curMac = null
|
|
1281
1713
|
const v = cookies && cookies[COOKIE]
|
|
1282
1714
|
if (v && secret) {
|
|
1283
1715
|
const i = v.lastIndexOf('.')
|
|
1284
1716
|
if (i > 0) {
|
|
1285
|
-
const data = v.slice(0, i), mac = Buffer.from(v.slice(i + 1))
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1717
|
+
const data = v.slice(0, i), mac = Buffer.from(v.slice(i + 1))
|
|
1718
|
+
for (const key of [secret, ...previousKeys()]) {
|
|
1719
|
+
const good = Buffer.from(signWith(key, data))
|
|
1720
|
+
if (mac.length === good.length && timingSafeEqual(mac, good)) {
|
|
1721
|
+
try {
|
|
1722
|
+
const s = JSON.parse(Buffer.from(data, 'base64url').toString())
|
|
1723
|
+
if (s && s.exp > Date.now() && (s.sv ?? '') === sessionVersion() && !REVOKED.has(v.slice(i + 1))) { cur = s; curMac = v.slice(i + 1) }
|
|
1724
|
+
} catch {}
|
|
1725
|
+
break
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1291
1728
|
}
|
|
1292
1729
|
}
|
|
1293
1730
|
const set = []
|
|
@@ -1296,14 +1733,16 @@ export function sessionFrom(cookies, secret) {
|
|
|
1296
1733
|
const user = cur ? cur.u : null
|
|
1297
1734
|
return {
|
|
1298
1735
|
user,
|
|
1299
|
-
session: {
|
|
1736
|
+
session: sessionSecrets({
|
|
1300
1737
|
user,
|
|
1301
|
-
set(u, { maxAge =
|
|
1302
|
-
if (!secret) throw
|
|
1303
|
-
const
|
|
1738
|
+
set(u, { maxAge = sessionSeconds() } = {}) {
|
|
1739
|
+
if (!secret) throw missingSecret('sessions')
|
|
1740
|
+
const version = sessionVersion()
|
|
1741
|
+
const data = b64u(JSON.stringify({ u, exp: Date.now() + maxAge * 1000, ...(version ? { sv: version } : {}) }))
|
|
1304
1742
|
set.push(cookie(`${data}.${sign(data)}`, maxAge))
|
|
1305
1743
|
},
|
|
1306
1744
|
clear() {
|
|
1745
|
+
if (curMac) revoke(curMac, cur.exp)
|
|
1307
1746
|
set.push(cookie('', 0))
|
|
1308
1747
|
cleared = true
|
|
1309
1748
|
},
|
|
@@ -1312,7 +1751,7 @@ export function sessionFrom(cookies, secret) {
|
|
|
1312
1751
|
// response, so the logout call took ~18 s (F16). Nothing private is in that cache: RPC is no-store and SSR
|
|
1313
1752
|
// HTML rendered with a session cookie is `private, no-store` (ssr() → keep).
|
|
1314
1753
|
headers: () => ({ ...(set.length ? { 'set-cookie': set } : {}), ...(cleared ? { 'clear-site-data': '"storage"' } : {}) }),
|
|
1315
|
-
},
|
|
1754
|
+
}, cookies, secret),
|
|
1316
1755
|
}
|
|
1317
1756
|
}
|
|
1318
1757
|
|
|
@@ -1378,9 +1817,30 @@ export function overLimit(key, max = 60) {
|
|
|
1378
1817
|
if (!h || now - h[0] > 60000) HITS.set(key, (h = [now, 0]))
|
|
1379
1818
|
return ++h[1] > max ? Math.ceil((h[0] + 60000 - now) / 1000) : 0
|
|
1380
1819
|
}
|
|
1381
|
-
|
|
1820
|
+
// t(), fmt and locale() read globalThis.__brainLocale. A render sets it while nothing else runs (renders are serialized);
|
|
1821
|
+
// load(), guards and form handlers overlap across awaits, so each carries its page's language in the async context.
|
|
1822
|
+
const pageLocale = new AsyncLocalStorage()
|
|
1823
|
+
let renderLocale = globalThis.__brainLocale
|
|
1824
|
+
Object.defineProperty(globalThis, '__brainLocale', { configurable: true, get: () => pageLocale.getStore() ?? renderLocale, set: v => (renderLocale = v) })
|
|
1825
|
+
// ctx.signal: aborted when the client disconnects before the answer ends or the function passes its deadline, so the
|
|
1826
|
+
// work it started (fetch, a query) stops with it.
|
|
1827
|
+
const aborts = new WeakMap()
|
|
1828
|
+
const newSignal = () => {
|
|
1829
|
+
const c = new AbortController()
|
|
1830
|
+
aborts.set(c.signal, c)
|
|
1831
|
+
return c.signal
|
|
1832
|
+
}
|
|
1833
|
+
const lifetime = res => {
|
|
1834
|
+
const signal = newSignal()
|
|
1835
|
+
res.once?.('close', () => res.writableFinished || aborts.get(signal).abort()) // a captured render has no socket
|
|
1836
|
+
return signal
|
|
1837
|
+
}
|
|
1838
|
+
const withTimeout = (p, ms, ctx) => {
|
|
1382
1839
|
let t
|
|
1383
|
-
return Promise.race([p, new Promise((_, rej) => (t = setTimeout(() =>
|
|
1840
|
+
return Promise.race([p, new Promise((_, rej) => (t = setTimeout(() => {
|
|
1841
|
+
aborts.get(ctx?.signal)?.abort()
|
|
1842
|
+
rej(new HttpError(504, 'timeout'))
|
|
1843
|
+
}, ms)))]).finally(() => clearTimeout(t))
|
|
1384
1844
|
}
|
|
1385
1845
|
// RPC body → arguments: a JSON array, with __proto__/constructor/prototype keys dropped. Throws HttpError(400).
|
|
1386
1846
|
export function parseArgs(raw) {
|
|
@@ -1408,7 +1868,8 @@ export async function callRpc({ fn, args, ctx, key, limit = 60, timeout = 10000
|
|
|
1408
1868
|
return { status: e.status || 400, body: { error: e.message } }
|
|
1409
1869
|
}
|
|
1410
1870
|
try {
|
|
1411
|
-
|
|
1871
|
+
ctx.signal ||= newSignal() // the dev sidecar builds its own ctx
|
|
1872
|
+
const v = await withTimeout(Promise.resolve(fn(ctx, ...a)), timeout, ctx)
|
|
1412
1873
|
return { status: 200, body: v ?? null }
|
|
1413
1874
|
} catch (e) {
|
|
1414
1875
|
if (e && e.errors && typeof e.errors === 'object') return { status: 422, body: { error: e.message || 'Invalid input', errors: e.errors } }
|
|
@@ -1449,7 +1910,8 @@ export async function callForm({ module, raw, ctx, rules, key, limit = 60, timeo
|
|
|
1449
1910
|
if (Object.keys(errors).length) return again(errors, '')
|
|
1450
1911
|
let r
|
|
1451
1912
|
try {
|
|
1452
|
-
|
|
1913
|
+
ctx.signal ||= newSignal()
|
|
1914
|
+
r = await withTimeout(pageLocale.run(locale, () => Promise.resolve(module[fn](ctx, values))), timeout, ctx)
|
|
1453
1915
|
} catch (e) {
|
|
1454
1916
|
if (e && e.errors && typeof e.errors === 'object') return again(e.errors, '')
|
|
1455
1917
|
if (e instanceof HttpError && e.status >= 400 && e.status < 500) return again({}, e.message)
|
|
@@ -1509,7 +1971,39 @@ function parseCookies(h = '') {
|
|
|
1509
1971
|
}
|
|
1510
1972
|
return out
|
|
1511
1973
|
}
|
|
1512
|
-
const
|
|
1974
|
+
const plainIp = text => { const ip = String(text).trim().replace(/^"|"$/g, '').replace(/^\[([^\]]+)\](?::\d+)?$/, '$1').replace(/^(\d+\.\d+\.\d+\.\d+):\d+$/, '$1').replace(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i, '$1'); return isIP(ip) ? ip : null }
|
|
1975
|
+
/** platform.trustProxy: one BlockList of proxies. Same limits as the backend's parse_proxy (brain-backend/src/platform.rs). */
|
|
1976
|
+
export function trustedProxies(list = []) {
|
|
1977
|
+
const nets = new BlockList()
|
|
1978
|
+
for (const entry of list) {
|
|
1979
|
+
const [address, prefix, extra] = String(entry).split('/')
|
|
1980
|
+
const family = isIP(address)
|
|
1981
|
+
const bits = prefix === undefined ? (family === 4 ? 32 : 128) : Number(prefix)
|
|
1982
|
+
const wide = family === 4 ? bits < 8 : bits < 16 && !(bits === 7 && /^fc00::$/i.test(address))
|
|
1983
|
+
if (!family || extra !== undefined || !Number.isInteger(bits) || bits > (family === 4 ? 32 : 128)) throw Error(`trustProxy entry \`${entry}\` must be an IP address or CIDR range`)
|
|
1984
|
+
if (wide) throw Error(`trustProxy entry \`${entry}\` is too wide; use at least /8 for IPv4 and /16 for IPv6, and list your proxy ranges`)
|
|
1985
|
+
nets.addSubnet(address, bits, family === 4 ? 'ipv4' : 'ipv6')
|
|
1986
|
+
}
|
|
1987
|
+
return list.length ? nets : null
|
|
1988
|
+
}
|
|
1989
|
+
/**
|
|
1990
|
+
* The client is the right-most forwarded hop that is not a trusted proxy, and only when the TCP peer is trusted: hops
|
|
1991
|
+
* left of it are client-supplied and never consulted. Same walk as resolve_client (brain-backend/src/edge_http.rs).
|
|
1992
|
+
*/
|
|
1993
|
+
export function resolveClient(peer, forwarded, nets) {
|
|
1994
|
+
const socketPeer = plainIp(peer ?? '')
|
|
1995
|
+
const trusted = ip => nets.check(ip, isIP(ip) === 4 ? 'ipv4' : 'ipv6')
|
|
1996
|
+
if (!socketPeer) return '?'
|
|
1997
|
+
if (!nets || !trusted(socketPeer)) return socketPeer
|
|
1998
|
+
let client = socketPeer
|
|
1999
|
+
for (const text of String(forwarded ?? '').split(',').reverse()) {
|
|
2000
|
+
const ip = plainIp(text)
|
|
2001
|
+
if (!ip) return client
|
|
2002
|
+
client = ip
|
|
2003
|
+
if (!trusted(ip)) return ip
|
|
2004
|
+
}
|
|
2005
|
+
return client
|
|
2006
|
+
}
|
|
1513
2007
|
function body(req, max) {
|
|
1514
2008
|
return new Promise((ok, fail) => {
|
|
1515
2009
|
let n = 0
|
|
@@ -1568,7 +2062,7 @@ export function prefersMarkdown(accept) {
|
|
|
1568
2062
|
return md > 0 && (md > html || (md === html && mdAt < htmlAt))
|
|
1569
2063
|
}
|
|
1570
2064
|
// SEO head from View.seo(props): title, description, canonical, og:*/twitter:*, JSON-LD and the view's <head> block
|
|
1571
|
-
// F5: the build writes an SSR route's canonical/og:url from its pattern (/
|
|
2065
|
+
// F5: the build writes an SSR route's canonical/og:url from its pattern (/posts/:slug); the request has the real path.
|
|
1572
2066
|
// A view's own <head> tags replace the generated ones with the same name/property (one tag per key, SPEC «SEO»).
|
|
1573
2067
|
function head(page, seo, path) {
|
|
1574
2068
|
let h = page
|
|
@@ -1637,6 +2131,7 @@ function hook(base, app = '/') {
|
|
|
1637
2131
|
)
|
|
1638
2132
|
}
|
|
1639
2133
|
|
|
2134
|
+
const LISTEN_WAIT_MS = 10000
|
|
1640
2135
|
// `node server.mjs` (directly or under `brain start`); paths compared after symlinks (/var → /private/var on macOS)
|
|
1641
2136
|
function real(p) {
|
|
1642
2137
|
try {
|
|
@@ -1650,10 +2145,22 @@ if (process.argv[1] && real(process.argv[1]) === real(fileURLToPath(import.meta.
|
|
|
1650
2145
|
const s = createServer({ dist: fileURLToPath(new URL('.', import.meta.url)) })
|
|
1651
2146
|
const fd = ENV.BRAIN_LISTEN_FD
|
|
1652
2147
|
const where = fd ? { fd: Number(fd) } : { port: +(ENV.PORT || 3000), host: ENV.HOST || '127.0.0.1' }
|
|
1653
|
-
s.
|
|
2148
|
+
s.once('listening', () => {
|
|
1654
2149
|
process.stdout.write(`brain:ready ${process.pid}\n`) // the supervisor waits for this line (§34)
|
|
1655
2150
|
console.log(json({ t: new Date().toISOString(), level: 'info', msg: 'listening', ...(fd ? { fd: Number(fd) } : where) }))
|
|
1656
2151
|
})
|
|
2152
|
+
// A restart may find the previous instance still draining: wait for it, briefly. Two live servers never share a port.
|
|
2153
|
+
const waitUntil = Date.now() + LISTEN_WAIT_MS
|
|
2154
|
+
s.on('error', error => {
|
|
2155
|
+
if (error.code === 'EADDRINUSE' && !fd && !s.listening && Date.now() < waitUntil) return void setTimeout(() => s.listen(where), 100)
|
|
2156
|
+
if (s.listening) throw error
|
|
2157
|
+
const port = where.port
|
|
2158
|
+
const fix = error.code === 'EADDRINUSE' ? `find the owner with \`lsof -nP -iTCP:${port} -sTCP:LISTEN\` (Linux: \`ss -ltnp 'sport = :${port}'\`), stop it, or set another PORT` : 'use another HOST or PORT'
|
|
2159
|
+
const cause = error.code === 'EADDRINUSE' ? `another live process still holds the port after ${LISTEN_WAIT_MS / 1000} s` : error.code
|
|
2160
|
+
console.error(json({ t: new Date().toISOString(), level: 'error', msg: `cannot listen on ${where.host}:${port}: ${cause}`, fix }))
|
|
2161
|
+
process.exit(1)
|
|
2162
|
+
})
|
|
2163
|
+
s.listen(where)
|
|
1657
2164
|
// SIGTERM/SIGINT: stop accepting, answer what is in flight or arrives on open connections (each then closes), for up
|
|
1658
2165
|
// to BRAIN_DRAIN_MS (10 s), then cut whatever is left; exit 0 either way
|
|
1659
2166
|
const drain = () => {
|