@christianirack/brainllm-runtime 0.5.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 +17 -4
- package/brain-data.js +4 -1
- package/brain-env.js +5 -4
- package/brain-guard.js +6 -6
- package/brain-live.js +6 -3
- 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/server.mjs +310 -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
|
//
|
|
@@ -21,6 +21,7 @@ import { createServer as http, STATUS_CODES, request as httpRequest, Agent as Ht
|
|
|
21
21
|
import net from 'node:net'
|
|
22
22
|
import { installNativeSockets } from './native-sockets.mjs'
|
|
23
23
|
import { Readable } from 'node:stream'
|
|
24
|
+
import zlib from 'node:zlib'
|
|
24
25
|
import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'
|
|
25
26
|
import { existsSync, readFileSync, realpathSync, statSync, lstatSync } from 'node:fs'
|
|
26
27
|
import * as Module from 'node:module'
|
|
@@ -67,6 +68,8 @@ const b64u = b => Buffer.from(b).toString('base64url')
|
|
|
67
68
|
const tag = s => createHash('sha1').update(s).digest('base64url').slice(0, 22)
|
|
68
69
|
|
|
69
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']
|
|
70
73
|
export class HttpError extends Error {
|
|
71
74
|
constructor(status, message, extra) {
|
|
72
75
|
super(message)
|
|
@@ -82,7 +85,20 @@ function intendedStatus(error) {
|
|
|
82
85
|
const plain = Object.getPrototypeOf(error) === Object.prototype || Object.getPrototypeOf(error) === null
|
|
83
86
|
return Number.isInteger(status) && STATUS_CODES[status] && (error instanceof HttpError && status >= 400 && status <= 599 || plain && status >= 400 && status < 500) ? status : null
|
|
84
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
|
+
}
|
|
85
100
|
const REDIRECT_CODES = new Set([301, 302, 303, 307, 308])
|
|
101
|
+
const NOINDEX = 'noindex, nofollow'
|
|
86
102
|
function safeLocation(value) {
|
|
87
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')
|
|
88
104
|
return value
|
|
@@ -120,8 +136,11 @@ function defaultError(status, rid, home = '/') {
|
|
|
120
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>`
|
|
121
137
|
}
|
|
122
138
|
|
|
123
|
-
// Coding weights are transport preferences, not substring matches (br;q=0 explicitly forbids Brotli).
|
|
124
|
-
|
|
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']) {
|
|
125
144
|
const values = new Map()
|
|
126
145
|
for (const part of String(header).split(',')) {
|
|
127
146
|
const [name, ...params] = part.trim().split(';')
|
|
@@ -132,8 +151,19 @@ export function acceptedEncodings(header = '') {
|
|
|
132
151
|
}
|
|
133
152
|
values.set(name.toLowerCase(),q)
|
|
134
153
|
}
|
|
135
|
-
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] || ''
|
|
136
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()
|
|
137
167
|
|
|
138
168
|
function googleSignInPage(page) {
|
|
139
169
|
let head = false, templates = 0
|
|
@@ -241,7 +271,7 @@ export function nativeAuthBridge(raw) {
|
|
|
241
271
|
let outgoing, timer, done = false, bytes = 0, requestBytes = 0, failure
|
|
242
272
|
const chunks = []
|
|
243
273
|
const headers = { 'x-request-id':rid }
|
|
244
|
-
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']) {
|
|
245
275
|
const value = req.headers[key]
|
|
246
276
|
if (typeof value === 'string' && value.length <= 8192) headers[key] = value
|
|
247
277
|
}
|
|
@@ -296,7 +326,7 @@ export function nativeAuthBridge(raw) {
|
|
|
296
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')
|
|
297
327
|
return '/api/' + name + '/' + id
|
|
298
328
|
}
|
|
299
|
-
const request = async (method, path, body) => {
|
|
329
|
+
const request = async (method, path, body, notFound) => {
|
|
300
330
|
if (response.writableEnded || response.destroyed) throw new HttpError(409, 'The server request has ended')
|
|
301
331
|
if (method !== 'GET' && incoming.method !== 'POST') throw new HttpError(405, 'Native writes require a server function called through POST')
|
|
302
332
|
let bytes = Buffer.alloc(0)
|
|
@@ -316,6 +346,14 @@ export function nativeAuthBridge(raw) {
|
|
|
316
346
|
let value
|
|
317
347
|
try {
|
|
318
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 }
|
|
319
357
|
if (status < 200 || status >= 300) throw new HttpError(status >= 400 && status < 500 ? status : 503, 'Native resource request failed')
|
|
320
358
|
if (status === 204) { value = null; return }
|
|
321
359
|
if (!String(headers['content-type'] || '').startsWith('application/json')) throw new HttpError(502, 'Native resource response must be JSON')
|
|
@@ -325,17 +363,80 @@ export function nativeAuthBridge(raw) {
|
|
|
325
363
|
if (response.destroyed || response.writableEnded) throw new HttpError(409, 'The server request has ended')
|
|
326
364
|
return value
|
|
327
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
|
+
}
|
|
328
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
|
|
329
374
|
list(options = {}) {
|
|
330
|
-
if (!options || typeof options !== 'object' || Array.isArray(options) || Object.keys(options).some(key => !
|
|
331
|
-
const {search
|
|
332
|
-
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')
|
|
333
|
-
|
|
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')}))
|
|
334
426
|
},
|
|
335
|
-
get: id => request('GET', idPath(id)),
|
|
336
427
|
create: values => request('POST', '/api/' + name, values),
|
|
337
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
|
+
},
|
|
338
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
|
+
},
|
|
339
440
|
})
|
|
340
441
|
}
|
|
341
442
|
async function login(incoming,response,rid,credentials) {
|
|
@@ -363,9 +464,16 @@ export function nativeAuthBridge(raw) {
|
|
|
363
464
|
} finally {request.destroy();bytes.fill(0)}
|
|
364
465
|
return {user}
|
|
365
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
|
+
})
|
|
366
473
|
return {
|
|
367
474
|
proxy,
|
|
368
475
|
resource,
|
|
476
|
+
backend,
|
|
369
477
|
login,
|
|
370
478
|
async prepare(req, res, rid) {
|
|
371
479
|
if (!cookies(req, false)) { req.$nativeUser = null; req.$nativeAuth ||= Promise.resolve(); return }
|
|
@@ -400,10 +508,9 @@ export function createServer(o = {}) {
|
|
|
400
508
|
const env = Object.freeze({ ...(o.env || ENV) }) // ctx.env: what the supervisor passed, read-only
|
|
401
509
|
const native = nativeAuthBridge(o.backendURL ?? env.BRAIN_BACKEND_URL)
|
|
402
510
|
if (o.nativeSockets !== undefined && typeof o.nativeSockets !== 'boolean') throw Error('nativeSockets must be an explicit boolean')
|
|
403
|
-
const
|
|
404
|
-
const LIMIT = +(o.rateLimit ?? env.BRAIN_RPC_LIMIT ?? 60) // per IP + function, per minute
|
|
405
|
-
const MAX_BODY = 64 * 1024
|
|
511
|
+
const SITEMAP_BYTES = +(o.sitemapBytes ?? 50 * 1024 * 1024) // protocol: 50 MB uncompressed per sitemap file
|
|
406
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
|
|
407
514
|
const sink = o.log || ((level, msg, extra) => console[level === 'error' ? 'error' : 'log'](json({ t: new Date().toISOString(), level, msg, ...extra })))
|
|
408
515
|
const log = (level, msg, extra = {}) => {
|
|
409
516
|
const safe = { ...extra }
|
|
@@ -418,10 +525,35 @@ export function createServer(o = {}) {
|
|
|
418
525
|
safe.private = true; safe.valuesCaptured = false
|
|
419
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'
|
|
420
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)
|
|
421
532
|
if (typeof safe.path === 'string') safe.path = safe.path.split(/[?#]/, 1)[0].slice(0, 2048)
|
|
422
533
|
sink(level, msg, safe)
|
|
423
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
|
+
}
|
|
424
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)
|
|
425
557
|
const hasManifestSockets = Object.hasOwn(mf,'nativeSockets')
|
|
426
558
|
if (hasManifestSockets && typeof mf.nativeSockets !== 'boolean') throw Error('Manifest nativeSockets must be an explicit boolean')
|
|
427
559
|
const nativeSockets = o.nativeSockets === undefined ? hasManifestSockets && mf.nativeSockets : o.nativeSockets
|
|
@@ -434,6 +566,7 @@ export function createServer(o = {}) {
|
|
|
434
566
|
// Base path (§23 #148): the app lives under mf.base ("/app/"); routes, files and functions are matched on the app
|
|
435
567
|
// path (base stripped); redirects the app gives as app paths get it back.
|
|
436
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>` : ''
|
|
437
570
|
const RD = mf.redirects || {}, RDX = (RD.patterns || []).map(p => ({ re: new RegExp(p.re), to: p.to, status: p.status || 301 }))
|
|
438
571
|
// `/old/` and `/old` are the same source (brain-seo normalizes the trailing slash)
|
|
439
572
|
const redirectOf = path => {
|
|
@@ -447,6 +580,28 @@ export function createServer(o = {}) {
|
|
|
447
580
|
return null
|
|
448
581
|
}
|
|
449
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
|
+
}
|
|
450
605
|
hook(distURL, BASE)
|
|
451
606
|
|
|
452
607
|
// ---------- helpers ----------
|
|
@@ -483,6 +638,16 @@ export function createServer(o = {}) {
|
|
|
483
638
|
delete headers['content-length']; delete headers['content-encoding']; delete headers.etag
|
|
484
639
|
}
|
|
485
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
|
+
}
|
|
486
651
|
res.writeHead(status, { ...SECURITY, ...(server.draining ? { connection: 'close' } : {}), ...headers })
|
|
487
652
|
res.end(res.req.method === 'HEAD' || status === 204 || status === 205 || status === 304 ? undefined : body)
|
|
488
653
|
}
|
|
@@ -495,13 +660,18 @@ export function createServer(o = {}) {
|
|
|
495
660
|
if (inm && inm.split(',').some(t => t.trim().replace(/^W\//, '') === etag.replace(/^W\//, ''))) return send(res, 304, '', headers)
|
|
496
661
|
}
|
|
497
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
|
|
498
666
|
const body = page.replace(/<script(?![^>]*\bnonce=)/g, `<script nonce="${nonce}"`)
|
|
499
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 })
|
|
500
668
|
}
|
|
501
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))
|
|
502
672
|
const failPage = (res, status, rid) => {
|
|
503
673
|
if (!wantsHtml(res.req)) return send(res, status, json({ schemaVersion: 1, status, rid, error: STATUS_CODES[status] || 'Request failed' }), { 'content-type': MIME['.json'] })
|
|
504
|
-
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`)
|
|
505
675
|
const path = typeof custom === 'string' && file(custom)
|
|
506
676
|
return html(res, status, path ? readFileSync(path, 'utf8') : defaultError(status, rid, BASE))
|
|
507
677
|
}
|
|
@@ -528,7 +698,7 @@ export function createServer(o = {}) {
|
|
|
528
698
|
const { user, session: ses } = native ? { user: req.$nativeUser ?? null, session: native.session(req, res, res.getHeader('x-request-id')) } : sessionFrom(cookies, secret)
|
|
529
699
|
return {
|
|
530
700
|
url, query: Object.fromEntries(url.searchParams), cookies, user, session: ses, env,
|
|
531
|
-
...(native ? { backend:
|
|
701
|
+
...(native ? { backend: native.backend(req, res, res.getHeader('x-request-id')) } : {}),
|
|
532
702
|
// `prefetch`: the router fetched this page ahead of a click (§19); a `load` with side effects can skip them
|
|
533
703
|
prefetch: req.headers['x-brain-prefetch'] === '1',
|
|
534
704
|
req: { method: req.method, headers: req.headers, ip: ipOf(req) }, ...extra,
|
|
@@ -608,38 +778,42 @@ export function createServer(o = {}) {
|
|
|
608
778
|
for (const m of route.middleware || []) middleware.push(await load(m))
|
|
609
779
|
r = await runChain({ guards, middleware, ctx })
|
|
610
780
|
} catch (e) {
|
|
611
|
-
log('error', 'guard failed', { rid, path: url.pathname,
|
|
781
|
+
log('error', 'guard failed', { rid, path: url.pathname, ...trace(e) })
|
|
612
782
|
return errorPage(req, res, route, params, url, rid)
|
|
613
783
|
}
|
|
614
|
-
|
|
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() })
|
|
615
786
|
if (r.deny !== undefined) return forbidden(req, res, url, rid, r.deny)
|
|
616
787
|
extra = r.headers
|
|
617
788
|
}
|
|
618
|
-
|
|
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)
|
|
619
798
|
try {
|
|
620
799
|
if (!fixed && route.server) {
|
|
621
800
|
const m = await load(route.server)
|
|
622
801
|
if (typeof m.load === 'function') props = (await withTimeout(Promise.resolve(m.load(ctx)), TIMEOUT)) ?? {}
|
|
623
802
|
}
|
|
624
803
|
} catch (e) {
|
|
625
|
-
|
|
626
|
-
if ((e instanceof HttpError && e.status === 404) || (e && e.notFound === true)) return notFound(req, res) // `throw notFound()` (F11)
|
|
627
|
-
const status = intendedStatus(e) || 500
|
|
628
|
-
log('error', 'load failed', { rid, path: url.pathname, status, stack: String((e && e.stack) || e) })
|
|
629
|
-
return status === 500 ? errorPage(req, res, route, params, url, rid) : failPage(res, status, rid)
|
|
804
|
+
return loadOutcome(e)
|
|
630
805
|
}
|
|
631
|
-
if (props && props.redirect
|
|
632
|
-
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()`
|
|
633
807
|
let body, seo, failed
|
|
634
808
|
try {
|
|
635
809
|
const at = { path: url.pathname, params: params || {}, query: Object.fromEntries(url.searchParams) } // route() (§41.5)
|
|
636
810
|
;({ body, seo, failed } = await (queue = queue.then(() => render(route, props, forms, at), () => render(route, props, forms, at))))
|
|
637
811
|
} catch (e) {
|
|
638
|
-
log('error', 'render failed', { rid, path: url.pathname,
|
|
812
|
+
log('error', 'render failed', { rid, path: url.pathname, ...trace(e) })
|
|
639
813
|
return failPage(res, 500, rid)
|
|
640
814
|
}
|
|
641
815
|
// a component that failed shows its fallback inside the page (still 200); the error goes to the log with the rid
|
|
642
|
-
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) })
|
|
643
817
|
const page = readFileSync(file(route.page), 'utf8')
|
|
644
818
|
.replace('<!--brain:ssr-->', () => body)
|
|
645
819
|
.replace('<!--brain:props-->', () => jsonInHtml(props))
|
|
@@ -651,19 +825,33 @@ export function createServer(o = {}) {
|
|
|
651
825
|
html(res, status, out, keep({ ...extra, ...ctx.session.headers() }), own ? undefined : 'W/"' + tag(out) + '"')
|
|
652
826
|
}
|
|
653
827
|
// Streaming SSR (#232, `route.stream`: the build sets it when the view's <meta seo> is static and it has a load()):
|
|
654
|
-
//
|
|
655
|
-
//
|
|
656
|
-
//
|
|
657
|
-
|
|
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) {
|
|
658
834
|
const [top, bottom = ''] = readFileSync(file(route.page), 'utf8').split('<!--brain:ssr-->')
|
|
659
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)
|
|
660
842
|
const seoF = sm.seo || (await load(route.view)).default.seo
|
|
661
843
|
if (route.i18n) await load(route.i18n)
|
|
662
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
|
|
663
846
|
res.writeHead(200, {
|
|
664
847
|
...SECURITY, 'content-type': MIME['.html'], 'content-security-policy': withNonce(mf.csp || CSP0, nonce), 'cache-control': 'no-cache',
|
|
665
848
|
...(mf.build ? { 'x-brain-build': mf.build } : {}), ...extra, ...ctx.session.headers(),
|
|
849
|
+
...(COMPRESS ? { vary: varyEncoding(extra.vary) } : {}), ...(enc ? { 'content-encoding': enc } : {}),
|
|
666
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))
|
|
667
855
|
globalThis.__brainLocale = route.locale || null
|
|
668
856
|
let seo0 = null
|
|
669
857
|
try {
|
|
@@ -671,25 +859,22 @@ export function createServer(o = {}) {
|
|
|
671
859
|
} finally {
|
|
672
860
|
globalThis.__brainLocale = null
|
|
673
861
|
}
|
|
674
|
-
// load() starts now: the loading view (#253) renders while it runs
|
|
675
|
-
const loadP = typeof sm.load === 'function' ? withTimeout(Promise.resolve().then(() => sm.load(ctx)), TIMEOUT) : Promise.resolve(null)
|
|
676
|
-
loadP.catch(() => {}) // handled below; never an unhandled rejection while the loading view renders
|
|
677
862
|
const at = { path: url.pathname, params: params || {}, query: Object.fromEntries(url.searchParams) }
|
|
678
|
-
|
|
863
|
+
write(n(head(top, seo0, url.pathname)))
|
|
679
864
|
// QA #253: the folder's _loading.brain goes in the region with the first chunk (SPEC: shows while load runs),
|
|
680
865
|
// between two marker comments; the last chunk starts with a script that removes them and what is between
|
|
681
866
|
let clear = ''
|
|
682
|
-
if (route.loading) {
|
|
867
|
+
if (route.loading && !early) { // the loading view (#253) only when load() is still running
|
|
683
868
|
try {
|
|
684
869
|
const lr = { ...route, view: route.loading, layouts: [], server: null }
|
|
685
870
|
const { body } = await (queue = queue.then(() => render(lr, {}, null, at), () => render(lr, {}, null, at)))
|
|
686
|
-
|
|
871
|
+
write(n(`<!--brain:loading-->${body}<!--/brain:loading-->`))
|
|
687
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>`
|
|
688
873
|
} catch (e) {
|
|
689
|
-
log('error', 'loading view failed (streamed)', { rid, path: url.pathname,
|
|
874
|
+
log('error', 'loading view failed (streamed)', { rid, path: url.pathname, ...trace(e) })
|
|
690
875
|
}
|
|
691
876
|
}
|
|
692
|
-
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))))
|
|
693
878
|
// QA #289: the page closes with the _error.brain template (its client mounts the error view with { message, rid }),
|
|
694
879
|
// never with the route's own mount script, which would replace the error with the view rendered from {}. Without an
|
|
695
880
|
// _error.brain the notice stays: the view's mount script is left out.
|
|
@@ -706,19 +891,20 @@ export function createServer(o = {}) {
|
|
|
706
891
|
props = (await loadP) ?? {}
|
|
707
892
|
if (props.redirect || props.notFound === true) throw props
|
|
708
893
|
} catch (e) {
|
|
709
|
-
|
|
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>`)
|
|
710
896
|
// a streamed page already sent its 200 head: the region gets the not-found notice and the page is noindex
|
|
711
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, ''))
|
|
712
|
-
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
|
|
713
899
|
return fail()
|
|
714
900
|
}
|
|
715
901
|
try {
|
|
716
902
|
const { body, failed } = await (queue = queue.then(() => render(route, props, null, at), () => render(route, props, null, at)))
|
|
717
|
-
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) })
|
|
718
904
|
const pm = params && Object.keys(params).length ? `<meta name="brain-params" content="${esc(JSON.stringify(params))}">` : ''
|
|
719
905
|
end(pm + body, props)
|
|
720
906
|
} catch (e) {
|
|
721
|
-
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) })
|
|
722
908
|
await fail()
|
|
723
909
|
}
|
|
724
910
|
}
|
|
@@ -735,6 +921,7 @@ export function createServer(o = {}) {
|
|
|
735
921
|
}
|
|
736
922
|
// deny → views/403.brain (manifest `forbidden`, props.reason) or the static 403.html, always status 403
|
|
737
923
|
function forbidden(req, res, url, rid, reason) {
|
|
924
|
+
res.setHeader('x-robots-tag', NOINDEX) // a members-only page never enters the index (§33)
|
|
738
925
|
if (!wantsHtml(req)) return failPage(res, 403, rid)
|
|
739
926
|
if (mf.forbidden) return ssr(req, res, mf.forbidden, {}, url, rid, null, 403, { reason })
|
|
740
927
|
const f = file('403.html')
|
|
@@ -809,7 +996,7 @@ export function createServer(o = {}) {
|
|
|
809
996
|
try {
|
|
810
997
|
raw = await body(req, MAX_BODY)
|
|
811
998
|
} catch {
|
|
812
|
-
return fail(413,
|
|
999
|
+
return fail(413, tooBig(rid, url.pathname))
|
|
813
1000
|
}
|
|
814
1001
|
if (native) await native.prepare(req, res, rid)
|
|
815
1002
|
const ctx = context(req, res, url, { params: {} })
|
|
@@ -817,11 +1004,11 @@ export function createServer(o = {}) {
|
|
|
817
1004
|
try {
|
|
818
1005
|
f = (await load(modPath))[fn]
|
|
819
1006
|
} catch (e) {
|
|
820
|
-
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) })
|
|
821
1008
|
return fail(500, 'Internal error', { rid })
|
|
822
1009
|
}
|
|
823
1010
|
const r = await callRpc({ fn: f, args: raw, ctx, timeout: TIMEOUT })
|
|
824
|
-
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) })
|
|
825
1012
|
const b = r.status >= 500 ? { ...r.body, rid } : r.body
|
|
826
1013
|
send(res, r.status, json(b), { 'content-type': MIME['.json'], 'cache-control': 'no-store', ...(r.headers || {}), ...ctx.session.headers() })
|
|
827
1014
|
}
|
|
@@ -849,7 +1036,7 @@ export function createServer(o = {}) {
|
|
|
849
1036
|
try {
|
|
850
1037
|
raw = await body(req, MAX_BODY)
|
|
851
1038
|
} catch {
|
|
852
|
-
return plain(413,
|
|
1039
|
+
return plain(413, tooBig(rid, url.pathname))
|
|
853
1040
|
}
|
|
854
1041
|
const id = new URLSearchParams(raw).get('_brain') || '', modPath = mf.rpc[id.split('/')[0]]
|
|
855
1042
|
if (!modPath) return plain(400, 'Unknown form')
|
|
@@ -859,12 +1046,13 @@ export function createServer(o = {}) {
|
|
|
859
1046
|
try {
|
|
860
1047
|
module = await load(modPath)
|
|
861
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)
|
|
862
1050
|
} catch (e) {
|
|
863
|
-
log('error', 'form module failed to load', { rid, fn: id,
|
|
1051
|
+
log('error', 'form module failed to load', { rid, fn: id, ...trace(e) })
|
|
864
1052
|
return failPage(res, 500, rid)
|
|
865
1053
|
}
|
|
866
|
-
const r = await callForm({ module, raw, ctx, rules, key: ipOf(req) + ' ' + id, limit: LIMIT, timeout: TIMEOUT })
|
|
867
|
-
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) })
|
|
868
1056
|
if (r.status === 303) return send(res, 303, '', { location: withBase(r.location), 'cache-control': 'no-store', ...ctx.session.headers() })
|
|
869
1057
|
if (r.status === 422) return ssr(req, res, route, params, url, rid, r.state, 422)
|
|
870
1058
|
plain(r.status, r.body.error, r.headers)
|
|
@@ -902,11 +1090,13 @@ export function createServer(o = {}) {
|
|
|
902
1090
|
for (const p of Array.isArray(list) ? list : []) {
|
|
903
1091
|
const path = fill(r.path, p)
|
|
904
1092
|
if (!path) continue
|
|
905
|
-
|
|
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) })
|
|
906
1096
|
else dropped++
|
|
907
1097
|
}
|
|
908
1098
|
} catch (e) {
|
|
909
|
-
log('error', 'sitemap() failed', { view: r.view,
|
|
1099
|
+
log('error', 'sitemap() failed', { view: r.view, ...trace(e) })
|
|
910
1100
|
}
|
|
911
1101
|
}
|
|
912
1102
|
if (dropped) log('warn', 'sitemap(): more than 50000 URLs, the rest are not listed', { dropped })
|
|
@@ -919,6 +1109,23 @@ export function createServer(o = {}) {
|
|
|
919
1109
|
if (!dynRun) dynRun = collect(origin).finally(() => (dynRun = null))
|
|
920
1110
|
return dynRun
|
|
921
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
|
+
}
|
|
922
1129
|
async function serveSitemap(req, res, url) {
|
|
923
1130
|
const f = file('/sitemap.xml')
|
|
924
1131
|
if (!f) return false
|
|
@@ -926,22 +1133,24 @@ export function createServer(o = {}) {
|
|
|
926
1133
|
const origin = [...text.matchAll(/<loc>(https?:\/\/[^<]+)<\/loc>/g)].map(m => URL.canParse(m[1]) && new URL(m[1]).origin).find(Boolean)
|
|
927
1134
|
if (!origin) return false
|
|
928
1135
|
const index = text.includes('<sitemapindex')
|
|
929
|
-
const
|
|
930
|
-
// a urlset that would pass
|
|
931
|
-
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)
|
|
932
1139
|
const xml = body => send(res, 200, req.method === 'HEAD' ? '' : body, { 'content-type': MIME['.xml'], 'cache-control': 'public, max-age=300' })
|
|
933
1140
|
if (url.pathname === '/sitemap-static.xml') return split ? (xml(text), true) : false
|
|
934
|
-
|
|
1141
|
+
const name = i => (i ? `/sitemap-dynamic-${i + 1}.xml` : '/sitemap-dynamic.xml')
|
|
935
1142
|
const child = p => ` <sitemap><loc>${esc(origin + withBase(p))}</loc></sitemap>\n`
|
|
936
|
-
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
|
+
}
|
|
937
1148
|
xml(
|
|
938
|
-
|
|
939
|
-
?
|
|
940
|
-
:
|
|
941
|
-
?
|
|
942
|
-
:
|
|
943
|
-
? `<?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`
|
|
944
|
-
: 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>'),
|
|
945
1154
|
)
|
|
946
1155
|
return true
|
|
947
1156
|
}
|
|
@@ -970,7 +1179,7 @@ export function createServer(o = {}) {
|
|
|
970
1179
|
function serveStatic(req, res, url) {
|
|
971
1180
|
const p = publicStaticPath(url.pathname)
|
|
972
1181
|
if (p === null) return false
|
|
973
|
-
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']
|
|
974
1183
|
for (const c of candidates) {
|
|
975
1184
|
const f = file(c)
|
|
976
1185
|
if (!f) continue
|
|
@@ -989,13 +1198,16 @@ export function createServer(o = {}) {
|
|
|
989
1198
|
'cache-control': p.startsWith('/_b/') ? 'public, max-age=31536000, immutable' : p === '/sw.js' ? 'no-cache' : 'public, max-age=3600',
|
|
990
1199
|
vary: 'accept-encoding',
|
|
991
1200
|
}
|
|
1201
|
+
// the smallest precompressed sibling the client accepts (the build keeps only those smaller than the file)
|
|
1202
|
+
let best = null
|
|
992
1203
|
for (const enc of acceptedEncodings(req.headers['accept-encoding'])) {
|
|
993
|
-
const
|
|
994
|
-
const
|
|
995
|
-
if (encoded) {
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
}
|
|
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
|
|
999
1211
|
}
|
|
1000
1212
|
send(res, 200, req.method === 'HEAD' ? '' : readFileSync(f), headers)
|
|
1001
1213
|
return true
|
|
@@ -1025,12 +1237,9 @@ export function createServer(o = {}) {
|
|
|
1025
1237
|
req.$brainPath = url.pathname
|
|
1026
1238
|
if (native && /^\/(?:api(?:\/|$)|_brain(?:\/|$)|s\/)/.test(url.pathname)) return await native.proxy(req,res,url,rid,send)
|
|
1027
1239
|
// redirects.json (manifest `redirects`, brain-seo server_json): exact sources first, then patterns, on the app path;
|
|
1028
|
-
// same-site targets get the base, the query is kept when the target has none
|
|
1029
|
-
const to =
|
|
1030
|
-
if (to
|
|
1031
|
-
const loc = /^https?:\/\//.test(to[0]) ? to[0] : withBase(to[0]) + (to[0].includes('?') ? '' : url.search)
|
|
1032
|
-
return send(res, to[1], '', { location: loc, 'cache-control': to[1] === 301 || to[1] === 308 ? 'public, max-age=3600' : 'no-store' }, true)
|
|
1033
|
-
}
|
|
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])
|
|
1034
1243
|
const r = /^\/_r\/([\w-]+)\/([\w$]+)$/.exec(url.pathname)
|
|
1035
1244
|
if (r) return await rpc(req, res, r[1], r[2], url, rid)
|
|
1036
1245
|
if (url.pathname.startsWith('/_r/')) return send(res, 404, json({ error: 'Unknown function' }), { 'content-type': MIME['.json'] })
|
|
@@ -1046,11 +1255,11 @@ export function createServer(o = {}) {
|
|
|
1046
1255
|
return await (post ? formPost : ssr)(req, res, route, m.groups ? { ...m.groups } : {}, url, rid)
|
|
1047
1256
|
}
|
|
1048
1257
|
if (post) return send(res, 405, 'Method not allowed', { allow: 'GET, HEAD' })
|
|
1049
|
-
if (DYN.length && (
|
|
1258
|
+
if (DYN.length && /^\/sitemap(-static|-dynamic(-\d+)?)?\.xml$/.test(url.pathname) && (await serveSitemap(req, res, url))) return
|
|
1050
1259
|
if (serveStatic(req, res, url)) return
|
|
1051
1260
|
notFound(req, res)
|
|
1052
1261
|
} catch (e) {
|
|
1053
|
-
log('error', 'unhandled', { rid, path: req.url,
|
|
1262
|
+
log('error', 'unhandled', { rid, path: req.url, ...trace(e) })
|
|
1054
1263
|
if (!res.headersSent) failPage(res, intendedStatus(e) || 500, rid)
|
|
1055
1264
|
else res.end()
|
|
1056
1265
|
}
|
|
@@ -1204,6 +1413,8 @@ export async function callRpc({ fn, args, ctx, key, limit = 60, timeout = 10000
|
|
|
1204
1413
|
} catch (e) {
|
|
1205
1414
|
if (e && e.errors && typeof e.errors === 'object') return { status: 422, body: { error: e.message || 'Invalid input', errors: e.errors } }
|
|
1206
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 } }
|
|
1207
1418
|
const deliberate = intendedStatus(e)
|
|
1208
1419
|
if (deliberate && deliberate < 500) return { status: deliberate, body: { error: e instanceof HttpError ? e.message : STATUS_CODES[deliberate] } }
|
|
1209
1420
|
if (deliberate) return {status:deliberate,body:{error:STATUS_CODES[deliberate]},cause:e}
|
|
@@ -1213,7 +1424,10 @@ export async function callRpc({ fn, args, ctx, key, limit = 60, timeout = 10000
|
|
|
1213
1424
|
// A form posted without JS (§38) → { status: 303, location } | { status: 422, state } | { status, body } (400/429).
|
|
1214
1425
|
// `module` exports the function and `$forms`; `rules` = { rule, check } (brain-schema / brain-form). `state` is what the
|
|
1215
1426
|
// page re-renders with: { '<hash>/<fn>': { values, errors, error } }.
|
|
1216
|
-
|
|
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 }) {
|
|
1217
1431
|
const q = new URLSearchParams(raw || ''), id = q.get('_brain') || '', fn = id.split('/')[1] || ''
|
|
1218
1432
|
if (key) {
|
|
1219
1433
|
const ra = overLimit(key, limit)
|
|
@@ -1221,7 +1435,16 @@ export async function callForm({ module, raw, ctx, rules, key, limit = 60, timeo
|
|
|
1221
1435
|
}
|
|
1222
1436
|
if (!/^[A-Za-z_$][\w$]*$/.test(fn) || typeof (module && module[fn]) !== 'function') return { status: 400, body: { error: 'Unknown form' } }
|
|
1223
1437
|
const schema = module.$forms && module.$forms[fn]
|
|
1224
|
-
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
|
+
}
|
|
1225
1448
|
const again = (errs, error, cause) => ({ status: 422, state: { [id]: { values, errors: errs, error } }, cause })
|
|
1226
1449
|
if (Object.keys(errors).length) return again(errors, '')
|
|
1227
1450
|
let r
|
|
@@ -1230,10 +1453,12 @@ export async function callForm({ module, raw, ctx, rules, key, limit = 60, timeo
|
|
|
1230
1453
|
} catch (e) {
|
|
1231
1454
|
if (e && e.errors && typeof e.errors === 'object') return again(e.errors, '')
|
|
1232
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)
|
|
1233
1458
|
const deliberate = intendedStatus(e)
|
|
1234
1459
|
if (deliberate && deliberate < 500) return {status:deliberate,body:{error:STATUS_CODES[deliberate]}}
|
|
1235
1460
|
if (deliberate) return {status:deliberate,body:{error:STATUS_CODES[deliberate]},cause:e}
|
|
1236
|
-
return again({},
|
|
1461
|
+
return again({}, generic, e)
|
|
1237
1462
|
}
|
|
1238
1463
|
if (r && r.errors) return again(r.errors, '')
|
|
1239
1464
|
const to = r && r.redirect != null ? safeLocation(r.redirect) : ctx.url.pathname + ctx.url.search
|
|
@@ -1292,7 +1517,7 @@ function body(req, max) {
|
|
|
1292
1517
|
// past the limit the rest is drained without keeping it (so the 413 reaches the client); far past it, cut
|
|
1293
1518
|
req.on('data', c => {
|
|
1294
1519
|
n += c.length
|
|
1295
|
-
if (n >
|
|
1520
|
+
if (n > max + Math.min(15 * max, 16 << 20)) req.destroy() // at most 16 MB of drain for a large limit
|
|
1296
1521
|
else if (n <= max) parts.push(c)
|
|
1297
1522
|
})
|
|
1298
1523
|
req.on('end', () => (n > max ? fail(new Error('too large')) : ok(Buffer.concat(parts).toString('utf8'))))
|