@christianirack/brainllm-runtime 0.5.0 → 0.6.1

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
  //
@@ -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
- 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']) {
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 ['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] || ''
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 => !['search','limit','offset'].includes(key))) throw new HttpError(400, 'Invalid native list options')
331
- const {search = '', limit = 20, offset = 0} = options
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
- 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')}))
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 TIMEOUT = +(o.timeout ?? env.BRAIN_TIMEOUT_MS ?? 10000)
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: 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')) } : {}),
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, stack: String((e && e.stack) || e) })
781
+ log('error', 'guard failed', { rid, path: url.pathname, ...trace(e) })
612
782
  return errorPage(req, res, route, params, url, rid)
613
783
  }
614
- 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() })
615
786
  if (r.deny !== undefined) return forbidden(req, res, url, rid, r.deny)
616
787
  extra = r.headers
617
788
  }
618
- 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)
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
- if (e && e.redirect) return send(res, e.status || 303, '', { location: withBase(redirect(e.redirect, e.status ?? 303).redirect), ...ctx.session.headers() })
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) return send(res, props.status || 303, '', { location: withBase(redirect(props.redirect, props.status ?? 303).redirect), ...ctx.session.headers() })
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, stack: String((e && e.stack) || e) })
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, 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) })
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
- // the page up to the region (head, CSS, layout shell) is sent at once, the view when load() ends. The status and
655
- // headers are already out by then, so a redirect from load() becomes location.replace and a failure an inline notice
656
- // with the request id (both logged); load() cannot change the session on a streamed route.
657
- 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) {
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
- res.write(n(head(top, seo0, url.pathname)))
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
- res.write(n(`<!--brain:loading-->${body}<!--/brain:loading-->`))
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, stack: String((e && e.stack) || e) })
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
- 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>`)
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, 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
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, 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) })
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, stack: String((e && e.stack) || e) })
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, 'Body larger than 64 KB')
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, stack: String((e && e.stack) || e) })
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, 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) })
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, 'Form larger than 64 KB')
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, stack: String((e && e.stack) || e) })
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, 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) })
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
- 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) })
906
1096
  else dropped++
907
1097
  }
908
1098
  } catch (e) {
909
- log('error', 'sitemap() failed', { view: r.view, stack: String((e && e.stack) || e) })
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 urls = await dynamicUrls(origin)
930
- // a urlset that would pass 50 000 URLs with the dynamic ones is split: index → static file + dynamic file
931
- 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)
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
- if (url.pathname === '/sitemap-dynamic.xml' && !index && !split) return false
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 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
+ }
937
1148
  xml(
938
- url.pathname === '/sitemap-dynamic.xml'
939
- ? `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${set}</urlset>\n`
940
- : index
941
- ? text.replace('</sitemapindex>', `${child('/sitemap-dynamic.xml')}</sitemapindex>`)
942
- : split
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 suffix = enc === 'br' ? '.br' : '.gz'
994
- const encoded = file(c + suffix)
995
- if (encoded) {
996
- send(res, 200, req.method === 'HEAD' ? '' : readFileSync(encoded), { ...headers, 'content-encoding': enc })
997
- return true
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 = redirectOf(url.pathname)
1030
- if (to && (req.method === 'GET' || req.method === 'HEAD')) {
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 && (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
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, stack: String((e && e.stack) || e) })
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
- 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 }) {
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 { 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
+ }
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({}, 'Something went wrong. Try again.', e)
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 > 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
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'))))