@christianirack/brainllm-runtime 0.4.0 → 0.6.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-ai.js +15 -7
- package/brain-analytics.js +401 -0
- package/brain-budget.js +46 -25
- package/brain-ch.js +35 -8
- package/brain-data.js +4 -1
- package/brain-env.js +5 -4
- package/brain-guard.js +6 -6
- package/brain-live.js +367 -0
- package/brain-mcp.js +87 -18
- package/brain-outbound.js +244 -0
- package/brain-router.js +8 -12
- package/brain-update.js +37 -0
- package/brain-url.js +7 -5
- package/brain.js +6 -3
- package/package.json +1 -1
- package/server/native-sockets.mjs +156 -0
- package/server/server.mjs +319 -85
- package/sw/brain-sw.js +51 -2
- package/ui/error-state.brain +26 -13
- package/ui/tabs.brain +4 -3
package/server/server.mjs
CHANGED
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
//
|
|
7
7
|
// It serves, in this order:
|
|
8
8
|
// GET /_health → 200 {"ok":true,"build":"<sig>"}
|
|
9
|
-
// POST /_r/<hash>/<fn> → RPC to a `server:` function (same origin + X-Brain-RPC
|
|
9
|
+
// POST /_r/<hash>/<fn> → RPC to a `server:` function (same origin + X-Brain-RPC; body, rate and time limits from brain.json `server`)
|
|
10
10
|
// GET routes in the manifest → SSR: load(ctx) → render the view on the DOM shim → page with props and SEO head
|
|
11
|
-
// GET files in dist/ → static (
|
|
11
|
+
// GET files in dist/ → static (the smallest accepted .zst/.br/.gz sibling; /_b/* immutable), clean URLs
|
|
12
12
|
// anything else → 404 page
|
|
13
13
|
// Every response carries the security headers; every HTML response gets a fresh CSP nonce on each <script>.
|
|
14
14
|
//
|
|
@@ -19,7 +19,9 @@
|
|
|
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
21
|
import net from 'node:net'
|
|
22
|
+
import { installNativeSockets } from './native-sockets.mjs'
|
|
22
23
|
import { Readable } from 'node:stream'
|
|
24
|
+
import zlib from 'node:zlib'
|
|
23
25
|
import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'
|
|
24
26
|
import { existsSync, readFileSync, realpathSync, statSync, lstatSync } from 'node:fs'
|
|
25
27
|
import * as Module from 'node:module'
|
|
@@ -66,6 +68,8 @@ const b64u = b => Buffer.from(b).toString('base64url')
|
|
|
66
68
|
const tag = s => createHash('sha1').update(s).digest('base64url').slice(0, 22)
|
|
67
69
|
|
|
68
70
|
// 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']
|
|
72
|
+
const FILTER_OPS = ['eq','ne','gt','gte','lt','lte','between','in','exists','prefix','iprefix','has']
|
|
69
73
|
export class HttpError extends Error {
|
|
70
74
|
constructor(status, message, extra) {
|
|
71
75
|
super(message)
|
|
@@ -81,7 +85,20 @@ function intendedStatus(error) {
|
|
|
81
85
|
const plain = Object.getPrototypeOf(error) === Object.prototype || Object.getPrototypeOf(error) === null
|
|
82
86
|
return Number.isInteger(status) && STATUS_CODES[status] && (error instanceof HttpError && status >= 400 && status <= 599 || plain && status >= 400 && status < 500) ? status : null
|
|
83
87
|
}
|
|
88
|
+
// A deliberate public failure → { status, message } for f.error: a plain `{ message }` without a status (400) or an
|
|
89
|
+
// author's Error subclass with a 4xx `status`. Plain `{ status, message }` keeps its message private (driver detail);
|
|
90
|
+
// built-in errors and bare `Error` messages stay private too.
|
|
91
|
+
const BUILTIN_ERRORS = new Set([Error, TypeError, RangeError, SyntaxError, ReferenceError, EvalError, URIError, AggregateError].map(c => c.prototype))
|
|
92
|
+
function publicError(error) {
|
|
93
|
+
if (!error || typeof error !== 'object' || error instanceof HttpError) return null
|
|
94
|
+
const proto = Object.getPrototypeOf(error), plain = proto === Object.prototype || proto === null
|
|
95
|
+
const typed = error instanceof Error && !BUILTIN_ERRORS.has(proto)
|
|
96
|
+
const status = plain ? (error.status === undefined ? 400 : 0) : error.status, message = error.message
|
|
97
|
+
if (!(plain || typed) || typeof message !== 'string' || !message || message.length > 500) return null
|
|
98
|
+
return Number.isInteger(status) && status >= 400 && status < 500 && STATUS_CODES[status] ? { status, message } : null
|
|
99
|
+
}
|
|
84
100
|
const REDIRECT_CODES = new Set([301, 302, 303, 307, 308])
|
|
101
|
+
const NOINDEX = 'noindex, nofollow'
|
|
85
102
|
function safeLocation(value) {
|
|
86
103
|
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
104
|
return value
|
|
@@ -119,8 +136,11 @@ function defaultError(status, rid, home = '/') {
|
|
|
119
136
|
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
137
|
}
|
|
121
138
|
|
|
122
|
-
// Coding weights are transport preferences, not substring matches (br;q=0 explicitly forbids Brotli).
|
|
123
|
-
|
|
139
|
+
// Coding weights are transport preferences, not substring matches (br;q=0 explicitly forbids Brotli). Ties keep the
|
|
140
|
+
// server order: static files prefer their max-level .br, dynamic bodies prefer zstd (fastest at a Brotli-like ratio).
|
|
141
|
+
// zstd is offered only where node:zlib has it (Node 22.15+).
|
|
142
|
+
const ZSTD = typeof zlib.zstdCompressSync === 'function'
|
|
143
|
+
export function acceptedEncodings(header = '', order = ['br', 'zstd', 'gzip']) {
|
|
124
144
|
const values = new Map()
|
|
125
145
|
for (const part of String(header).split(',')) {
|
|
126
146
|
const [name, ...params] = part.trim().split(';')
|
|
@@ -131,8 +151,19 @@ export function acceptedEncodings(header = '') {
|
|
|
131
151
|
}
|
|
132
152
|
values.set(name.toLowerCase(),q)
|
|
133
153
|
}
|
|
134
|
-
return
|
|
154
|
+
return order.filter(name => name !== 'zstd' || ZSTD).map(name => [name,values.get(name) ?? values.get('*') ?? 0]).filter(([,q]) => q > 0).sort((a,b) => b[1] - a[1]).map(([name]) => name)
|
|
155
|
+
}
|
|
156
|
+
// Response compression (docs/PERFORMANCE.md): text bodies of at least 1 KiB, negotiated per request; a body that
|
|
157
|
+
// already has an encoding, a `no-transform` cache policy or a status without content is sent as is.
|
|
158
|
+
const COMPRESSIBLE = /^(?:text\/|image\/svg\+xml|application\/(?:json|javascript|xml|[\w.-]+\+(?:json|xml)))/i
|
|
159
|
+
const DYNAMIC = ['zstd', 'br', 'gzip']
|
|
160
|
+
const varyEncoding = v => (/accept-encoding/i.test(v || '') ? v : [v, 'Accept-Encoding'].filter(Boolean).join(', '))
|
|
161
|
+
function bodyEncoding(req, status, headers) {
|
|
162
|
+
if (status < 200 || status === 204 || status === 206 || status === 304 || headers['content-encoding'] || !COMPRESSIBLE.test(headers['content-type'] || '') || /no-transform/i.test(headers['cache-control'] || '')) return null
|
|
163
|
+
return acceptedEncodings(req.headers['accept-encoding'], DYNAMIC)[0] || ''
|
|
135
164
|
}
|
|
165
|
+
const encodeBody = (enc, buf) => enc === 'zstd' ? zlib.zstdCompressSync(buf) : enc === 'br' ? zlib.brotliCompressSync(buf, { params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 5, [zlib.constants.BROTLI_PARAM_SIZE_HINT]: buf.length } }) : zlib.gzipSync(buf)
|
|
166
|
+
const encodeStream = enc => enc === 'zstd' ? zlib.createZstdCompress() : enc === 'br' ? zlib.createBrotliCompress({ params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 5 } }) : zlib.createGzip()
|
|
136
167
|
|
|
137
168
|
function googleSignInPage(page) {
|
|
138
169
|
let head = false, templates = 0
|
|
@@ -240,7 +271,7 @@ export function nativeAuthBridge(raw) {
|
|
|
240
271
|
let outgoing, timer, done = false, bytes = 0, requestBytes = 0, failure
|
|
241
272
|
const chunks = []
|
|
242
273
|
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']) {
|
|
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']) {
|
|
244
275
|
const value = req.headers[key]
|
|
245
276
|
if (typeof value === 'string' && value.length <= 8192) headers[key] = value
|
|
246
277
|
}
|
|
@@ -295,7 +326,7 @@ export function nativeAuthBridge(raw) {
|
|
|
295
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(400, 'Invalid native record identifier')
|
|
296
327
|
return '/api/' + name + '/' + id
|
|
297
328
|
}
|
|
298
|
-
const request = async (method, path, body) => {
|
|
329
|
+
const request = async (method, path, body, notFound) => {
|
|
299
330
|
if (response.writableEnded || response.destroyed) throw new HttpError(409, 'The server request has ended')
|
|
300
331
|
if (method !== 'GET' && incoming.method !== 'POST') throw new HttpError(405, 'Native writes require a server function called through POST')
|
|
301
332
|
let bytes = Buffer.alloc(0)
|
|
@@ -315,6 +346,14 @@ export function nativeAuthBridge(raw) {
|
|
|
315
346
|
let value
|
|
316
347
|
try {
|
|
317
348
|
await proxy(req, response, new URL(path, target), rid, (_res, status, data, headers) => {
|
|
349
|
+
if (status === 422) {
|
|
350
|
+
// the backend's own field messages (never driver text): a server function that rethrows answers 422 {errors}
|
|
351
|
+
let fields
|
|
352
|
+
try { fields = JSON.parse(data.toString('utf8'))?.error?.fields } catch {}
|
|
353
|
+
const errors = fields && typeof fields === 'object' && !Array.isArray(fields) ? Object.fromEntries(Object.entries(fields).filter(([k, v]) => typeof v === 'string' && k.length <= 128).slice(0, 64).map(([k, v]) => [k, v.slice(0, 512)])) : {}
|
|
354
|
+
throw new HttpError(422, 'Invalid input', { errors })
|
|
355
|
+
}
|
|
356
|
+
if (status === 404 && notFound) { value = notFound(); return }
|
|
318
357
|
if (status < 200 || status >= 300) throw new HttpError(status >= 400 && status < 500 ? status : 503, 'Native resource request failed')
|
|
319
358
|
if (status === 204) { value = null; return }
|
|
320
359
|
if (!String(headers['content-type'] || '').startsWith('application/json')) throw new HttpError(502, 'Native resource response must be JSON')
|
|
@@ -324,17 +363,80 @@ export function nativeAuthBridge(raw) {
|
|
|
324
363
|
if (response.destroyed || response.writableEnded) throw new HttpError(409, 'The server request has ended')
|
|
325
364
|
return value
|
|
326
365
|
}
|
|
366
|
+
const names = (value, label) => {
|
|
367
|
+
const list = typeof value === 'string' ? value.split(',') : value
|
|
368
|
+
if (!Array.isArray(list) || list.length > 16 || list.some(item => typeof item !== 'string' || !/^[a-z][a-z0-9_]{0,47}$/.test(item))) throw new HttpError(400, `Invalid native ${label} names`)
|
|
369
|
+
return list.join(',')
|
|
370
|
+
}
|
|
327
371
|
return Object.freeze({
|
|
372
|
+
// the HTTP list contract (docs/backend/filters.md, sorting.md, pagination.md): the backend validates declared
|
|
373
|
+
// filters and sorts and answers 422 {errors}; this only rejects shapes the query string cannot express
|
|
328
374
|
list(options = {}) {
|
|
329
|
-
if (!options || typeof options !== 'object' || Array.isArray(options) || Object.keys(options).some(key => !
|
|
330
|
-
const {search
|
|
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
|
-
|
|
375
|
+
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
|
|
377
|
+
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
|
+
const q = new URLSearchParams()
|
|
379
|
+
if (filter !== undefined) {
|
|
380
|
+
if (!filter || typeof filter !== 'object' || Array.isArray(filter) || Object.keys(filter).length > 32) throw new HttpError(400, 'Invalid native list filter')
|
|
381
|
+
const scalar = value => {
|
|
382
|
+
if (typeof value === 'number' && Number.isFinite(value)) return String(value)
|
|
383
|
+
if (typeof value === 'boolean') return String(value)
|
|
384
|
+
if (typeof value === 'string' && Buffer.byteLength(value) <= 256) return value
|
|
385
|
+
throw new HttpError(400, 'Invalid native filter value')
|
|
386
|
+
}
|
|
387
|
+
for (const [field, condition] of Object.entries(filter)) {
|
|
388
|
+
if (!/^[a-z][a-z0-9_]{0,47}(?:\.[a-z][a-z0-9_]{0,47})?$/.test(field)) throw new HttpError(400, 'Invalid native list filter field')
|
|
389
|
+
const ops = condition && typeof condition === 'object' && !Array.isArray(condition) ? Object.entries(condition) : [['eq', condition]]
|
|
390
|
+
if (!ops.length) throw new HttpError(400, 'Invalid native filter value')
|
|
391
|
+
for (const [op, value] of ops) {
|
|
392
|
+
if (!FILTER_OPS.includes(op)) throw new HttpError(400, 'Invalid native filter operator')
|
|
393
|
+
let raw
|
|
394
|
+
if (op === 'in' || op === 'between' || op === 'has') {
|
|
395
|
+
const items = Array.isArray(value) ? value : [value]
|
|
396
|
+
if (!items.length || items.length > 50 || (op === 'between' && items.length !== 2)) throw new HttpError(400, 'Invalid native filter value')
|
|
397
|
+
raw = items.map(scalar)
|
|
398
|
+
if (raw.some(item => item.includes(','))) throw new HttpError(400, 'Invalid native filter value')
|
|
399
|
+
raw = raw.join(',')
|
|
400
|
+
} else raw = scalar(value)
|
|
401
|
+
q.append(op === 'eq' ? field : `${field}[${op}]`, raw)
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
if (search !== undefined) q.set('search', search)
|
|
406
|
+
if (limit !== undefined) q.set('limit', String(limit))
|
|
407
|
+
if (offset !== undefined) q.set('offset', String(offset))
|
|
408
|
+
if (sort !== undefined) {
|
|
409
|
+
const keys = typeof sort === 'string' ? sort.split(',') : sort
|
|
410
|
+
if (!Array.isArray(keys) || !keys.length || keys.length > 4 || keys.some(key => typeof key !== 'string' || !/^-?[a-z][a-z0-9_]{0,47}$/.test(key))) throw new HttpError(400, 'Invalid native list sort')
|
|
411
|
+
q.set('sort', keys.join(','))
|
|
412
|
+
}
|
|
413
|
+
if (cursor !== undefined && after !== undefined) throw new HttpError(400, 'Use either cursor or after')
|
|
414
|
+
for (const [key, value] of [['cursor', cursor], ['after', after]]) if (value !== undefined) {
|
|
415
|
+
if (typeof value !== 'string' || !/^[A-Za-z0-9_-]{1,2048}$/.test(value)) throw new HttpError(400, 'Invalid native list cursor')
|
|
416
|
+
q.set(key, value)
|
|
417
|
+
}
|
|
418
|
+
for (const [key, value] of [['include', include], ['aggregate', aggregate], ['count', count]]) if (value !== undefined) q.set(key, names(value, key))
|
|
419
|
+
const query = q.toString()
|
|
420
|
+
return request('GET', '/api/' + name + (query ? '?' + query : ''))
|
|
421
|
+
},
|
|
422
|
+
get(id, options = {}) {
|
|
423
|
+
if (!options || typeof options !== 'object' || Array.isArray(options) || Object.keys(options).some(key => key !== 'include')) throw new HttpError(400, 'Invalid native get options')
|
|
424
|
+
const path = idPath(id)
|
|
425
|
+
return request('GET', options.include === undefined ? path : path + '?' + new URLSearchParams({include: names(options.include, 'include')}))
|
|
333
426
|
},
|
|
334
|
-
get: id => request('GET', idPath(id)),
|
|
335
427
|
create: values => request('POST', '/api/' + name, values),
|
|
336
428
|
update: (id, values) => request('PUT', idPath(id), values),
|
|
429
|
+
// only the sent fields change (docs/backend/patch.md); null clears an optional field
|
|
430
|
+
async patch(id, values) {
|
|
431
|
+
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
|
+
return request('PATCH', idPath(id), values)
|
|
433
|
+
},
|
|
337
434
|
remove: id => request('DELETE', idPath(id)),
|
|
435
|
+
// an earlier slug of a readable record → its current slug; live, unknown or unreadable slugs → null
|
|
436
|
+
slugRedirect(slug) {
|
|
437
|
+
if (typeof slug !== 'string' || !/^[A-Za-z0-9._~-]{1,512}$/.test(slug)) throw new HttpError(400, 'Invalid native slug')
|
|
438
|
+
return request('GET', '/api/' + name + '/redirects/' + slug, undefined, () => null).then(value => typeof value?.slug === 'string' ? value.slug : null)
|
|
439
|
+
},
|
|
338
440
|
})
|
|
339
441
|
}
|
|
340
442
|
async function login(incoming,response,rid,credentials) {
|
|
@@ -362,9 +464,16 @@ export function nativeAuthBridge(raw) {
|
|
|
362
464
|
} finally {request.destroy();bytes.fill(0)}
|
|
363
465
|
return {user}
|
|
364
466
|
}
|
|
467
|
+
// ctx.backend for one server request (load, actions, RPC, dev sidecar): the same object everywhere
|
|
468
|
+
const backend = (incoming, response, rid) => Object.freeze({
|
|
469
|
+
resource: name => resource(incoming, response, rid, name),
|
|
470
|
+
slugRedirect: (name, slug) => resource(incoming, response, rid, name).slugRedirect(slug),
|
|
471
|
+
login: credentials => login(incoming, response, rid, credentials),
|
|
472
|
+
})
|
|
365
473
|
return {
|
|
366
474
|
proxy,
|
|
367
475
|
resource,
|
|
476
|
+
backend,
|
|
368
477
|
login,
|
|
369
478
|
async prepare(req, res, rid) {
|
|
370
479
|
if (!cookies(req, false)) { req.$nativeUser = null; req.$nativeAuth ||= Promise.resolve(); return }
|
|
@@ -389,6 +498,7 @@ export function nativeAuthBridge(raw) {
|
|
|
389
498
|
headers: () => req.$nativeLoginHeaders || {},
|
|
390
499
|
}
|
|
391
500
|
},
|
|
501
|
+
_socketCookie: req => cookies(req, false),
|
|
392
502
|
close() { stopped = true; for (const request of owned) request.destroy(); agent.destroy() },
|
|
393
503
|
}
|
|
394
504
|
}
|
|
@@ -397,10 +507,10 @@ export function createServer(o = {}) {
|
|
|
397
507
|
const dist = resolve(o.dist || process.cwd())
|
|
398
508
|
const env = Object.freeze({ ...(o.env || ENV) }) // ctx.env: what the supervisor passed, read-only
|
|
399
509
|
const native = nativeAuthBridge(o.backendURL ?? env.BRAIN_BACKEND_URL)
|
|
400
|
-
|
|
401
|
-
const
|
|
402
|
-
const MAX_BODY = 64 * 1024
|
|
510
|
+
if (o.nativeSockets !== undefined && typeof o.nativeSockets !== 'boolean') throw Error('nativeSockets must be an explicit boolean')
|
|
511
|
+
const SITEMAP_BYTES = +(o.sitemapBytes ?? 50 * 1024 * 1024) // protocol: 50 MB uncompressed per sitemap file
|
|
403
512
|
const secret = o.secret ?? env.BRAIN_SECRET ?? ''
|
|
513
|
+
const COMPRESS = o.compress !== false && env.BRAIN_COMPRESS !== 'off' // a proxy that already compresses can opt out
|
|
404
514
|
const sink = o.log || ((level, msg, extra) => console[level === 'error' ? 'error' : 'log'](json({ t: new Date().toISOString(), level, msg, ...extra })))
|
|
405
515
|
const log = (level, msg, extra = {}) => {
|
|
406
516
|
const safe = { ...extra }
|
|
@@ -415,10 +525,39 @@ export function createServer(o = {}) {
|
|
|
415
525
|
safe.private = true; safe.valuesCaptured = false
|
|
416
526
|
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
527
|
}
|
|
528
|
+
// a compiler budget failure (brain-budget.js) keeps its own code, limit, source position and fix
|
|
529
|
+
const b = safe.budgetFailure
|
|
530
|
+
delete safe.budgetFailure
|
|
531
|
+
if (b) Object.assign(safe, b)
|
|
418
532
|
if (typeof safe.path === 'string') safe.path = safe.path.split(/[?#]/, 1)[0].slice(0, 2048)
|
|
419
533
|
sink(level, msg, safe)
|
|
420
534
|
}
|
|
535
|
+
// log fields for a thrown value: its stack (sanitized by log) plus, for B09xx budget failures, the typed fields only
|
|
536
|
+
const trace = e => {
|
|
537
|
+
const out = { stack: String((e && e.stack) || e) }
|
|
538
|
+
if (e && e.kind === 'budget' && /^B09\d\d$/.test(e.code) && typeof e.budget === 'string' && /^[a-zA-Z]{1,16}$/.test(e.budget)) {
|
|
539
|
+
const str = (v, n) => typeof v === 'string' ? v.slice(0, n) : null, pos = v => Number.isSafeInteger(v) && v > 0 ? v : null
|
|
540
|
+
out.budgetFailure = { code: e.code, budget: e.budget, limit: Number.isFinite(e.limit) ? e.limit : null, file: str(e.file, 256), component: str(e.component, 80), line: pos(e.line), column: pos(e.column), fix: str(e.fix, 200) || 'Bound the work or move it to an owned worker.' }
|
|
541
|
+
}
|
|
542
|
+
return out
|
|
543
|
+
}
|
|
421
544
|
const mf = readManifest(dist)
|
|
545
|
+
// brain.json `server` (§11.1), written into the manifest by the build; createServer options and env still win
|
|
546
|
+
const SL = mf.server || {}
|
|
547
|
+
const TIMEOUT = +(o.timeout ?? env.BRAIN_TIMEOUT_MS ?? SL.timeoutMs ?? 10000)
|
|
548
|
+
const LIMIT = +(o.rateLimit ?? env.BRAIN_RPC_LIMIT ?? SL.rpcPerMinute ?? 60) // per IP + function, per minute
|
|
549
|
+
const BODY_KB = +(o.maxBodyKB ?? env.BRAIN_MAX_BODY_KB ?? SL.maxBodyKB ?? 1024)
|
|
550
|
+
if (!Number.isInteger(BODY_KB) || BODY_KB < 1 || BODY_KB > 65536) throw Error('brain.json server.maxBodyKB must be an integer from 1 to 65536 (KB)')
|
|
551
|
+
const MAX_BODY = BODY_KB * 1024 // RPC and form bodies; bounded reads never buffer more
|
|
552
|
+
const TOO_BIG = `Body larger than ${BODY_KB} KB; raise brain.json server.maxBodyKB`
|
|
553
|
+
// a streamed route holds its 200 head this long for load() (#232): a load that settles in time still answers its
|
|
554
|
+
// real status (303 redirect, 404 notFound, deliberate 4xx, 500); a slower one streams and handles them inline
|
|
555
|
+
const STREAM_HOLD = +(o.streamHoldMs ?? 200)
|
|
556
|
+
const tooBig = (rid, path) => (log('warn', 'body too large', { rid, path, limitKB: BODY_KB, fix: 'Raise brain.json server.maxBodyKB (or BRAIN_MAX_BODY_KB), up to 65536' }), TOO_BIG)
|
|
557
|
+
const hasManifestSockets = Object.hasOwn(mf,'nativeSockets')
|
|
558
|
+
if (hasManifestSockets && typeof mf.nativeSockets !== 'boolean') throw Error('Manifest nativeSockets must be an explicit boolean')
|
|
559
|
+
const nativeSockets = o.nativeSockets === undefined ? hasManifestSockets && mf.nativeSockets : o.nativeSockets
|
|
560
|
+
if (nativeSockets && !native) throw Error('Native sockets require a configured native backend')
|
|
422
561
|
// the build's single route table (#187): already in match order, each with its RegExp source (named groups = params)
|
|
423
562
|
const routes = mf.routes.map(r => ({ ...r, rx: r.rx ? new RegExp(r.rx) : exact(r.path) }))
|
|
424
563
|
// the real path: Node imports file URLs through realpath, and the /_b/ hook must name the same modules (a symlinked
|
|
@@ -427,6 +566,7 @@ export function createServer(o = {}) {
|
|
|
427
566
|
// Base path (§23 #148): the app lives under mf.base ("/app/"); routes, files and functions are matched on the app
|
|
428
567
|
// path (base stripped); redirects the app gives as app paths get it back.
|
|
429
568
|
const BASE = mf.base || '/'
|
|
569
|
+
const analyticsTag = native && (o.analytics ?? env.BRAIN_ANALYTICS) !== 'off' && o.analytics !== false ? `<script type="module" async src="${BASE}_brain/analytics.js"></script>` : ''
|
|
430
570
|
const RD = mf.redirects || {}, RDX = (RD.patterns || []).map(p => ({ re: new RegExp(p.re), to: p.to, status: p.status || 301 }))
|
|
431
571
|
// `/old/` and `/old` are the same source (brain-seo normalizes the trailing slash)
|
|
432
572
|
const redirectOf = path => {
|
|
@@ -440,6 +580,28 @@ export function createServer(o = {}) {
|
|
|
440
580
|
return null
|
|
441
581
|
}
|
|
442
582
|
const withBase = u => (typeof u === 'string' && BASE !== '/' && u.startsWith('/') && !u.startsWith('//') && !(u + '/').startsWith(BASE) ? BASE + u.slice(1) : u)
|
|
583
|
+
// §33 crawler-safe URLs: the redirect rule, the trailing-slash policy (brain.json seo.trailingSlash, default "never")
|
|
584
|
+
// and the canonical host (seo.canonicalHost → site) are folded into ONE hop; loopback hosts are never redirected
|
|
585
|
+
// (a proxy that does not forward Host, local runs). The query is kept unless a rule's target has its own.
|
|
586
|
+
const SLASH = RD.slash || 'never', HOST = typeof RD.host === 'string' && /^https?:\/\//.test(RD.host) ? new URL(RD.host) : null
|
|
587
|
+
const loopback = h => !h || /^(?:localhost|127(?:\.\d+){3}|\[::1\])(?::\d+)?$/i.test(h)
|
|
588
|
+
const canonicalOf = (req, url) => {
|
|
589
|
+
let path = url.pathname, query = url.search, status = 301, moved = false
|
|
590
|
+
const to = redirectOf(path)
|
|
591
|
+
if (to) {
|
|
592
|
+
if (/^https?:\/\//.test(to[0])) return [to[0], to[1], true]
|
|
593
|
+
const q = to[0].indexOf('?')
|
|
594
|
+
path = q < 0 ? to[0] : to[0].slice(0, q)
|
|
595
|
+
if (q >= 0) query = to[0].slice(q)
|
|
596
|
+
status = to[1]
|
|
597
|
+
moved = true
|
|
598
|
+
}
|
|
599
|
+
if (path !== '/' && SLASH === 'never' && path.endsWith('/')) path = path.replace(/\/+$/, '') || '/'
|
|
600
|
+
else if (path !== '/' && SLASH === 'always' && !path.endsWith('/') && !/\.[^/]*$/.test(path)) path += '/'
|
|
601
|
+
const host = HOST && !loopback(req.headers.host) && req.headers.host.toLowerCase() !== HOST.host
|
|
602
|
+
if (!moved && !host && path === url.pathname) return null
|
|
603
|
+
return [(host ? HOST.origin : '') + withBase(path) + query, status, host]
|
|
604
|
+
}
|
|
443
605
|
hook(distURL, BASE)
|
|
444
606
|
|
|
445
607
|
// ---------- helpers ----------
|
|
@@ -476,6 +638,16 @@ export function createServer(o = {}) {
|
|
|
476
638
|
delete headers['content-length']; delete headers['content-encoding']; delete headers.etag
|
|
477
639
|
}
|
|
478
640
|
}
|
|
641
|
+
const enc = COMPRESS ? bodyEncoding(res.req, status, headers) : null
|
|
642
|
+
if (enc != null) {
|
|
643
|
+
headers = { ...headers, vary: varyEncoding(headers.vary) }
|
|
644
|
+
const size = body ? Buffer.byteLength(body) : 0
|
|
645
|
+
if (enc && size >= 1024 && res.req.method !== 'HEAD') {
|
|
646
|
+
body = encodeBody(enc, Buffer.isBuffer(body) ? body : Buffer.from(body))
|
|
647
|
+
headers['content-encoding'] = enc
|
|
648
|
+
delete headers['content-length']
|
|
649
|
+
}
|
|
650
|
+
}
|
|
479
651
|
res.writeHead(status, { ...SECURITY, ...(server.draining ? { connection: 'close' } : {}), ...headers })
|
|
480
652
|
res.end(res.req.method === 'HEAD' || status === 204 || status === 205 || status === 304 ? undefined : body)
|
|
481
653
|
}
|
|
@@ -488,13 +660,18 @@ export function createServer(o = {}) {
|
|
|
488
660
|
if (inm && inm.split(',').some(t => t.trim().replace(/^W\//, '') === etag.replace(/^W\//, ''))) return send(res, 304, '', headers)
|
|
489
661
|
}
|
|
490
662
|
const nonce = randomBytes(16).toString('base64')
|
|
663
|
+
// behavior analytics (docs/backend/analytics.md): with a native backend every page loads its collector as an async
|
|
664
|
+
// module served by that backend, outside the build and the route budgets; BRAIN_ANALYTICS=off skips the tag
|
|
665
|
+
if (analyticsTag && !page.includes(analyticsTag)) page = page.includes('</head>') ? page.replace('</head>', analyticsTag + '</head>') : page + analyticsTag
|
|
491
666
|
const body = page.replace(/<script(?![^>]*\bnonce=)/g, `<script nonce="${nonce}"`)
|
|
492
667
|
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 })
|
|
493
668
|
}
|
|
494
669
|
const notFound = (req, res) => failPage(res, 404, res.getHeader('x-request-id'))
|
|
670
|
+
// `gone()` from load (§33): 410 tells crawlers the URL was removed for good; views/410.brain, else the 404 page
|
|
671
|
+
const missing = (req, res, x) => (x && x.gone === true ? failPage(res, 410, res.getHeader('x-request-id')) : notFound(req, res))
|
|
495
672
|
const failPage = (res, status, rid) => {
|
|
496
673
|
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`
|
|
674
|
+
const custom = (status === 404 && mf.notFound) || (mf.errors && mf.errors[status]) || (status === 410 && !file('410.html') ? mf.notFound : `${status}.html`)
|
|
498
675
|
const path = typeof custom === 'string' && file(custom)
|
|
499
676
|
return html(res, status, path ? readFileSync(path, 'utf8') : defaultError(status, rid, BASE))
|
|
500
677
|
}
|
|
@@ -521,7 +698,7 @@ export function createServer(o = {}) {
|
|
|
521
698
|
const { user, session: ses } = native ? { user: req.$nativeUser ?? null, session: native.session(req, res, res.getHeader('x-request-id')) } : sessionFrom(cookies, secret)
|
|
522
699
|
return {
|
|
523
700
|
url, query: Object.fromEntries(url.searchParams), cookies, user, session: ses, env,
|
|
524
|
-
...(native ? { backend:
|
|
701
|
+
...(native ? { backend: native.backend(req, res, res.getHeader('x-request-id')) } : {}),
|
|
525
702
|
// `prefetch`: the router fetched this page ahead of a click (§19); a `load` with side effects can skip them
|
|
526
703
|
prefetch: req.headers['x-brain-prefetch'] === '1',
|
|
527
704
|
req: { method: req.method, headers: req.headers, ip: ipOf(req) }, ...extra,
|
|
@@ -601,38 +778,42 @@ export function createServer(o = {}) {
|
|
|
601
778
|
for (const m of route.middleware || []) middleware.push(await load(m))
|
|
602
779
|
r = await runChain({ guards, middleware, ctx })
|
|
603
780
|
} catch (e) {
|
|
604
|
-
log('error', 'guard failed', { rid, path: url.pathname,
|
|
781
|
+
log('error', 'guard failed', { rid, path: url.pathname, ...trace(e) })
|
|
605
782
|
return errorPage(req, res, route, params, url, rid)
|
|
606
783
|
}
|
|
607
|
-
|
|
784
|
+
// 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() })
|
|
608
786
|
if (r.deny !== undefined) return forbidden(req, res, url, rid, r.deny)
|
|
609
787
|
extra = r.headers
|
|
610
788
|
}
|
|
611
|
-
|
|
789
|
+
// what load() thrown or returned instead of props answers: redirect, notFound() (F11), deliberate status, failure
|
|
790
|
+
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() })
|
|
792
|
+
if ((e instanceof HttpError && e.status === 404) || (e && e.notFound === true)) return missing(req, res, e) // `notFound()` 404 / `gone()` 410
|
|
793
|
+
const status = intendedStatus(e) || 500
|
|
794
|
+
log('error', 'load failed', { rid, path: url.pathname, status, ...trace(e) })
|
|
795
|
+
return status === 500 ? errorPage(req, res, route, params, url, rid) : failPage(res, status, rid)
|
|
796
|
+
}
|
|
797
|
+
if (route.stream && route.server && !fixed && !forms && status === 200 && !res.$capture) return streamed(req, res, route, params, url, rid, ctx, keep(extra || {}), loadOutcome)
|
|
612
798
|
try {
|
|
613
799
|
if (!fixed && route.server) {
|
|
614
800
|
const m = await load(route.server)
|
|
615
801
|
if (typeof m.load === 'function') props = (await withTimeout(Promise.resolve(m.load(ctx)), TIMEOUT)) ?? {}
|
|
616
802
|
}
|
|
617
803
|
} catch (e) {
|
|
618
|
-
|
|
619
|
-
if ((e instanceof HttpError && e.status === 404) || (e && e.notFound === true)) return notFound(req, res) // `throw notFound()` (F11)
|
|
620
|
-
const status = intendedStatus(e) || 500
|
|
621
|
-
log('error', 'load failed', { rid, path: url.pathname, status, stack: String((e && e.stack) || e) })
|
|
622
|
-
return status === 500 ? errorPage(req, res, route, params, url, rid) : failPage(res, status, rid)
|
|
804
|
+
return loadOutcome(e)
|
|
623
805
|
}
|
|
624
|
-
if (props && props.redirect
|
|
625
|
-
if (props && props.notFound === true) return notFound(req, res) // `return notFound()` (F11)
|
|
806
|
+
if (props && (props.redirect || props.notFound === true)) return loadOutcome(props) // `return redirect()` / `return notFound()`
|
|
626
807
|
let body, seo, failed
|
|
627
808
|
try {
|
|
628
809
|
const at = { path: url.pathname, params: params || {}, query: Object.fromEntries(url.searchParams) } // route() (§41.5)
|
|
629
810
|
;({ body, seo, failed } = await (queue = queue.then(() => render(route, props, forms, at), () => render(route, props, forms, at))))
|
|
630
811
|
} catch (e) {
|
|
631
|
-
log('error', 'render failed', { rid, path: url.pathname,
|
|
812
|
+
log('error', 'render failed', { rid, path: url.pathname, ...trace(e) })
|
|
632
813
|
return failPage(res, 500, rid)
|
|
633
814
|
}
|
|
634
815
|
// a component that failed shows its fallback inside the page (still 200); the error goes to the log with the rid
|
|
635
|
-
for (const f of failed || []) log('error', 'component failed during SSR', { rid, path: url.pathname, comp: f.comp, where: f.w,
|
|
816
|
+
for (const f of failed || []) log('error', 'component failed during SSR', { rid, path: url.pathname, comp: f.comp, where: f.w, ...trace(f.e) })
|
|
636
817
|
const page = readFileSync(file(route.page), 'utf8')
|
|
637
818
|
.replace('<!--brain:ssr-->', () => body)
|
|
638
819
|
.replace('<!--brain:props-->', () => jsonInHtml(props))
|
|
@@ -644,19 +825,33 @@ export function createServer(o = {}) {
|
|
|
644
825
|
html(res, status, out, keep({ ...extra, ...ctx.session.headers() }), own ? undefined : 'W/"' + tag(out) + '"')
|
|
645
826
|
}
|
|
646
827
|
// Streaming SSR (#232, `route.stream`: the build sets it when the view's <meta seo> is static and it has a load()):
|
|
647
|
-
//
|
|
648
|
-
//
|
|
649
|
-
//
|
|
650
|
-
|
|
828
|
+
// load() starts first and the head waits up to STREAM_HOLD for it: a load that redirects, is not found or throws a
|
|
829
|
+
// status in that time gets the same answer as a non-streamed route (`outcome`). Past it, the page up to the region
|
|
830
|
+
// (head, CSS, layout shell) is sent, the view when load() ends. The status and headers are out by then, so a late
|
|
831
|
+
// redirect becomes location.replace and a late failure an inline notice with the request id (both logged); load()
|
|
832
|
+
// cannot change the session on a streamed route.
|
|
833
|
+
async function streamed(req, res, route, params, url, rid, ctx, extra, outcome) {
|
|
651
834
|
const [top, bottom = ''] = readFileSync(file(route.page), 'utf8').split('<!--brain:ssr-->')
|
|
652
835
|
const sm = await load(route.server)
|
|
836
|
+
const loadP = typeof sm.load === 'function' ? withTimeout(Promise.resolve().then(() => sm.load(ctx)), TIMEOUT) : Promise.resolve(null)
|
|
837
|
+
loadP.catch(() => {}) // handled below; never an unhandled rejection while the loading view renders
|
|
838
|
+
let timer
|
|
839
|
+
const early = await Promise.race([loadP.then(v => ({ v }), e => ({ e })), new Promise(r => (timer = setTimeout(r, STREAM_HOLD)))])
|
|
840
|
+
clearTimeout(timer)
|
|
841
|
+
if (early && (early.e !== undefined || (early.v && (early.v.redirect || early.v.notFound === true)))) return outcome(early.e !== undefined ? early.e : early.v)
|
|
653
842
|
const seoF = sm.seo || (await load(route.view)).default.seo
|
|
654
843
|
if (route.i18n) await load(route.i18n)
|
|
655
844
|
const nonce = randomBytes(16).toString('base64'), n = h => h.replace(/<script(?![^>]*\bnonce=)/g, `<script nonce="${nonce}"`)
|
|
845
|
+
const enc = COMPRESS && req.method !== 'HEAD' ? acceptedEncodings(req.headers['accept-encoding'], DYNAMIC)[0] : null
|
|
656
846
|
res.writeHead(200, {
|
|
657
847
|
...SECURITY, 'content-type': MIME['.html'], 'content-security-policy': withNonce(mf.csp || CSP0, nonce), 'cache-control': 'no-cache',
|
|
658
848
|
...(mf.build ? { 'x-brain-build': mf.build } : {}), ...extra, ...ctx.session.headers(),
|
|
849
|
+
...(COMPRESS ? { vary: varyEncoding(extra.vary) } : {}), ...(enc ? { 'content-encoding': enc } : {}),
|
|
659
850
|
})
|
|
851
|
+
// each chunk is flushed through the encoder, so the head still reaches the browser before load() ends
|
|
852
|
+
const zs = enc ? encodeStream(enc) : null
|
|
853
|
+
if (zs) zs.pipe(res)
|
|
854
|
+
const write = s => (zs ? (zs.write(s), zs.flush()) : res.write(s))
|
|
660
855
|
globalThis.__brainLocale = route.locale || null
|
|
661
856
|
let seo0 = null
|
|
662
857
|
try {
|
|
@@ -664,25 +859,22 @@ export function createServer(o = {}) {
|
|
|
664
859
|
} finally {
|
|
665
860
|
globalThis.__brainLocale = null
|
|
666
861
|
}
|
|
667
|
-
// load() starts now: the loading view (#253) renders while it runs
|
|
668
|
-
const loadP = typeof sm.load === 'function' ? withTimeout(Promise.resolve().then(() => sm.load(ctx)), TIMEOUT) : Promise.resolve(null)
|
|
669
|
-
loadP.catch(() => {}) // handled below; never an unhandled rejection while the loading view renders
|
|
670
862
|
const at = { path: url.pathname, params: params || {}, query: Object.fromEntries(url.searchParams) }
|
|
671
|
-
|
|
863
|
+
write(n(head(top, seo0, url.pathname)))
|
|
672
864
|
// QA #253: the folder's _loading.brain goes in the region with the first chunk (SPEC: shows while load runs),
|
|
673
865
|
// between two marker comments; the last chunk starts with a script that removes them and what is between
|
|
674
866
|
let clear = ''
|
|
675
|
-
if (route.loading) {
|
|
867
|
+
if (route.loading && !early) { // the loading view (#253) only when load() is still running
|
|
676
868
|
try {
|
|
677
869
|
const lr = { ...route, view: route.loading, layouts: [], server: null }
|
|
678
870
|
const { body } = await (queue = queue.then(() => render(lr, {}, null, at), () => render(lr, {}, null, at)))
|
|
679
|
-
|
|
871
|
+
write(n(`<!--brain:loading-->${body}<!--/brain:loading-->`))
|
|
680
872
|
clear = `<script>{const s=document.currentScript;let x=0;for(const c of[...s.parentNode.childNodes]){if(c.data=='brain:loading')x=1;if(x)c.remove();if(c.data=='/brain:loading')x=0}s.remove()}</script>`
|
|
681
873
|
} catch (e) {
|
|
682
|
-
log('error', 'loading view failed (streamed)', { rid, path: url.pathname,
|
|
874
|
+
log('error', 'loading view failed (streamed)', { rid, path: url.pathname, ...trace(e) })
|
|
683
875
|
}
|
|
684
876
|
}
|
|
685
|
-
const end = (body, props = {}, b = bottom) => res.end(n(clear + body + b.replace('<!--brain:props-->', () => jsonInHtml(props))))
|
|
877
|
+
const end = (body, props = {}, b = bottom) => (zs || res).end(n(clear + body + b.replace('<!--brain:props-->', () => jsonInHtml(props))))
|
|
686
878
|
// QA #289: the page closes with the _error.brain template (its client mounts the error view with { message, rid }),
|
|
687
879
|
// never with the route's own mount script, which would replace the error with the view rendered from {}. Without an
|
|
688
880
|
// _error.brain the notice stays: the view's mount script is left out.
|
|
@@ -699,19 +891,20 @@ export function createServer(o = {}) {
|
|
|
699
891
|
props = (await loadP) ?? {}
|
|
700
892
|
if (props.redirect || props.notFound === true) throw props
|
|
701
893
|
} catch (e) {
|
|
702
|
-
|
|
894
|
+
// 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>`)
|
|
703
896
|
// a streamed page already sent its 200 head: the region gets the not-found notice and the page is noindex
|
|
704
897
|
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, ''))
|
|
705
|
-
log('error', 'load failed (streamed)', { rid, path: url.pathname, status: 200,
|
|
898
|
+
log('error', 'load failed (streamed)', { rid, path: url.pathname, status: 200, ...trace(e) }) // headers already out
|
|
706
899
|
return fail()
|
|
707
900
|
}
|
|
708
901
|
try {
|
|
709
902
|
const { body, failed } = await (queue = queue.then(() => render(route, props, null, at), () => render(route, props, null, at)))
|
|
710
|
-
for (const f of failed || []) log('error', 'component failed during SSR', { rid, path: url.pathname, comp: f.comp, where: f.w,
|
|
903
|
+
for (const f of failed || []) log('error', 'component failed during SSR', { rid, path: url.pathname, comp: f.comp, where: f.w, ...trace(f.e) })
|
|
711
904
|
const pm = params && Object.keys(params).length ? `<meta name="brain-params" content="${esc(JSON.stringify(params))}">` : ''
|
|
712
905
|
end(pm + body, props)
|
|
713
906
|
} catch (e) {
|
|
714
|
-
log('error', 'render failed (streamed)', { rid, path: url.pathname, status: 200,
|
|
907
|
+
log('error', 'render failed (streamed)', { rid, path: url.pathname, status: 200, ...trace(e) })
|
|
715
908
|
await fail()
|
|
716
909
|
}
|
|
717
910
|
}
|
|
@@ -728,6 +921,7 @@ export function createServer(o = {}) {
|
|
|
728
921
|
}
|
|
729
922
|
// deny → views/403.brain (manifest `forbidden`, props.reason) or the static 403.html, always status 403
|
|
730
923
|
function forbidden(req, res, url, rid, reason) {
|
|
924
|
+
res.setHeader('x-robots-tag', NOINDEX) // a members-only page never enters the index (§33)
|
|
731
925
|
if (!wantsHtml(req)) return failPage(res, 403, rid)
|
|
732
926
|
if (mf.forbidden) return ssr(req, res, mf.forbidden, {}, url, rid, null, 403, { reason })
|
|
733
927
|
const f = file('403.html')
|
|
@@ -802,7 +996,7 @@ export function createServer(o = {}) {
|
|
|
802
996
|
try {
|
|
803
997
|
raw = await body(req, MAX_BODY)
|
|
804
998
|
} catch {
|
|
805
|
-
return fail(413,
|
|
999
|
+
return fail(413, tooBig(rid, url.pathname))
|
|
806
1000
|
}
|
|
807
1001
|
if (native) await native.prepare(req, res, rid)
|
|
808
1002
|
const ctx = context(req, res, url, { params: {} })
|
|
@@ -810,11 +1004,11 @@ export function createServer(o = {}) {
|
|
|
810
1004
|
try {
|
|
811
1005
|
f = (await load(modPath))[fn]
|
|
812
1006
|
} catch (e) {
|
|
813
|
-
log('error', 'rpc module failed to load', { rid, fn: hash + '/' + fn,
|
|
1007
|
+
log('error', 'rpc module failed to load', { rid, fn: hash + '/' + fn, ...trace(e) })
|
|
814
1008
|
return fail(500, 'Internal error', { rid })
|
|
815
1009
|
}
|
|
816
1010
|
const r = await callRpc({ fn: f, args: raw, ctx, timeout: TIMEOUT })
|
|
817
|
-
if (r.cause) log('error', r.status === 504 ? 'rpc timeout' : 'rpc failed', { rid, fn: hash + '/' + fn, ms: TIMEOUT,
|
|
1011
|
+
if (r.cause) log('error', r.status === 504 ? 'rpc timeout' : 'rpc failed', { rid, fn: hash + '/' + fn, ms: TIMEOUT, ...trace(r.cause) })
|
|
818
1012
|
const b = r.status >= 500 ? { ...r.body, rid } : r.body
|
|
819
1013
|
send(res, r.status, json(b), { 'content-type': MIME['.json'], 'cache-control': 'no-store', ...(r.headers || {}), ...ctx.session.headers() })
|
|
820
1014
|
}
|
|
@@ -842,7 +1036,7 @@ export function createServer(o = {}) {
|
|
|
842
1036
|
try {
|
|
843
1037
|
raw = await body(req, MAX_BODY)
|
|
844
1038
|
} catch {
|
|
845
|
-
return plain(413,
|
|
1039
|
+
return plain(413, tooBig(rid, url.pathname))
|
|
846
1040
|
}
|
|
847
1041
|
const id = new URLSearchParams(raw).get('_brain') || '', modPath = mf.rpc[id.split('/')[0]]
|
|
848
1042
|
if (!modPath) return plain(400, 'Unknown form')
|
|
@@ -852,12 +1046,13 @@ export function createServer(o = {}) {
|
|
|
852
1046
|
try {
|
|
853
1047
|
module = await load(modPath)
|
|
854
1048
|
rules = await load('_server/brain-form.js')
|
|
1049
|
+
if (route.i18n) await load(route.i18n) // the page's dictionary: validation messages in its language (§37)
|
|
855
1050
|
} catch (e) {
|
|
856
|
-
log('error', 'form module failed to load', { rid, fn: id,
|
|
1051
|
+
log('error', 'form module failed to load', { rid, fn: id, ...trace(e) })
|
|
857
1052
|
return failPage(res, 500, rid)
|
|
858
1053
|
}
|
|
859
|
-
const r = await callForm({ module, raw, ctx, rules, key: ipOf(req) + ' ' + id, limit: LIMIT, timeout: TIMEOUT })
|
|
860
|
-
if (r.cause) log('error', 'form failed', { rid, fn: id,
|
|
1054
|
+
const r = await callForm({ module, raw, ctx, rules, key: ipOf(req) + ' ' + id, limit: LIMIT, timeout: TIMEOUT, locale: route.locale })
|
|
1055
|
+
if (r.cause) log('error', 'form failed', { rid, fn: id, ...trace(r.cause) })
|
|
861
1056
|
if (r.status === 303) return send(res, 303, '', { location: withBase(r.location), 'cache-control': 'no-store', ...ctx.session.headers() })
|
|
862
1057
|
if (r.status === 422) return ssr(req, res, route, params, url, rid, r.state, 422)
|
|
863
1058
|
plain(r.status, r.body.error, r.headers)
|
|
@@ -895,11 +1090,13 @@ export function createServer(o = {}) {
|
|
|
895
1090
|
for (const p of Array.isArray(list) ? list : []) {
|
|
896
1091
|
const path = fill(r.path, p)
|
|
897
1092
|
if (!path) continue
|
|
898
|
-
|
|
1093
|
+
const loc = origin + withBase(path)
|
|
1094
|
+
if (loc.length > 2048) log('warn', 'sitemap(): URL longer than 2048 characters left out; fix: shorten the slug', { view: r.view, loc: loc.slice(0, 120) })
|
|
1095
|
+
else if (urls.length < MAX_URLS) urls.push({ loc, lastmod: day(p.lastmod) })
|
|
899
1096
|
else dropped++
|
|
900
1097
|
}
|
|
901
1098
|
} catch (e) {
|
|
902
|
-
log('error', 'sitemap() failed', { view: r.view,
|
|
1099
|
+
log('error', 'sitemap() failed', { view: r.view, ...trace(e) })
|
|
903
1100
|
}
|
|
904
1101
|
}
|
|
905
1102
|
if (dropped) log('warn', 'sitemap(): more than 50000 URLs, the rest are not listed', { dropped })
|
|
@@ -912,6 +1109,23 @@ export function createServer(o = {}) {
|
|
|
912
1109
|
if (!dynRun) dynRun = collect(origin).finally(() => (dynRun = null))
|
|
913
1110
|
return dynRun
|
|
914
1111
|
}
|
|
1112
|
+
// §33 S01: every sitemap file holds at most 50 000 URLs and SITEMAP_BYTES bytes of UTF-8 XML; the dynamic URLs fill
|
|
1113
|
+
// /sitemap-dynamic.xml, /sitemap-dynamic-2.xml, … in sitemap() order, so the split is deterministic
|
|
1114
|
+
const SET_HEAD = '<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
|
|
1115
|
+
const SET_FOOT = '</urlset>\n'
|
|
1116
|
+
const bytes = t => Buffer.byteLength(t)
|
|
1117
|
+
function pages(urls) {
|
|
1118
|
+
const out = [[]]
|
|
1119
|
+
let size = bytes(SET_HEAD + SET_FOOT)
|
|
1120
|
+
for (const u of urls) {
|
|
1121
|
+
const line = ` <url><loc>${esc(u.loc)}</loc>${u.lastmod ? `<lastmod>${esc(u.lastmod)}</lastmod>` : ''}</url>\n`
|
|
1122
|
+
const n = bytes(line)
|
|
1123
|
+
if (out.at(-1).length && (out.at(-1).length >= MAX_URLS || size + n > SITEMAP_BYTES)) out.push([]), (size = bytes(SET_HEAD + SET_FOOT))
|
|
1124
|
+
out.at(-1).push(line)
|
|
1125
|
+
size += n
|
|
1126
|
+
}
|
|
1127
|
+
return out.map(p => p.join(''))
|
|
1128
|
+
}
|
|
915
1129
|
async function serveSitemap(req, res, url) {
|
|
916
1130
|
const f = file('/sitemap.xml')
|
|
917
1131
|
if (!f) return false
|
|
@@ -919,22 +1133,24 @@ export function createServer(o = {}) {
|
|
|
919
1133
|
const origin = [...text.matchAll(/<loc>(https?:\/\/[^<]+)<\/loc>/g)].map(m => URL.canParse(m[1]) && new URL(m[1]).origin).find(Boolean)
|
|
920
1134
|
if (!origin) return false
|
|
921
1135
|
const index = text.includes('<sitemapindex')
|
|
922
|
-
const
|
|
923
|
-
// a urlset that would pass
|
|
924
|
-
const split = !index && (text.match(/<url>/g) || []).length +
|
|
1136
|
+
const sets = pages(await dynamicUrls(origin))
|
|
1137
|
+
// a urlset that would pass either limit with the dynamic URLs is split: index → static file + dynamic file(s)
|
|
1138
|
+
const split = !index && (sets.length > 1 || (text.match(/<url>/g) || []).length + (sets[0].match(/<url>/g) || []).length > MAX_URLS || bytes(text) + bytes(sets[0]) > SITEMAP_BYTES)
|
|
925
1139
|
const xml = body => send(res, 200, req.method === 'HEAD' ? '' : body, { 'content-type': MIME['.xml'], 'cache-control': 'public, max-age=300' })
|
|
926
1140
|
if (url.pathname === '/sitemap-static.xml') return split ? (xml(text), true) : false
|
|
927
|
-
|
|
1141
|
+
const name = i => (i ? `/sitemap-dynamic-${i + 1}.xml` : '/sitemap-dynamic.xml')
|
|
928
1142
|
const child = p => ` <sitemap><loc>${esc(origin + withBase(p))}</loc></sitemap>\n`
|
|
929
|
-
const
|
|
1143
|
+
const kids = sets.map((_, i) => child(name(i))).join('')
|
|
1144
|
+
if (url.pathname !== '/sitemap.xml') {
|
|
1145
|
+
const i = sets.findIndex((_, i) => name(i) === url.pathname)
|
|
1146
|
+
return i >= 0 && (index || split) ? (xml(SET_HEAD + sets[i] + SET_FOOT), true) : false
|
|
1147
|
+
}
|
|
930
1148
|
xml(
|
|
931
|
-
|
|
932
|
-
?
|
|
933
|
-
:
|
|
934
|
-
?
|
|
935
|
-
:
|
|
936
|
-
? `<?xml version="1.0" encoding="UTF-8"?>\n<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${child('/sitemap-static.xml')}${child('/sitemap-dynamic.xml')}</sitemapindex>\n`
|
|
937
|
-
: text.replace('</urlset>', set + '</urlset>'),
|
|
1149
|
+
index
|
|
1150
|
+
? text.replace('</sitemapindex>', `${kids}</sitemapindex>`)
|
|
1151
|
+
: split
|
|
1152
|
+
? `<?xml version="1.0" encoding="UTF-8"?>\n<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${child('/sitemap-static.xml')}${kids}</sitemapindex>\n`
|
|
1153
|
+
: text.replace('</urlset>', sets[0] + '</urlset>'),
|
|
938
1154
|
)
|
|
939
1155
|
return true
|
|
940
1156
|
}
|
|
@@ -963,7 +1179,7 @@ export function createServer(o = {}) {
|
|
|
963
1179
|
function serveStatic(req, res, url) {
|
|
964
1180
|
const p = publicStaticPath(url.pathname)
|
|
965
1181
|
if (p === null) return false
|
|
966
|
-
const candidates = p.endsWith('/') ? [p + 'index.html'] : [p, p + '.html', p + '/index.html']
|
|
1182
|
+
const candidates = p.endsWith('/') ? [p + 'index.html', ...(p.length > 1 ? [p.slice(0, -1) + '.html'] : [])] : [p, p + '.html', p + '/index.html']
|
|
967
1183
|
for (const c of candidates) {
|
|
968
1184
|
const f = file(c)
|
|
969
1185
|
if (!f) continue
|
|
@@ -982,13 +1198,16 @@ export function createServer(o = {}) {
|
|
|
982
1198
|
'cache-control': p.startsWith('/_b/') ? 'public, max-age=31536000, immutable' : p === '/sw.js' ? 'no-cache' : 'public, max-age=3600',
|
|
983
1199
|
vary: 'accept-encoding',
|
|
984
1200
|
}
|
|
1201
|
+
// the smallest precompressed sibling the client accepts (the build keeps only those smaller than the file)
|
|
1202
|
+
let best = null
|
|
985
1203
|
for (const enc of acceptedEncodings(req.headers['accept-encoding'])) {
|
|
986
|
-
const
|
|
987
|
-
const
|
|
988
|
-
if (encoded) {
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
}
|
|
1204
|
+
const encoded = file(c + (enc === 'br' ? '.br' : enc === 'zstd' ? '.zst' : '.gz'))
|
|
1205
|
+
const size = encoded ? statSync(encoded).size : Infinity
|
|
1206
|
+
if (encoded && (!best || size < best.size)) best = { enc, encoded, size }
|
|
1207
|
+
}
|
|
1208
|
+
if (best) {
|
|
1209
|
+
send(res, 200, req.method === 'HEAD' ? '' : readFileSync(best.encoded), { ...headers, 'content-encoding': best.enc })
|
|
1210
|
+
return true
|
|
992
1211
|
}
|
|
993
1212
|
send(res, 200, req.method === 'HEAD' ? '' : readFileSync(f), headers)
|
|
994
1213
|
return true
|
|
@@ -1018,12 +1237,9 @@ export function createServer(o = {}) {
|
|
|
1018
1237
|
req.$brainPath = url.pathname
|
|
1019
1238
|
if (native && /^\/(?:api(?:\/|$)|_brain(?:\/|$)|s\/)/.test(url.pathname)) return await native.proxy(req,res,url,rid,send)
|
|
1020
1239
|
// redirects.json (manifest `redirects`, brain-seo server_json): exact sources first, then patterns, on the app path;
|
|
1021
|
-
// same-site targets get the base, the query is kept when the target has none
|
|
1022
|
-
const to =
|
|
1023
|
-
if (to
|
|
1024
|
-
const loc = /^https?:\/\//.test(to[0]) ? to[0] : withBase(to[0]) + (to[0].includes('?') ? '' : url.search)
|
|
1025
|
-
return send(res, to[1], '', { location: loc, 'cache-control': to[1] === 301 || to[1] === 308 ? 'public, max-age=3600' : 'no-store' }, true)
|
|
1026
|
-
}
|
|
1240
|
+
// same-site targets get the base, the query is kept when the target has none; slash policy and host in the same hop
|
|
1241
|
+
const to = (req.method === 'GET' || req.method === 'HEAD') && canonicalOf(req, url)
|
|
1242
|
+
if (to) return send(res, to[1], '', { location: to[0], 'cache-control': to[1] === 301 || to[1] === 308 ? 'public, max-age=3600' : 'no-store' }, to[2])
|
|
1027
1243
|
const r = /^\/_r\/([\w-]+)\/([\w$]+)$/.exec(url.pathname)
|
|
1028
1244
|
if (r) return await rpc(req, res, r[1], r[2], url, rid)
|
|
1029
1245
|
if (url.pathname.startsWith('/_r/')) return send(res, 404, json({ error: 'Unknown function' }), { 'content-type': MIME['.json'] })
|
|
@@ -1039,16 +1255,17 @@ export function createServer(o = {}) {
|
|
|
1039
1255
|
return await (post ? formPost : ssr)(req, res, route, m.groups ? { ...m.groups } : {}, url, rid)
|
|
1040
1256
|
}
|
|
1041
1257
|
if (post) return send(res, 405, 'Method not allowed', { allow: 'GET, HEAD' })
|
|
1042
|
-
if (DYN.length && (
|
|
1258
|
+
if (DYN.length && /^\/sitemap(-static|-dynamic(-\d+)?)?\.xml$/.test(url.pathname) && (await serveSitemap(req, res, url))) return
|
|
1043
1259
|
if (serveStatic(req, res, url)) return
|
|
1044
1260
|
notFound(req, res)
|
|
1045
1261
|
} catch (e) {
|
|
1046
|
-
log('error', 'unhandled', { rid, path: req.url,
|
|
1262
|
+
log('error', 'unhandled', { rid, path: req.url, ...trace(e) })
|
|
1047
1263
|
if (!res.headersSent) failPage(res, intendedStatus(e) || 500, rid)
|
|
1048
1264
|
else res.end()
|
|
1049
1265
|
}
|
|
1050
1266
|
})
|
|
1051
1267
|
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})
|
|
1052
1269
|
server.headersTimeout = 15000
|
|
1053
1270
|
server.requestTimeout = 30000
|
|
1054
1271
|
return server
|
|
@@ -1196,6 +1413,8 @@ export async function callRpc({ fn, args, ctx, key, limit = 60, timeout = 10000
|
|
|
1196
1413
|
} catch (e) {
|
|
1197
1414
|
if (e && e.errors && typeof e.errors === 'object') return { status: 422, body: { error: e.message || 'Invalid input', errors: e.errors } }
|
|
1198
1415
|
if (e instanceof HttpError && e.status === 504) return { status: 504, body: { error: 'Timeout' }, cause: e }
|
|
1416
|
+
const pub = publicError(e)
|
|
1417
|
+
if (pub) return { status: pub.status, body: { error: pub.message } }
|
|
1199
1418
|
const deliberate = intendedStatus(e)
|
|
1200
1419
|
if (deliberate && deliberate < 500) return { status: deliberate, body: { error: e instanceof HttpError ? e.message : STATUS_CODES[deliberate] } }
|
|
1201
1420
|
if (deliberate) return {status:deliberate,body:{error:STATUS_CODES[deliberate]},cause:e}
|
|
@@ -1205,7 +1424,10 @@ export async function callRpc({ fn, args, ctx, key, limit = 60, timeout = 10000
|
|
|
1205
1424
|
// A form posted without JS (§38) → { status: 303, location } | { status: 422, state } | { status, body } (400/429).
|
|
1206
1425
|
// `module` exports the function and `$forms`; `rules` = { rule, check } (brain-schema / brain-form). `state` is what the
|
|
1207
1426
|
// page re-renders with: { '<hash>/<fn>': { values, errors, error } }.
|
|
1208
|
-
|
|
1427
|
+
// `locale` (§37): the page's language, so the schema messages (`form.*`) and the generic error come from its dictionary
|
|
1428
|
+
// (globalThis.__brainDict, filled by the page's chunk) like form() in the browser. It is set only around the synchronous
|
|
1429
|
+
// validation, so concurrent requests in other languages never see it.
|
|
1430
|
+
export async function callForm({ module, raw, ctx, rules, key, limit = 60, timeout = 10000, locale }) {
|
|
1209
1431
|
const q = new URLSearchParams(raw || ''), id = q.get('_brain') || '', fn = id.split('/')[1] || ''
|
|
1210
1432
|
if (key) {
|
|
1211
1433
|
const ra = overLimit(key, limit)
|
|
@@ -1213,7 +1435,16 @@ export async function callForm({ module, raw, ctx, rules, key, limit = 60, timeo
|
|
|
1213
1435
|
}
|
|
1214
1436
|
if (!/^[A-Za-z_$][\w$]*$/.test(fn) || typeof (module && module[fn]) !== 'function') return { status: 400, body: { error: 'Unknown form' } }
|
|
1215
1437
|
const schema = module.$forms && module.$forms[fn]
|
|
1216
|
-
const
|
|
1438
|
+
const G = globalThis, had = '__brainLocale' in G, prev = G.__brainLocale
|
|
1439
|
+
let values, errors, generic
|
|
1440
|
+
if (locale) G.__brainLocale = locale
|
|
1441
|
+
try {
|
|
1442
|
+
;({ values, errors } = schema ? coerce(rules, schema, q) : { values: Object.fromEntries([...q].filter(([k]) => k !== '_brain')), errors: {} })
|
|
1443
|
+
const D = G.__brainDict, m = locale && D && D[locale] && D[locale]['form.error']
|
|
1444
|
+
generic = typeof m === 'string' ? m : 'Something went wrong. Try again.'
|
|
1445
|
+
} finally {
|
|
1446
|
+
if (locale) had ? (G.__brainLocale = prev) : delete G.__brainLocale
|
|
1447
|
+
}
|
|
1217
1448
|
const again = (errs, error, cause) => ({ status: 422, state: { [id]: { values, errors: errs, error } }, cause })
|
|
1218
1449
|
if (Object.keys(errors).length) return again(errors, '')
|
|
1219
1450
|
let r
|
|
@@ -1222,10 +1453,12 @@ export async function callForm({ module, raw, ctx, rules, key, limit = 60, timeo
|
|
|
1222
1453
|
} catch (e) {
|
|
1223
1454
|
if (e && e.errors && typeof e.errors === 'object') return again(e.errors, '')
|
|
1224
1455
|
if (e instanceof HttpError && e.status >= 400 && e.status < 500) return again({}, e.message)
|
|
1456
|
+
const pub = publicError(e)
|
|
1457
|
+
if (pub) return again({}, pub.message)
|
|
1225
1458
|
const deliberate = intendedStatus(e)
|
|
1226
1459
|
if (deliberate && deliberate < 500) return {status:deliberate,body:{error:STATUS_CODES[deliberate]}}
|
|
1227
1460
|
if (deliberate) return {status:deliberate,body:{error:STATUS_CODES[deliberate]},cause:e}
|
|
1228
|
-
return again({},
|
|
1461
|
+
return again({}, generic, e)
|
|
1229
1462
|
}
|
|
1230
1463
|
if (r && r.errors) return again(r.errors, '')
|
|
1231
1464
|
const to = r && r.redirect != null ? safeLocation(r.redirect) : ctx.url.pathname + ctx.url.search
|
|
@@ -1284,7 +1517,7 @@ function body(req, max) {
|
|
|
1284
1517
|
// past the limit the rest is drained without keeping it (so the 413 reaches the client); far past it, cut
|
|
1285
1518
|
req.on('data', c => {
|
|
1286
1519
|
n += c.length
|
|
1287
|
-
if (n >
|
|
1520
|
+
if (n > max + Math.min(15 * max, 16 << 20)) req.destroy() // at most 16 MB of drain for a large limit
|
|
1288
1521
|
else if (n <= max) parts.push(c)
|
|
1289
1522
|
})
|
|
1290
1523
|
req.on('end', () => (n > max ? fail(new Error('too large')) : ok(Buffer.concat(parts).toString('utf8'))))
|
|
@@ -1426,6 +1659,7 @@ if (process.argv[1] && real(process.argv[1]) === real(fileURLToPath(import.meta.
|
|
|
1426
1659
|
const drain = () => {
|
|
1427
1660
|
if (s.draining) return
|
|
1428
1661
|
s.draining = true
|
|
1662
|
+
s.$brainSocketStop?.()
|
|
1429
1663
|
const ms = +(ENV.BRAIN_DRAIN_MS || 10000)
|
|
1430
1664
|
const force = setTimeout(() => {
|
|
1431
1665
|
s.closeAllConnections()
|