@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/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, 64 KB, rate limit, 10 s)
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 (precompressed .br/.gz when accepted; /_b/* immutable), clean URLs
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
- export function acceptedEncodings(header = '') {
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 ['br','gzip'].map(name => [name,values.get(name) ?? values.get('*') ?? 0]).filter(([,q]) => q > 0).sort((a,b) => b[1] - a[1]).map(([name]) => name)
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 => !['search','limit','offset'].includes(key))) throw new HttpError(400, 'Invalid native list options')
330
- const {search = '', limit = 20, offset = 0} = options
331
- if (typeof search !== 'string' || Buffer.byteLength(search) > 256 || !Number.isInteger(limit) || limit < 1 || limit > 100 || !Number.isInteger(offset) || offset < 0 || offset > 10000) throw new HttpError(400, 'Invalid native list pagination or search')
332
- return request('GET', '/api/' + name + '?' + new URLSearchParams({search,limit:String(limit),offset:String(offset)}))
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
- const TIMEOUT = +(o.timeout ?? env.BRAIN_TIMEOUT_MS ?? 10000)
401
- const LIMIT = +(o.rateLimit ?? env.BRAIN_RPC_LIMIT ?? 60) // per IP + function, per minute
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: Object.freeze({resource: name => native.resource(req, res, res.getHeader('x-request-id'), name),login:credentials=>native.login(req,res,res.getHeader('x-request-id'),credentials)}) } : {}),
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, stack: String((e && e.stack) || e) })
781
+ log('error', 'guard failed', { rid, path: url.pathname, ...trace(e) })
605
782
  return errorPage(req, res, route, params, url, rid)
606
783
  }
607
- if (r.redirect) return send(res, r.status, '', { location: withBase(redirect(r.redirect, r.status ?? 303).redirect), 'cache-control': 'no-store', ...ctx.session.headers() })
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
- if (route.stream && route.server && !fixed && !forms && status === 200 && !res.$capture) return streamed(req, res, route, params, url, rid, ctx, keep(extra || {}))
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
- if (e && e.redirect) return send(res, e.status || 303, '', { location: withBase(redirect(e.redirect, e.status ?? 303).redirect), ...ctx.session.headers() })
619
- if ((e instanceof HttpError && e.status === 404) || (e && e.notFound === true)) return notFound(req, res) // `throw notFound()` (F11)
620
- const status = intendedStatus(e) || 500
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) return send(res, props.status || 303, '', { location: withBase(redirect(props.redirect, props.status ?? 303).redirect), ...ctx.session.headers() })
625
- if (props && props.notFound === true) return notFound(req, res) // `return notFound()` (F11)
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, stack: String((e && e.stack) || e) })
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, stack: String((f.e && f.e.stack) || f.e) })
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
- // the page up to the region (head, CSS, layout shell) is sent at once, the view when load() ends. The status and
648
- // headers are already out by then, so a redirect from load() becomes location.replace and a failure an inline notice
649
- // with the request id (both logged); load() cannot change the session on a streamed route.
650
- async function streamed(req, res, route, params, url, rid, ctx, extra) {
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
- res.write(n(head(top, seo0, url.pathname)))
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
- res.write(n(`<!--brain:loading-->${body}<!--/brain:loading-->`))
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, stack: String((e && e.stack) || e) })
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
- if (e && e.redirect) return end(`<script>location.replace(${jsonInHtml(withBase(redirect(e.redirect, e.status ?? 303).redirect))})</script>`)
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, stack: String((e && e.stack) || e) }) // headers already out
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, stack: String((f.e && f.e.stack) || f.e) })
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, stack: String((e && e.stack) || e) })
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, 'Body larger than 64 KB')
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, stack: String((e && e.stack) || e) })
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, stack: String((r.cause && r.cause.stack) || r.cause) })
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, 'Form larger than 64 KB')
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, stack: String((e && e.stack) || e) })
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, stack: String((r.cause && r.cause.stack) || r.cause) })
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
- if (urls.length < MAX_URLS) urls.push({ loc: origin + withBase(path), lastmod: day(p.lastmod) })
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, stack: String((e && e.stack) || e) })
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 urls = await dynamicUrls(origin)
923
- // a urlset that would pass 50 000 URLs with the dynamic ones is split: index → static file + dynamic file
924
- const split = !index && (text.match(/<url>/g) || []).length + urls.length > MAX_URLS
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
- if (url.pathname === '/sitemap-dynamic.xml' && !index && !split) return false
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 set = urls.map(u => ` <url><loc>${esc(u.loc)}</loc>${u.lastmod ? `<lastmod>${esc(u.lastmod)}</lastmod>` : ''}</url>\n`).join('')
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
- url.pathname === '/sitemap-dynamic.xml'
932
- ? `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${set}</urlset>\n`
933
- : index
934
- ? text.replace('</sitemapindex>', `${child('/sitemap-dynamic.xml')}</sitemapindex>`)
935
- : split
936
- ? `<?xml version="1.0" encoding="UTF-8"?>\n<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${child('/sitemap-static.xml')}${child('/sitemap-dynamic.xml')}</sitemapindex>\n`
937
- : text.replace('</urlset>', set + '</urlset>'),
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 suffix = enc === 'br' ? '.br' : '.gz'
987
- const encoded = file(c + suffix)
988
- if (encoded) {
989
- send(res, 200, req.method === 'HEAD' ? '' : readFileSync(encoded), { ...headers, 'content-encoding': enc })
990
- return true
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 = redirectOf(url.pathname)
1023
- if (to && (req.method === 'GET' || req.method === 'HEAD')) {
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 && (url.pathname === '/sitemap.xml' || url.pathname === '/sitemap-dynamic.xml' || url.pathname === '/sitemap-static.xml') && (await serveSitemap(req, res, url))) return
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, stack: String((e && e.stack) || e) })
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
- export async function callForm({ module, raw, ctx, rules, key, limit = 60, timeout = 10000 }) {
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 { values, errors } = schema ? coerce(rules, schema, q) : { values: Object.fromEntries([...q].filter(([k]) => k !== '_brain')), errors: {} }
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({}, 'Something went wrong. Try again.', e)
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 > 16 * max) req.destroy()
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()