@christianirack/brainllm-runtime 0.0.0-stage → 0.3.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.
Files changed (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +4 -2
  3. package/brain-ai.js +129 -0
  4. package/brain-anim.js +21 -0
  5. package/brain-ch.js +254 -0
  6. package/brain-consent.js +38 -0
  7. package/brain-data.js +549 -0
  8. package/brain-dev.js +100 -0
  9. package/brain-env.js +160 -0
  10. package/brain-err.js +242 -0
  11. package/brain-form.js +275 -0
  12. package/brain-guard.js +242 -0
  13. package/brain-hydrate.js +72 -0
  14. package/brain-i18n.js +111 -0
  15. package/brain-inspect.js +745 -0
  16. package/brain-island.js +106 -0
  17. package/brain-lazy.js +88 -0
  18. package/brain-mcp.js +73 -0
  19. package/brain-mock.js +80 -0
  20. package/brain-overlay.js +123 -0
  21. package/brain-persist.js +65 -0
  22. package/brain-router.js +503 -0
  23. package/brain-rpc.js +20 -0
  24. package/brain-schema.js +93 -0
  25. package/brain-wc.js +69 -0
  26. package/brain-why.js +198 -0
  27. package/brain-worker.js +66 -0
  28. package/brain.js +421 -0
  29. package/package.json +20 -4
  30. package/server/server.mjs +1046 -0
  31. package/shim/dom.js +448 -0
  32. package/shim/probe.js +111 -0
  33. package/sw/brain-sw.js +103 -0
  34. package/themes/contrast.cssbrain +39 -0
  35. package/themes/dark.cssbrain +39 -0
  36. package/themes/glass.cssbrain +39 -0
  37. package/themes/paper.cssbrain +39 -0
  38. package/ui/button.brain +31 -0
  39. package/ui/center.brain +11 -0
  40. package/ui/checkbox.brain +13 -0
  41. package/ui/container.brain +11 -0
  42. package/ui/dialog.brain +29 -0
  43. package/ui/error-state.brain +53 -0
  44. package/ui/field.brain +34 -0
  45. package/ui/grid.brain +11 -0
  46. package/ui/input.brain +23 -0
  47. package/ui/lang-switch.brain +38 -0
  48. package/ui/menu.brain +64 -0
  49. package/ui/row.brain +12 -0
  50. package/ui/select.brain +29 -0
  51. package/ui/sidebar.brain +17 -0
  52. package/ui/spacer.brain +12 -0
  53. package/ui/stack.brain +10 -0
  54. package/ui/table.brain +73 -0
  55. package/ui/tabs.brain +33 -0
  56. package/ui/toast.brain +45 -0
@@ -0,0 +1,1046 @@
1
+ // BRAINLLM production server (INTERNALS §11.1, §22, §31). Node ≥ 22, zero dependencies (node:http).
2
+ // `brain build` copies this file to dist/server.mjs when the app has `load` or `server:` functions; `brain start`
3
+ // (INTERNALS §34) runs it with the listening socket inherited as BRAIN_LISTEN_FD (else PORT/HOST) and with the
4
+ // environment it resolved from `.brain_env` (§36): this file reads no env file itself. It prints `brain:ready <pid>`
5
+ // once it accepts and drains on SIGTERM.
6
+ //
7
+ // It serves, in this order:
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)
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
12
+ // anything else → 404 page
13
+ // Every response carries the security headers; every HTML response gets a fresh CSP nonce on each <script>.
14
+ //
15
+ // dist/_server/manifest.json (emitted by brain build):
16
+ // { v, build, core, routes: [{ path, view, layouts, server, page }], rpc: { <hash>: <module> }, notFound }
17
+ // `build` is the build signature (§35.7): sent as X-Brain-Build on every HTML response and in /_health.
18
+ // A page template (`page`) is the route's final HTML with `<!--brain:ssr-->` inside #brain-view and
19
+ // `<!--brain:props-->` inside <script type="application/json" id="brain-props">.
20
+ import { createServer as http } from 'node:http'
21
+ import net from 'node:net'
22
+ import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'
23
+ import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs'
24
+ import * as Module from 'node:module'
25
+ import { basename, extname, join, normalize, resolve, sep } from 'node:path'
26
+ import { fileURLToPath, pathToFileURL } from 'node:url'
27
+
28
+ const MIME = {
29
+ '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8',
30
+ '.css': 'text/css; charset=utf-8', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png',
31
+ '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.avif': 'image/avif', '.gif': 'image/gif',
32
+ '.ico': 'image/x-icon', '.woff2': 'font/woff2', '.woff': 'font/woff', '.txt': 'text/plain; charset=utf-8',
33
+ '.xml': 'application/xml', '.webmanifest': 'application/manifest+json', '.map': 'application/json',
34
+ '.mp4': 'video/mp4', '.webm': 'video/webm', '.pdf': 'application/pdf', '.wasm': 'application/wasm',
35
+ '.md': 'text/markdown; charset=utf-8', // llms.txt copies of the pages (§33), as brain start's static server sends them
36
+ }
37
+ // feeds are .xml files with their own type (SEO, §32)
38
+ const FEEDS = { 'rss.xml': 'application/rss+xml; charset=utf-8', 'atom.xml': 'application/atom+xml; charset=utf-8' }
39
+ const SECURITY = {
40
+ 'strict-transport-security': 'max-age=63072000; includeSubDomains',
41
+ 'x-content-type-options': 'nosniff',
42
+ 'referrer-policy': 'strict-origin-when-cross-origin',
43
+ 'x-frame-options': 'DENY',
44
+ 'permissions-policy': 'camera=(), microphone=(), geolocation=(), payment=(), usb=()',
45
+ 'cross-origin-opener-policy': 'same-origin',
46
+ // what serves the app and where it lives, for crawlers and LLMs that read headers (no version: that stays in the HTML)
47
+ 'x-powered-by': 'BRAINLLM (https://chrisifm.github.io/BRAINLLM/)',
48
+ }
49
+ // The build's policy (manifest `csp`, without hashes: the same one static hosting uses) plus this response's nonce in
50
+ // script-src; without it, a default that lets api() reach HTTPS APIs of other origins.
51
+ const CSP0 =
52
+ "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; " +
53
+ "font-src 'self' data:; connect-src 'self' https: wss:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'"
54
+ const withNonce = (policy, nonce) =>
55
+ /(^|;)\s*script-src\b/.test(policy)
56
+ ? policy.replace(/((?:^|;)\s*script-src\b[^;]*)/, `$1 'nonce-${nonce}'`)
57
+ : `${policy}; script-src 'self' 'nonce-${nonce}'`
58
+
59
+ const { env: ENV } = process // the only source of configuration
60
+ const json = v => JSON.stringify(v)
61
+ // JSON safe inside <script>: no `</script>`, no HTML comment openers, no line separators
62
+ const jsonInHtml = v => json(v ?? null).replace(/</g, '\\u003c').replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029')
63
+ const esc = s => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
64
+ const b64u = b => Buffer.from(b).toString('base64url')
65
+ const tag = s => createHash('sha1').update(s).digest('base64url').slice(0, 22)
66
+
67
+ // Errors a server function may throw on purpose: { errors: { field: msg } } → 422; { status, message } with 4xx → that
68
+ export class HttpError extends Error {
69
+ constructor(status, message, extra) {
70
+ super(message)
71
+ this.status = status
72
+ Object.assign(this, extra)
73
+ }
74
+ }
75
+ // `throw redirect('/login')` (or return it) from load/guards; only same-origin paths (open-redirect guard, §31)
76
+ export const redirect = (url, status = 303) => {
77
+ const u = String(url)
78
+ if (!u.startsWith('/') || u.startsWith('//') || u.startsWith('/\\')) throw new HttpError(500, 'redirect to another origin is not allowed: ' + u)
79
+ return { redirect: u, status }
80
+ }
81
+
82
+ export function createServer(o = {}) {
83
+ const dist = resolve(o.dist || process.cwd())
84
+ const env = Object.freeze({ ...(o.env || ENV) }) // ctx.env: what the supervisor passed, read-only
85
+ const TIMEOUT = +(o.timeout ?? env.BRAIN_TIMEOUT_MS ?? 10000)
86
+ const LIMIT = +(o.rateLimit ?? env.BRAIN_RPC_LIMIT ?? 60) // per IP + function, per minute
87
+ const MAX_BODY = 64 * 1024
88
+ const secret = o.secret ?? env.BRAIN_SECRET ?? ''
89
+ const log = o.log || ((level, msg, extra) => console[level === 'error' ? 'error' : 'log'](json({ t: new Date().toISOString(), level, msg, ...extra })))
90
+ const mf = readManifest(dist)
91
+ // the build's single route table (#187): already in match order, each with its RegExp source (named groups = params)
92
+ const routes = mf.routes.map(r => ({ ...r, rx: r.rx ? new RegExp(r.rx) : exact(r.path) }))
93
+ // the real path: Node imports file URLs through realpath, and the /_b/ hook must name the same modules (a symlinked
94
+ // dist such as /var → /private/var on macOS would otherwise load the core twice)
95
+ const distURL = pathToFileURL(real(dist) + sep).href
96
+ // Base path (§23 #148): the app lives under mf.base ("/app/"); routes, files and functions are matched on the app
97
+ // path (base stripped); redirects the app gives as app paths get it back.
98
+ const BASE = mf.base || '/'
99
+ const RD = mf.redirects || {}, RDX = (RD.patterns || []).map(p => ({ re: new RegExp(p.re), to: p.to, status: p.status || 301 }))
100
+ // `/old/` and `/old` are the same source (brain-seo normalizes the trailing slash)
101
+ const redirectOf = path => {
102
+ const p = path.length > 1 ? path.replace(/\/+$/, '') : path
103
+ const ex = RD.exact && (RD.exact[p] || RD.exact[p + '/'])
104
+ if (ex) return [ex[0], ex[1] || 301]
105
+ for (const x of RDX) {
106
+ const m = x.re.exec(path) || x.re.exec(p)
107
+ if (m) return [x.to.replace(/\$(\d+)/g, (_, i) => m[+i] ?? ''), x.status]
108
+ }
109
+ return null
110
+ }
111
+ const withBase = u => (typeof u === 'string' && BASE !== '/' && u.startsWith('/') && !u.startsWith('//') && !(u + '/').startsWith(BASE) ? BASE + u.slice(1) : u)
112
+ hook(distURL, BASE)
113
+
114
+ // ---------- helpers ----------
115
+ const send = (res, status, body, headers = {}) => {
116
+ res.writeHead(status, { ...SECURITY, ...(server.draining ? { connection: 'close' } : {}), ...headers })
117
+ res.end(body)
118
+ }
119
+ // HTML: `no-cache` + ETag (strong for files, weak for SSR) → a repeat visit gets 304 with no body (§35); the ETag is
120
+ // taken before the per-response nonce is injected, so it is stable
121
+ const html = (res, status, page, headers = {}, etag) => {
122
+ if (etag && status === 200) {
123
+ headers = { etag, ...headers }
124
+ const inm = res.req.headers['if-none-match']
125
+ if (inm && inm.split(',').some(t => t.trim().replace(/^W\//, '') === etag.replace(/^W\//, ''))) return send(res, 304, '', headers)
126
+ }
127
+ const nonce = randomBytes(16).toString('base64')
128
+ const body = page.replace(/<script(?![^>]*\bnonce=)/g, `<script nonce="${nonce}"`)
129
+ send(res, status, body, { 'content-type': MIME['.html'], 'content-security-policy': withNonce(mf.csp || CSP0, nonce), 'cache-control': 'no-cache', ...(mf.build ? { 'x-brain-build': mf.build } : {}), ...headers })
130
+ }
131
+ const notFound = (req, res) => {
132
+ const f = mf.notFound && file(mf.notFound)
133
+ if (f) return html(res, 404, readFileSync(f, 'utf8'))
134
+ html(res, 404, '<!doctype html><title>Not found</title><main><h1>Not found</h1></main>')
135
+ }
136
+ const failPage = (res, status, rid) =>
137
+ html(res, status, `<!doctype html><title>Error</title><main><h1>${status === 504 ? 'The page took too long' : 'Something went wrong'}</h1><p>Reference: ${esc(rid)}</p></main>`)
138
+ // a file inside dist/ (never outside: no `..`, no absolute paths)
139
+ function file(rel) {
140
+ const p = normalize(join(dist, rel))
141
+ if (p !== dist && !p.startsWith(dist + sep)) return null
142
+ try {
143
+ return statSync(p).isFile() ? p : null
144
+ } catch {
145
+ return null
146
+ }
147
+ }
148
+ const mods = new Map()
149
+ const load = rel => {
150
+ if (BASE !== '/' && rel.startsWith(BASE)) rel = rel.slice(BASE.length) // '/app/_b/x.js' → dist/_b/x.js
151
+ if (!mods.has(rel)) mods.set(rel, import(new URL(rel.replace(/^\//, ''), distURL).href))
152
+ return mods.get(rel)
153
+ }
154
+
155
+ const context = (req, res, url, extra) => {
156
+ const cookies = parseCookies(req.headers.cookie)
157
+ const { user, session: ses } = sessionFrom(cookies, secret)
158
+ return {
159
+ url, query: Object.fromEntries(url.searchParams), cookies, user, session: ses, env,
160
+ // `prefetch`: the router fetched this page ahead of a click (§19); a `load` with side effects can skip them
161
+ prefetch: req.headers['x-brain-prefetch'] === '1',
162
+ req: { method: req.method, headers: req.headers, ip: ipOf(req) }, ...extra,
163
+ }
164
+ }
165
+
166
+ // ---------- SSR ----------
167
+ let queue = Promise.resolve() // the DOM shim is global: renders run one at a time (load runs concurrently)
168
+ // forms: { '<hash>/<fn>': { values, errors, error } } — the state of a form posted without JS, re-rendered (§38)
169
+ // `fixed`: render this route with these props (the 403 and _error pages): no guards, no load
170
+ // `<meta cache="N">` (route.cache, #235): the SSR HTML of an anonymous GET is kept N s per URL (and language) and
171
+ // served again; between N and 2N s the copy is served while a fresh one renders in the background
172
+ // (stale-while-revalidate). Never for requests with a session cookie or Authorization, forms or error pages.
173
+ const SSRC = new Map(), SSRC_MAX = 500, SSRC_BYTES = 32 * 1024 * 1024, SSRC_ENTRY = 1024 * 1024
174
+ let ssrBytes = 0
175
+ async function cached(req, res, route, params, url, rid) {
176
+ const n = +route.cache, ms = n * 1000, key = (route.locale || '') + url.pathname + url.search
177
+ const fill = () =>
178
+ new Promise(ok => {
179
+ const cap = { req, $capture: (out, h, st) => {
180
+ const headers = Object.fromEntries(Object.entries(h).map(([k, v]) => [k.toLowerCase(), v]))
181
+ const privateResponse = headers['set-cookie'] || headers['vary'] || /private|no-store|no-cache/i.test(headers['cache-control'] || '')
182
+ ok(st === 200 ? { out, h, cacheable: !privateResponse, etag: 'W/"' + tag(out) + '"', t: Date.now(), bytes: Buffer.byteLength(out) } : null)
183
+ }, writeHead() {}, end: () => ok(null), setHeader() {}, getHeader() {} }
184
+ ssr(req, cap, route, params, url, rid, null, 200, null, true).catch(() => ok(null))
185
+ }).then(c => {
186
+ if (!c || !c.cacheable) {
187
+ ssrBytes -= SSRC.get(key)?.bytes || 0
188
+ SSRC.delete(key)
189
+ }
190
+ if (c && c.cacheable && c.bytes <= SSRC_ENTRY) {
191
+ ssrBytes -= SSRC.get(key)?.bytes || 0
192
+ SSRC.delete(key)
193
+ SSRC.set(key, c)
194
+ ssrBytes += c.bytes
195
+ while (SSRC.size > SSRC_MAX || ssrBytes > SSRC_BYTES) {
196
+ const oldest = SSRC.keys().next().value
197
+ ssrBytes -= SSRC.get(oldest).bytes
198
+ SSRC.delete(oldest)
199
+ }
200
+ }
201
+ return c
202
+ })
203
+ const serve = c => html(res, 200, c.out, { ...c.h, 'cache-control': c.cacheable ? `public, s-maxage=${n}, stale-while-revalidate=${n}` : 'no-store' }, c.etag)
204
+ let c = SSRC.get(key)
205
+ const age = c ? Date.now() - c.t : Infinity
206
+ if (c && age < 2 * ms) {
207
+ if (age >= ms && !c.busy) (c.busy = true), fill().finally(() => (c.busy = false))
208
+ return serve(c)
209
+ }
210
+ c = await fill()
211
+ return c ? serve(c) : ssr(req, res, route, params, url, rid, null, 200, null, true) // a redirect or an error: as usual
212
+ }
213
+ const anonymous = req => req.method === 'GET' && !req.headers.authorization && !req.headers.cookie
214
+
215
+ async function ssr(req, res, route, params, url, rid, forms = null, status = 200, fixed = null, nocache = false) {
216
+ // 103 Early Hints (#232): the route's preloads (the same Link values as _headers) before any work, once
217
+ if (route.links && route.links.length && res.writeEarlyHints && !res.$capture && !res.$hinted) {
218
+ res.$hinted = true
219
+ try {
220
+ res.writeEarlyHints({ link: route.links })
221
+ } catch {}
222
+ }
223
+ if (!nocache && route.cache > 0 && !forms && !fixed && status === 200 && anonymous(req)) return cached(req, res, route, params, url, rid)
224
+ const ctx = context(req, res, url, { params })
225
+ // a page rendered for a signed-in user is never stored (§35): after logout, Back must not show the previous
226
+ // user's page from the HTTP cache or bfcache. Anonymous pages keep `no-cache` + ETag.
227
+ const own = ctx.user != null || ctx.cookies[COOKIE] !== undefined
228
+ const keep = h => (own ? { ...h, 'cache-control': 'private, no-store' } : h)
229
+ let props = fixed || {}, extra = {}
230
+ if (!fixed && ((route.guards && route.guards.length) || (route.middleware && route.middleware.length))) {
231
+ let r
232
+ try {
233
+ const guards = [], middleware = []
234
+ for (const g of route.guards || []) guards.push(await load(g))
235
+ for (const m of route.middleware || []) middleware.push(await load(m))
236
+ r = await runChain({ guards, middleware, ctx })
237
+ } catch (e) {
238
+ log('error', 'guard failed', { rid, path: url.pathname, stack: String((e && e.stack) || e) })
239
+ return errorPage(req, res, route, params, url, rid)
240
+ }
241
+ if (r.redirect) return send(res, r.status, '', { location: withBase(r.redirect), 'cache-control': 'no-store', ...ctx.session.headers() })
242
+ if (r.deny !== undefined) return forbidden(req, res, url, rid, r.deny)
243
+ extra = r.headers
244
+ }
245
+ if (route.stream && route.server && !fixed && !forms && status === 200 && !res.$capture) return streamed(req, res, route, params, url, rid, ctx, keep(extra || {}))
246
+ try {
247
+ if (!fixed && route.server) {
248
+ const m = await load(route.server)
249
+ if (typeof m.load === 'function') props = (await withTimeout(Promise.resolve(m.load(ctx)), TIMEOUT)) ?? {}
250
+ }
251
+ } catch (e) {
252
+ if (e && e.redirect) return send(res, e.status || 303, '', { location: withBase(e.redirect), ...ctx.session.headers() })
253
+ if ((e instanceof HttpError && e.status === 404) || (e && e.notFound === true)) return notFound(req, res) // `throw notFound()` (F11)
254
+ const status = e instanceof HttpError && e.status === 504 ? 504 : 500
255
+ log('error', 'load failed', { rid, path: url.pathname, status, stack: String((e && e.stack) || e) })
256
+ return status === 500 ? errorPage(req, res, route, params, url, rid) : failPage(res, status, rid)
257
+ }
258
+ if (props && props.redirect) return send(res, props.status || 303, '', { location: withBase(props.redirect), ...ctx.session.headers() })
259
+ if (props && props.notFound === true) return notFound(req, res) // `return notFound()` (F11)
260
+ let body, seo, failed
261
+ try {
262
+ const at = { path: url.pathname, params: params || {}, query: Object.fromEntries(url.searchParams) } // route() (§41.5)
263
+ ;({ body, seo, failed } = await (queue = queue.then(() => render(route, props, forms, at), () => render(route, props, forms, at))))
264
+ } catch (e) {
265
+ log('error', 'render failed', { rid, path: url.pathname, stack: String((e && e.stack) || e) })
266
+ return failPage(res, 500, rid)
267
+ }
268
+ // a component that failed shows its fallback inside the page (still 200); the error goes to the log with the rid
269
+ 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) })
270
+ const page = readFileSync(file(route.page), 'utf8')
271
+ .replace('<!--brain:ssr-->', () => body)
272
+ .replace('<!--brain:props-->', () => jsonInHtml(props))
273
+ let out = head(page, seo, url.pathname)
274
+ // route params for route() in the browser (the client has no route table)
275
+ if (params && Object.keys(params).length) out = out.replace('</head>', () => `<meta name="brain-params" content="${esc(JSON.stringify(params))}"></head>`)
276
+ if (forms) out = out.replace('</body>', () => `<script type="application/json" id="brain-forms">${jsonInHtml(forms)}</script></body>`)
277
+ if (res.$capture) return res.$capture(out, keep({ ...extra, ...ctx.session.headers() }), status)
278
+ html(res, status, out, keep({ ...extra, ...ctx.session.headers() }), own ? undefined : 'W/"' + tag(out) + '"')
279
+ }
280
+ // Streaming SSR (#232, `route.stream`: the build sets it when the view's <meta seo> is static and it has a load()):
281
+ // the page up to the region (head, CSS, layout shell) is sent at once, the view when load() ends. The status and
282
+ // headers are already out by then, so a redirect from load() becomes location.replace and a failure an inline notice
283
+ // with the request id (both logged); load() cannot change the session on a streamed route.
284
+ async function streamed(req, res, route, params, url, rid, ctx, extra) {
285
+ const [top, bottom = ''] = readFileSync(file(route.page), 'utf8').split('<!--brain:ssr-->')
286
+ const sm = await load(route.server)
287
+ const seoF = sm.seo || (await load(route.view)).default.seo
288
+ if (route.i18n) await load(route.i18n)
289
+ const nonce = randomBytes(16).toString('base64'), n = h => h.replace(/<script(?![^>]*\bnonce=)/g, `<script nonce="${nonce}"`)
290
+ res.writeHead(200, {
291
+ ...SECURITY, 'content-type': MIME['.html'], 'content-security-policy': withNonce(mf.csp || CSP0, nonce), 'cache-control': 'no-cache',
292
+ ...(mf.build ? { 'x-brain-build': mf.build } : {}), ...extra, ...ctx.session.headers(),
293
+ })
294
+ globalThis.__brainLocale = route.locale || null
295
+ let seo0 = null
296
+ try {
297
+ seo0 = typeof seoF === 'function' ? seoF({}) : null
298
+ } finally {
299
+ globalThis.__brainLocale = null
300
+ }
301
+ // load() starts now: the loading view (#253) renders while it runs
302
+ const loadP = typeof sm.load === 'function' ? withTimeout(Promise.resolve().then(() => sm.load(ctx)), TIMEOUT) : Promise.resolve(null)
303
+ loadP.catch(() => {}) // handled below; never an unhandled rejection while the loading view renders
304
+ const at = { path: url.pathname, params: params || {}, query: Object.fromEntries(url.searchParams) }
305
+ res.write(n(head(top, seo0, url.pathname)))
306
+ // QA #253: the folder's _loading.brain goes in the region with the first chunk (SPEC: shows while load runs),
307
+ // between two marker comments; the last chunk starts with a script that removes them and what is between
308
+ let clear = ''
309
+ if (route.loading) {
310
+ try {
311
+ const lr = { ...route, view: route.loading, layouts: [], server: null }
312
+ const { body } = await (queue = queue.then(() => render(lr, {}, null, at), () => render(lr, {}, null, at)))
313
+ res.write(n(`<!--brain:loading-->${body}<!--/brain:loading-->`))
314
+ 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>`
315
+ } catch (e) {
316
+ log('error', 'loading view failed (streamed)', { rid, path: url.pathname, stack: String((e && e.stack) || e) })
317
+ }
318
+ }
319
+ const end = (body, props = {}, b = bottom) => res.end(n(clear + body + b.replace('<!--brain:props-->', () => jsonInHtml(props))))
320
+ // QA #289: the page closes with the _error.brain template (its client mounts the error view with { message, rid }),
321
+ // never with the route's own mount script, which would replace the error with the view rendered from {}. Without an
322
+ // _error.brain the notice stays: the view's mount script is left out.
323
+ const fail = async () => {
324
+ const body = await failBody(route, url, rid)
325
+ if (route.error && route.error.page && file(route.error.page)) {
326
+ const eb = readFileSync(file(route.error.page), 'utf8').split('<!--brain:ssr-->')[1]
327
+ if (eb !== undefined) return end(body, { message: 'Something went wrong.', rid }, eb)
328
+ }
329
+ end(body, {}, bottom.replace(/<script type="module">(?:(?!<\/script>)[\s\S])*?brain-view[\s\S]*?<\/script>/g, ''))
330
+ }
331
+ let props
332
+ try {
333
+ props = (await loadP) ?? {}
334
+ if (props.redirect || props.notFound === true) throw props
335
+ } catch (e) {
336
+ if (e && e.redirect) return end(`<script>location.replace(${jsonInHtml(withBase(e.redirect))})</script>`)
337
+ // a streamed page already sent its 200 head: the region gets the not-found notice and the page is noindex
338
+ 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, ''))
339
+ log('error', 'load failed (streamed)', { rid, path: url.pathname, status: 200, stack: String((e && e.stack) || e) }) // headers already out
340
+ return fail()
341
+ }
342
+ try {
343
+ const { body, failed } = await (queue = queue.then(() => render(route, props, null, at), () => render(route, props, null, at)))
344
+ 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) })
345
+ const pm = params && Object.keys(params).length ? `<meta name="brain-params" content="${esc(JSON.stringify(params))}">` : ''
346
+ end(pm + body, props)
347
+ } catch (e) {
348
+ log('error', 'render failed (streamed)', { rid, path: url.pathname, status: 200, stack: String((e && e.stack) || e) })
349
+ await fail()
350
+ }
351
+ }
352
+ // the head is already out: the folder's _error.brain (props { message, rid }) renders inside the region, else a notice
353
+ async function failBody(route, url, rid) {
354
+ const notice = `<p role="alert" class="brain-fallback">Something went wrong. Reference: ${esc(rid)}</p>`
355
+ if (!route.error) return notice
356
+ try {
357
+ const props = { message: 'Something went wrong.', rid }, at = { path: url.pathname, params: {}, query: {} }
358
+ return (await (queue = queue.then(() => render(route.error, props, null, at), () => render(route.error, props, null, at)))).body
359
+ } catch {
360
+ return notice
361
+ }
362
+ }
363
+ // deny → views/403.brain (manifest `forbidden`, props.reason) or the static 403.html, always status 403
364
+ function forbidden(req, res, url, rid, reason) {
365
+ if (mf.forbidden) return ssr(req, res, mf.forbidden, {}, url, rid, null, 403, { reason })
366
+ const f = file('403.html')
367
+ if (f) return html(res, 403, readFileSync(f, 'utf8'))
368
+ send(res, 403, 'Forbidden', { 'content-type': MIME['.txt'] })
369
+ }
370
+ // a failing load or guard → the folder's _error.brain (route `error`, props { message, rid }), else the generic page
371
+ function errorPage(req, res, route, params, url, rid) {
372
+ if (route.error) return ssr(req, res, route.error, params, url, rid, null, 500, { message: 'Something went wrong.', rid })
373
+ return failPage(res, 500, rid)
374
+ }
375
+ async function render(route, props, forms, at = null) {
376
+ const { install } = await load('_server/dom.js')
377
+ const doc = install()
378
+ const root = doc.createElement('div')
379
+ const core = await load(mf.core)
380
+ const mount = core.mount || core.m
381
+ const V = (await load(route.view)).default
382
+ const L = []
383
+ for (const l of route.layouts || []) L.push((await load(l)).default)
384
+ let tree = () => V(props)
385
+ for (let i = L.length - 1; i > 0; i--) {
386
+ const inner = tree, C = L[i]
387
+ tree = () => C({ children: inner })
388
+ }
389
+ if (route.i18n) await load(route.i18n) // the route's dictionary chunk: add(locale, {…}) before anything renders
390
+ globalThis.__brainForms = forms // form() reads it while the view is built (renders are serialized)
391
+ globalThis.__brainLocale = route.locale || null // t(), fmt and locale() of this page (§37)
392
+ globalThis.__brainAlternates = route.alternates || null // <LangSwitch> links, in the HTML before any JS
393
+ globalThis.__brainRoute = at // route(): app path, params, query of this request
394
+ globalThis.__brainBase = BASE !== '/' ? BASE : null // computed href/src get the base (brain.js attr)
395
+ const failed = (globalThis.__brainSSR = []) // components that fell back (brain-guard, §42): logged by ssr()
396
+ let stop
397
+ // #285: with `adopt` in the manifest (codegen navigates with fc/nx), the HTML is stamped so islands resume on it
398
+ const unstamp = mf.adopt && core._rt ? (await load('_server/brain-hydrate.js')).stamp(core._rt()) : null
399
+ try {
400
+ stop = L.length ? mount(L[0], root, { children: tree }) : mount(V, root, props)
401
+ } finally {
402
+ globalThis.__brainForms = null
403
+ if (unstamp) unstamp()
404
+ }
405
+ await new Promise(r => setTimeout(r, 0))
406
+ globalThis.__brainSSR = null
407
+ fillValues(root)
408
+ const body = root.innerHTML
409
+ stop()
410
+ // SEO head: the codegen emits `export const seo` / `head` in the view's server module; the client chunk's V.seo
411
+ // (older builds) is the fallback. Still inside the page's locale: `<meta seo title={t('…')}>` is translated.
412
+ let seo = null
413
+ try {
414
+ const sm = route.server ? await load(route.server) : null
415
+ const seoF = (sm && sm.seo) || V.seo, headF = (sm && sm.head) || V.head
416
+ seo = typeof seoF === 'function' ? seoF(props) : null
417
+ if (seo && typeof headF === 'function') seo = { ...seo, $head: headF(props) }
418
+ } catch {} finally {
419
+ globalThis.__brainLocale = globalThis.__brainAlternates = globalThis.__brainRoute = globalThis.__brainBase = null
420
+ }
421
+ return { body, seo, failed }
422
+ }
423
+
424
+ // ---------- RPC (transport; the logic is callRpc, shared with the dev server) ----------
425
+ async function rpc(req, res, hash, fn, url, rid) {
426
+ const fail = (status, error, { $h, ...extra } = {}) => send(res, status, json({ error, ...extra }), { 'content-type': MIME['.json'], 'cache-control': 'no-store', ...$h })
427
+ if (req.method !== 'POST') return fail(405, 'Use POST', { $h: { allow: 'POST' } })
428
+ if (req.headers['x-brain-rpc'] !== '1') return fail(403, 'Missing X-Brain-RPC header')
429
+ if (!sameOrigin(req)) return fail(403, 'Cross-origin request')
430
+ const modPath = mf.rpc[hash]
431
+ if (!modPath || !/^[A-Za-z_$][\w$]*$/.test(fn)) return fail(404, 'Unknown function')
432
+ const ra = overLimit(ipOf(req) + ' ' + hash + '/' + fn, LIMIT) // before reading the body
433
+ if (ra) return fail(429, 'Too many requests', { $h: { 'retry-after': String(ra) } })
434
+ let raw
435
+ try {
436
+ raw = await body(req, MAX_BODY)
437
+ } catch {
438
+ return fail(413, 'Body larger than 64 KB')
439
+ }
440
+ const ctx = context(req, res, url, { params: {} })
441
+ let f
442
+ try {
443
+ f = (await load(modPath))[fn]
444
+ } catch (e) {
445
+ log('error', 'rpc module failed to load', { rid, fn: hash + '/' + fn, stack: String((e && e.stack) || e) })
446
+ return fail(500, 'Internal error', { rid })
447
+ }
448
+ const r = await callRpc({ fn: f, args: raw, ctx, timeout: TIMEOUT })
449
+ 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) })
450
+ const b = r.status >= 500 ? { ...r.body, rid } : r.body
451
+ send(res, r.status, json(b), { 'content-type': MIME['.json'], 'cache-control': 'no-store', ...(r.headers || {}), ...ctx.session.headers() })
452
+ }
453
+ // strict (form posts, no custom header possible): a request with neither Origin nor Sec-Fetch-Site is refused
454
+ function sameOrigin(req, strict) {
455
+ const host = req.headers['x-forwarded-host'] || req.headers.host
456
+ const origin = req.headers.origin
457
+ if (origin) {
458
+ try {
459
+ return new URL(origin).host === host
460
+ } catch {
461
+ return false
462
+ }
463
+ }
464
+ const site = req.headers['sec-fetch-site']
465
+ return site ? site === 'same-origin' : !strict
466
+ }
467
+
468
+ // ---------- form posted without JS (§38; transport, the logic is callForm, shared with the dev server) ----------
469
+ async function formPost(req, res, route, params, url, rid) {
470
+ const plain = (status, text, h = {}) => send(res, status, text, { 'content-type': MIME['.txt'], 'cache-control': 'no-store', ...h })
471
+ if (!sameOrigin(req, true)) return plain(403, 'Cross-origin form post')
472
+ if (!/^application\/x-www-form-urlencoded/i.test(req.headers['content-type'] || '')) return plain(415, 'Forms are sent as application/x-www-form-urlencoded')
473
+ let raw
474
+ try {
475
+ raw = await body(req, MAX_BODY)
476
+ } catch {
477
+ return plain(413, 'Form larger than 64 KB')
478
+ }
479
+ const id = new URLSearchParams(raw).get('_brain') || '', modPath = mf.rpc[id.split('/')[0]]
480
+ if (!modPath) return plain(400, 'Unknown form')
481
+ const ctx = context(req, res, url, { params })
482
+ let module, rules
483
+ try {
484
+ module = await load(modPath)
485
+ rules = await load('_server/brain-form.js')
486
+ } catch (e) {
487
+ log('error', 'form module failed to load', { rid, fn: id, stack: String((e && e.stack) || e) })
488
+ return failPage(res, 500, rid)
489
+ }
490
+ const r = await callForm({ module, raw, ctx, rules, key: ipOf(req) + ' ' + id, limit: LIMIT, timeout: TIMEOUT })
491
+ if (r.cause) log('error', 'form failed', { rid, fn: id, stack: String((r.cause && r.cause.stack) || r.cause) })
492
+ if (r.status === 303) return send(res, 303, '', { location: withBase(r.location), 'cache-control': 'no-store', ...ctx.session.headers() })
493
+ if (r.status === 422) return ssr(req, res, route, params, url, rid, r.state, 422)
494
+ plain(r.status, r.body.error, r.headers)
495
+ }
496
+
497
+ // ---------- dynamic sitemap (§32) ----------
498
+ // A dynamic view without paths() can export `sitemap()` (manifest route `sitemap: true`): it returns the params of its
499
+ // pages, plus an optional `lastmod`, at request time. /sitemap.xml is the build's file with those URLs added (a sitemap
500
+ // index gets one more child, /sitemap-dynamic.xml). A file holds at most 50 000 URLs (sitemaps.org): when the build's
501
+ // URLs plus the dynamic ones pass that, /sitemap.xml becomes an index of /sitemap-static.xml (the build's file) and
502
+ // /sitemap-dynamic.xml; dynamic URLs past 50 000 are dropped with a warning. Absolute URLs take the origin of the
503
+ // build's sitemap (brain.json `site`); a build without a sitemap serves none. The list is kept 5 min (one sitemap() run
504
+ // at a time when it expires) and the response says max-age=300; a sitemap() that throws is logged and skipped.
505
+ const DYN = routes.filter(r => r.sitemap && r.server)
506
+ let dynList = null
507
+ const fill = (pattern, p) => {
508
+ let ok = true
509
+ const out = pattern.replace(/([:*])(\w+)/g, (_, k, n) => {
510
+ const v = p && p[n]
511
+ if (v == null || v === '') return (ok = false), ''
512
+ return k === '*' ? String(v).split('/').map(encodeURIComponent).join('/') : encodeURIComponent(String(v))
513
+ })
514
+ return ok ? out : null
515
+ }
516
+ const day = v => (v instanceof Date ? v.toISOString().slice(0, 10) : typeof v === 'string' && /^\d{4}-\d{2}-\d{2}/.test(v) ? v : null)
517
+ const MAX_URLS = 50000
518
+ let dynRun = null
519
+ async function collect(origin) {
520
+ const urls = []
521
+ let dropped = 0
522
+ for (const r of DYN) {
523
+ try {
524
+ const sm = await load(r.server)
525
+ const list = typeof sm.sitemap === 'function' ? await withTimeout(Promise.resolve().then(() => sm.sitemap({ env })), TIMEOUT) : []
526
+ for (const p of Array.isArray(list) ? list : []) {
527
+ const path = fill(r.path, p)
528
+ if (!path) continue
529
+ if (urls.length < MAX_URLS) urls.push({ loc: origin + withBase(path), lastmod: day(p.lastmod) })
530
+ else dropped++
531
+ }
532
+ } catch (e) {
533
+ log('error', 'sitemap() failed', { view: r.view, stack: String((e && e.stack) || e) })
534
+ }
535
+ }
536
+ if (dropped) log('warn', 'sitemap(): more than 50000 URLs, the rest are not listed', { dropped })
537
+ dynList = { at: Date.now(), urls }
538
+ return urls
539
+ }
540
+ async function dynamicUrls(origin) {
541
+ if (dynList && Date.now() - dynList.at < 300000) return dynList.urls
542
+ // concurrent requests after expiry share one run of every sitemap()
543
+ if (!dynRun) dynRun = collect(origin).finally(() => (dynRun = null))
544
+ return dynRun
545
+ }
546
+ async function serveSitemap(req, res, url) {
547
+ const f = file('/sitemap.xml')
548
+ if (!f) return false
549
+ const text = readFileSync(f, 'utf8')
550
+ const origin = [...text.matchAll(/<loc>(https?:\/\/[^<]+)<\/loc>/g)].map(m => URL.canParse(m[1]) && new URL(m[1]).origin).find(Boolean)
551
+ if (!origin) return false
552
+ const index = text.includes('<sitemapindex')
553
+ const urls = await dynamicUrls(origin)
554
+ // a urlset that would pass 50 000 URLs with the dynamic ones is split: index → static file + dynamic file
555
+ const split = !index && (text.match(/<url>/g) || []).length + urls.length > MAX_URLS
556
+ const xml = body => send(res, 200, req.method === 'HEAD' ? '' : body, { 'content-type': MIME['.xml'], 'cache-control': 'public, max-age=300' })
557
+ if (url.pathname === '/sitemap-static.xml') return split ? (xml(text), true) : false
558
+ if (url.pathname === '/sitemap-dynamic.xml' && !index && !split) return false
559
+ const child = p => ` <sitemap><loc>${esc(origin + withBase(p))}</loc></sitemap>\n`
560
+ const set = urls.map(u => ` <url><loc>${esc(u.loc)}</loc>${u.lastmod ? `<lastmod>${esc(u.lastmod)}</lastmod>` : ''}</url>\n`).join('')
561
+ xml(
562
+ url.pathname === '/sitemap-dynamic.xml'
563
+ ? `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${set}</urlset>\n`
564
+ : index
565
+ ? text.replace('</sitemapindex>', `${child('/sitemap-dynamic.xml')}</sitemapindex>`)
566
+ : split
567
+ ? `<?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`
568
+ : text.replace('</urlset>', set + '</urlset>'),
569
+ )
570
+ return true
571
+ }
572
+
573
+ // ---------- static ----------
574
+ // §33 content negotiation: `Accept` preferring text/markdown gets the page's Markdown twin (`/pricing` → pricing.md,
575
+ // `/` → index.md) when the build published one (`seo.markdown`, default true; never for noindex pages).
576
+ function serveTwin(req, res, url) {
577
+ if (!prefersMarkdown(req.headers.accept)) return false
578
+ const md = file(mdPath(decodeURIComponent(url.pathname)))
579
+ if (!md) return false
580
+ send(res, 200, req.method === 'HEAD' ? '' : readFileSync(md), { 'content-type': MIME['.md'], 'cache-control': 'public, max-age=3600', vary: 'accept' })
581
+ return true
582
+ }
583
+ function serveStatic(req, res, url) {
584
+ let p = decodeURIComponent(url.pathname)
585
+ const candidates = p.endsWith('/') ? [p + 'index.html'] : [p, p + '.html', p + '/index.html']
586
+ for (const c of candidates) {
587
+ const f = file(c)
588
+ if (!f) continue
589
+ const ext = extname(f), type = FEEDS[basename(f)] || MIME[ext] || 'application/octet-stream'
590
+ if (ext === '.html') {
591
+ // §33: the page's Markdown twin (`/pricing` → pricing.md) when the client prefers text/markdown
592
+ if (serveTwin(req, res, url)) return true
593
+ const md = file(mdPath(p))
594
+ const page = readFileSync(f, 'utf8')
595
+ // static HTML changes only with a deploy: its ETag is the build signature (content hash without a manifest)
596
+ return html(res, 200, page, md ? { vary: 'accept' } : {}, '"' + (mf.build || tag(page)) + '"'), true
597
+ }
598
+ const headers = {
599
+ 'content-type': type,
600
+ // the worker script must be revalidated on every check, or a CDN could keep an old worker for an hour (§35.7)
601
+ 'cache-control': p.startsWith('/_b/') ? 'public, max-age=31536000, immutable' : p === '/sw.js' ? 'no-cache' : 'public, max-age=3600',
602
+ vary: 'accept-encoding',
603
+ }
604
+ const ae = String(req.headers['accept-encoding'] || '')
605
+ for (const [enc, suffix] of [['br', '.br'], ['gzip', '.gz']]) {
606
+ if (ae.includes(enc) && existsSync(f + suffix)) {
607
+ send(res, 200, req.method === 'HEAD' ? '' : readFileSync(f + suffix), { ...headers, 'content-encoding': enc })
608
+ return true
609
+ }
610
+ }
611
+ send(res, 200, req.method === 'HEAD' ? '' : readFileSync(f), headers)
612
+ return true
613
+ }
614
+ return false
615
+ }
616
+
617
+ // ---------- dispatch ----------
618
+ const server = http(async (req, res) => {
619
+ const rid = req.headers['x-request-id'] && /^[\w-]{1,64}$/.test(req.headers['x-request-id']) ? req.headers['x-request-id'] : randomUUID()
620
+ res.setHeader('x-request-id', rid)
621
+ const t0 = performance.now()
622
+ res.on('finish', () => {
623
+ const ms = performance.now() - t0
624
+ if (ms > 1000) log('warn', 'slow request', { rid, path: req.url, status: res.statusCode, ms: Math.round(ms) })
625
+ })
626
+ try {
627
+ const full = new URL(req.url, 'http://' + (req.headers.host || 'localhost'))
628
+ // /_health stays at the root: the supervisor calls it there whatever the base
629
+ if (full.pathname === '/_health' || full.pathname === BASE + '_health') return send(res, 200, json({ ok: true, build: mf.build || null }), { 'content-type': MIME['.json'], 'cache-control': 'no-store' })
630
+ let url = full
631
+ if (BASE !== '/') {
632
+ if (full.pathname === BASE.slice(0, -1)) return send(res, 301, '', { location: BASE + full.search })
633
+ if (!full.pathname.startsWith(BASE)) return notFound(req, res)
634
+ url = new URL('/' + full.pathname.slice(BASE.length) + full.search, full) // the app path
635
+ }
636
+ // redirects.json (manifest `redirects`, brain-seo server_json): exact sources first, then patterns, on the app path;
637
+ // same-site targets get the base, the query is kept when the target has none
638
+ const to = redirectOf(url.pathname)
639
+ if (to && (req.method === 'GET' || req.method === 'HEAD')) {
640
+ const loc = /^https?:\/\//.test(to[0]) ? to[0] : withBase(to[0]) + (to[0].includes('?') ? '' : url.search)
641
+ return send(res, to[1], '', { location: loc, 'cache-control': to[1] === 301 || to[1] === 308 ? 'public, max-age=3600' : 'no-store' })
642
+ }
643
+ const r = /^\/_r\/([\w-]+)\/([\w$]+)$/.exec(url.pathname)
644
+ if (r) return await rpc(req, res, r[1], r[2], url, rid)
645
+ if (url.pathname.startsWith('/_r/')) return send(res, 404, json({ error: 'Unknown function' }), { 'content-type': MIME['.json'] })
646
+ if (url.pathname.startsWith('/_server/')) return notFound(req, res) // server code is never served
647
+ const post = req.method === 'POST'
648
+ if (!post && req.method !== 'GET' && req.method !== 'HEAD') return send(res, 405, 'Method not allowed', { allow: 'GET, HEAD, POST' })
649
+ for (const route of routes) {
650
+ const m = route.rx.exec(url.pathname)
651
+ if (!m) continue
652
+ // a form page without load is prerendered: GET serves that file (build ETag); only its POST needs the server
653
+ if (!post && route.forms && serveStatic(req, res, url)) return
654
+ if (!post && serveTwin(req, res, url)) return
655
+ return await (post ? formPost : ssr)(req, res, route, m.groups ? { ...m.groups } : {}, url, rid)
656
+ }
657
+ if (post) return send(res, 405, 'Method not allowed', { allow: 'GET, HEAD' })
658
+ if (DYN.length && (url.pathname === '/sitemap.xml' || url.pathname === '/sitemap-dynamic.xml' || url.pathname === '/sitemap-static.xml') && (await serveSitemap(req, res, url))) return
659
+ if (serveStatic(req, res, url)) return
660
+ notFound(req, res)
661
+ } catch (e) {
662
+ log('error', 'unhandled', { rid, path: req.url, stack: String((e && e.stack) || e) })
663
+ if (!res.headersSent) failPage(res, 500, rid)
664
+ else res.end()
665
+ }
666
+ })
667
+ server.headersTimeout = 15000
668
+ server.requestTimeout = 30000
669
+ return server
670
+ }
671
+
672
+ // ---------- session (signed cookie; shared with the dev server) ----------
673
+ const COOKIE = '__Host-session'
674
+ // The user of a request: the signed `__Host-session` cookie (HMAC-SHA256 with BRAIN_SECRET), plus the session handle
675
+ // to set/clear it. Same in `node server.mjs` and in `brain run` (the dev sidecar imports it).
676
+ export function sessionFrom(cookies, secret) {
677
+ const sign = data => b64u(createHmac('sha256', secret).update(data).digest())
678
+ let cur = null
679
+ const v = cookies && cookies[COOKIE]
680
+ if (v && secret) {
681
+ const i = v.lastIndexOf('.')
682
+ if (i > 0) {
683
+ const data = v.slice(0, i), mac = Buffer.from(v.slice(i + 1)), good = Buffer.from(sign(data))
684
+ if (mac.length === good.length && timingSafeEqual(mac, good))
685
+ try {
686
+ const s = JSON.parse(Buffer.from(data, 'base64url').toString())
687
+ if (s && s.exp > Date.now()) cur = s
688
+ } catch {}
689
+ }
690
+ }
691
+ const set = []
692
+ let cleared = false
693
+ const cookie = (value, maxAge) => `${COOKIE}=${value}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${maxAge}`
694
+ const user = cur ? cur.u : null
695
+ return {
696
+ user,
697
+ session: {
698
+ user,
699
+ set(u, { maxAge = 7 * 24 * 3600 } = {}) {
700
+ if (!secret) throw new Error('BRAIN_SECRET is not set: sessions need it (32+ random bytes in .brain_env)')
701
+ const data = b64u(JSON.stringify({ u, exp: Date.now() + maxAge * 1000 }))
702
+ set.push(cookie(`${data}.${sign(data)}`, maxAge))
703
+ },
704
+ clear() {
705
+ set.push(cookie('', 0))
706
+ cleared = true
707
+ },
708
+ // logout wipes this origin's storage on the device: localStorage, IndexedDB, the Cache API and service workers
709
+ // (§35: the next user sees nothing). Not "cache": Chrome clears the whole HTTP cache before it finishes the
710
+ // response, so the logout call took ~18 s (F16). Nothing private is in that cache: RPC is no-store and SSR
711
+ // HTML rendered with a session cookie is `private, no-store` (ssr() → keep).
712
+ headers: () => ({ ...(set.length ? { 'set-cookie': set } : {}), ...(cleared ? { 'clear-site-data': '"storage"' } : {}) }),
713
+ },
714
+ }
715
+ }
716
+
717
+ // ---------- guards and middleware (§22; shared with the dev server) ----------
718
+ // `_guard.jsbrain`: (ctx) => true | false | redirect('/login') | deny('reason'), returned or thrown.
719
+ // `_middleware.jsbrain`: async (ctx, next) => …; may set ctx.headers (response headers) or stop with redirect/deny.
720
+ // The same shapes come out of brain-router's redirect()/deny() in the browser.
721
+ export const deny = (reason = 'Forbidden') => ({ deny: String(reason) })
722
+ const samePath = u => typeof u === 'string' && u.startsWith('/') && !u.startsWith('//') && !u.startsWith('/\\')
723
+ function verdict(v) {
724
+ if (v === false) return { deny: 'Forbidden' }
725
+ if (typeof v === 'string') v = { redirect: v }
726
+ if (!v || typeof v !== 'object') return null
727
+ if (v.redirect !== undefined) {
728
+ if (!samePath(v.redirect)) throw new HttpError(500, 'redirect to another origin is not allowed: ' + v.redirect) // §31 open redirect
729
+ return { redirect: v.redirect, status: v.status || 303 }
730
+ }
731
+ return v.deny !== undefined ? { deny: String(v.deny) } : null
732
+ }
733
+ // Runs the route's guards (outer folder first), then its middleware as an onion (`next()` runs the rest).
734
+ // → { ok: true, headers } | { redirect, status } | { deny: reason }. Any other error propagates (→ 500 / _error).
735
+ export async function runChain({ guards = [], middleware = [], ctx }) {
736
+ const headers = (ctx.headers = ctx.headers || {})
737
+ for (const g of guards) {
738
+ const f = (g && g.default) || g
739
+ let r
740
+ try {
741
+ r = verdict(await f(ctx))
742
+ } catch (e) {
743
+ if (!(r = verdict(e))) throw e
744
+ }
745
+ if (r) return r
746
+ }
747
+ let i = 0, stop = null
748
+ const next = async () => {
749
+ const m = middleware[i++]
750
+ if (!m || stop) return
751
+ try {
752
+ const r = verdict(await ((m && m.default) || m)(ctx, next))
753
+ if (r) stop = stop || r
754
+ } catch (e) {
755
+ const r = verdict(e)
756
+ if (!r) throw e
757
+ stop = stop || r
758
+ }
759
+ }
760
+ await next()
761
+ return stop || { ok: true, headers }
762
+ }
763
+
764
+ // ---------- shared request logic (server.mjs handlers and the dev server's sidecar call the same code, §11.1) ----------
765
+ const HITS = new Map() // `${key}` → [windowStart, count]
766
+ let sweeping = null
767
+ // Fixed 60 s window per key (ip + function). Returns 0, or the seconds to wait (Retry-After).
768
+ export function overLimit(key, max = 60) {
769
+ if (!sweeping)
770
+ (sweeping = setInterval(() => {
771
+ const now = Date.now()
772
+ for (const [k, [t]] of HITS) if (now - t > 60000) HITS.delete(k)
773
+ }, 60000)).unref()
774
+ const now = Date.now()
775
+ let h = HITS.get(key)
776
+ if (!h || now - h[0] > 60000) HITS.set(key, (h = [now, 0]))
777
+ return ++h[1] > max ? Math.ceil((h[0] + 60000 - now) / 1000) : 0
778
+ }
779
+ const withTimeout = (p, ms) => {
780
+ let t
781
+ return Promise.race([p, new Promise((_, rej) => (t = setTimeout(() => rej(new HttpError(504, 'timeout')), ms)))]).finally(() => clearTimeout(t))
782
+ }
783
+ // RPC body → arguments: a JSON array, with __proto__/constructor/prototype keys dropped. Throws HttpError(400).
784
+ export function parseArgs(raw) {
785
+ let a
786
+ try {
787
+ a = raw ? JSON.parse(raw, (k, v) => (k === '__proto__' || k === 'constructor' || k === 'prototype' ? undefined : v)) : []
788
+ } catch {
789
+ throw new HttpError(400, 'Body is not JSON')
790
+ }
791
+ if (!Array.isArray(a)) throw new HttpError(400, 'Body must be a JSON array of arguments')
792
+ return a
793
+ }
794
+ // Runs one `server:` function → { status, body, headers?, cause? }. `args`: raw body string or an array. With `key`, the
795
+ // rate limit applies here too. `cause` (500/504) is the real error, for the caller's log; the body stays generic.
796
+ export async function callRpc({ fn, args, ctx, key, limit = 60, timeout = 10000 }) {
797
+ if (key) {
798
+ const ra = overLimit(key, limit)
799
+ if (ra) return { status: 429, body: { error: 'Too many requests' }, headers: { 'retry-after': String(ra) } }
800
+ }
801
+ if (typeof fn !== 'function') return { status: 404, body: { error: 'Unknown function' } }
802
+ let a
803
+ try {
804
+ a = typeof args === 'string' || args == null ? parseArgs(args || '') : args
805
+ } catch (e) {
806
+ return { status: e.status || 400, body: { error: e.message } }
807
+ }
808
+ try {
809
+ const v = await withTimeout(Promise.resolve(fn(ctx, ...a)), timeout)
810
+ return { status: 200, body: v ?? null }
811
+ } catch (e) {
812
+ if (e && e.errors && typeof e.errors === 'object') return { status: 422, body: { error: e.message || 'Invalid input', errors: e.errors } }
813
+ if (e instanceof HttpError && e.status === 504) return { status: 504, body: { error: 'Timeout' }, cause: e }
814
+ if (e instanceof HttpError && e.status >= 400 && e.status < 500) return { status: e.status, body: { error: e.message } }
815
+ return { status: 500, body: { error: 'Internal error' }, cause: e }
816
+ }
817
+ }
818
+ // A form posted without JS (§38) → { status: 303, location } | { status: 422, state } | { status, body } (400/429).
819
+ // `module` exports the function and `$forms`; `rules` = { rule, check } (brain-schema / brain-form). `state` is what the
820
+ // page re-renders with: { '<hash>/<fn>': { values, errors, error } }.
821
+ export async function callForm({ module, raw, ctx, rules, key, limit = 60, timeout = 10000 }) {
822
+ const q = new URLSearchParams(raw || ''), id = q.get('_brain') || '', fn = id.split('/')[1] || ''
823
+ if (key) {
824
+ const ra = overLimit(key, limit)
825
+ if (ra) return { status: 429, body: { error: 'Too many requests' }, headers: { 'retry-after': String(ra) } }
826
+ }
827
+ if (!/^[A-Za-z_$][\w$]*$/.test(fn) || typeof (module && module[fn]) !== 'function') return { status: 400, body: { error: 'Unknown form' } }
828
+ const schema = module.$forms && module.$forms[fn]
829
+ const { values, errors } = schema ? coerce(rules, schema, q) : { values: Object.fromEntries([...q].filter(([k]) => k !== '_brain')), errors: {} }
830
+ const again = (errs, error, cause) => ({ status: 422, state: { [id]: { values, errors: errs, error } }, cause })
831
+ if (Object.keys(errors).length) return again(errors, '')
832
+ let r
833
+ try {
834
+ r = await withTimeout(Promise.resolve(module[fn](ctx, values)), timeout)
835
+ } catch (e) {
836
+ if (e && e.errors && typeof e.errors === 'object') return again(e.errors, '')
837
+ if (e instanceof HttpError && e.status >= 400 && e.status < 500) return again({}, e.message)
838
+ return again({}, 'Something went wrong. Try again.', e)
839
+ }
840
+ if (r && r.errors) return again(r.errors, '')
841
+ const to = r && typeof r.redirect === 'string' && r.redirect.startsWith('/') && !r.redirect.startsWith('//') ? r.redirect : ctx.url.pathname + ctx.url.search
842
+ return { status: 303, location: to }
843
+ }
844
+
845
+ // ---------- pure helpers ----------
846
+ // form-encoded → values with the schema's types + messages (same rules and texts as form() in the browser)
847
+ function coerce(F, schema, q, base = '', values = {}, errors = {}) {
848
+ for (const k of Object.keys(schema)) {
849
+ const path = base ? base + '.' + k : k, r = F.rule(schema[k])
850
+ if (r.t === 'obj') {
851
+ values[k] = {}
852
+ coerce(F, r.s, q, path, values[k], errors)
853
+ continue
854
+ }
855
+ const one = v => (r.t === 'num' ? (v === '' || v == null ? null : Number(v)) : r.t === 'bool' ? v === 'on' || v === 'true' || v === '1' : v == null ? '' : v)
856
+ const v = r.list ? q.getAll(path).map(one) : r.t === 'bool' ? one(q.get(path)) : one(q.get(path) ?? '')
857
+ values[k] = v
858
+ const msg = F.check(r, r.t === 'num' && !r.list && v === null ? '' : r.t === 'num' && !r.list && Number.isNaN(v) ? q.get(path) : v)
859
+ if (msg) errors[path] = msg
860
+ }
861
+ return { values, errors }
862
+ }
863
+ function readManifest(dist) {
864
+ const f = join(dist, '_server', 'manifest.json')
865
+ const m = existsSync(f) ? JSON.parse(readFileSync(f, 'utf8')) : { routes: [], rpc: {} }
866
+ return { core: '/_b/brain.js', notFound: '404.html', ...m, routes: m.routes || [], rpc: m.rpc || {} }
867
+ }
868
+ // a route without `rx` (older manifest) matches only its exact path
869
+ const exact = p => new RegExp('^' + p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\/?$')
870
+ function parseCookies(h = '') {
871
+ const out = {}
872
+ for (const part of h.split(';')) {
873
+ const i = part.indexOf('=')
874
+ if (i > 0) {
875
+ const k = part.slice(0, i).trim()
876
+ if (k && !(k in out)) out[k] = decodeURIComponent(part.slice(i + 1).trim())
877
+ }
878
+ }
879
+ return out
880
+ }
881
+ const ipOf = req => String(req.headers['x-forwarded-for'] || '').split(',')[0].trim() || req.socket.remoteAddress || '?'
882
+ function body(req, max) {
883
+ return new Promise((ok, fail) => {
884
+ let n = 0
885
+ const parts = []
886
+ // past the limit the rest is drained without keeping it (so the 413 reaches the client); far past it, cut
887
+ req.on('data', c => {
888
+ n += c.length
889
+ if (n > 16 * max) req.destroy()
890
+ else if (n <= max) parts.push(c)
891
+ })
892
+ req.on('end', () => (n > max ? fail(new Error('too large')) : ok(Buffer.concat(parts).toString('utf8'))))
893
+ req.on('error', fail)
894
+ })
895
+ }
896
+ // bind:value / checked are properties; copy them into attributes so the SSR HTML shows them
897
+ function fillValues(n) {
898
+ for (const c of n.childNodes || []) {
899
+ if (c.nodeType !== 1) continue
900
+ const t = (c.tagName || '').toLowerCase()
901
+ if (t === 'textarea' && c.value != null) c.textContent = String(c.value)
902
+ else if (t === 'input' && c.type !== 'password') {
903
+ if (c.value != null && c.value !== '') c.setAttribute('value', String(c.value))
904
+ if (c.checked === true) c.setAttribute('checked', '')
905
+ } else if (t === 'option' && c.selected === true) c.setAttribute('selected', '')
906
+ // <select value={x}>: the option with that value starts selected (an option's value is its attribute or text)
907
+ else if (t === 'select' && c.value != null && c.value !== '') {
908
+ const pick = o => {
909
+ for (const x of o.childNodes || []) {
910
+ if (x.nodeType !== 1) continue
911
+ if (x.tagName === 'OPTION' && (x.getAttribute('value') ?? x.textContent) === String(c.value)) x.setAttribute('selected', '')
912
+ else pick(x)
913
+ }
914
+ }
915
+ pick(c)
916
+ }
917
+ fillValues(c)
918
+ }
919
+ }
920
+ // `/` → index.md, `/docs/setup/` → docs/setup.md (brain-seo files::md_path)
921
+ const mdPath = p => (p.replace(/^\/+|\/+$/g, '') || 'index') + '.md'
922
+ // true when `Accept` ranks text/markdown above text/html (an equal weight goes to whichever is listed first);
923
+ // wildcards alone never select Markdown, so browsers keep getting HTML
924
+ export function prefersMarkdown(accept) {
925
+ if (!accept) return false
926
+ let md = -1, html = -1, mdAt = 0, htmlAt = 0, any = -1
927
+ accept.split(',').forEach((part, i) => {
928
+ const [type, ...params] = part.trim().toLowerCase().split(';')
929
+ const qp = params.map(x => x.trim()).find(x => x.startsWith('q='))
930
+ const q = qp ? Number(qp.slice(2)) : 1
931
+ if (!(q >= 0)) return
932
+ if (type === 'text/markdown' || type === 'text/x-markdown') (md = Math.max(md, q)), (mdAt = mdAt || i + 1)
933
+ else if (type === 'text/html' || type === 'application/xhtml+xml') (html = Math.max(html, q)), (htmlAt = htmlAt || i + 1)
934
+ else if (type === 'text/*' || type === '*/*') any = Math.max(any, q)
935
+ })
936
+ if (html < 0) (html = any), (htmlAt = Infinity) // an explicit type beats a wildcard of the same weight
937
+ return md > 0 && (md > html || (md === html && mdAt < htmlAt))
938
+ }
939
+ // SEO head from View.seo(props): title, description, canonical, og:*/twitter:*, JSON-LD and the view's <head> block
940
+ // F5: the build writes an SSR route's canonical/og:url from its pattern (/jobs/:slug); the request has the real path.
941
+ // A view's own <head> tags replace the generated ones with the same name/property (one tag per key, SPEC «SEO»).
942
+ function head(page, seo, path) {
943
+ let h = page
944
+ if (path) {
945
+ // the attribute is already escaped HTML: only the request path that replaces a pattern is escaped
946
+ const real = v => v.replace(/^(https?:\/\/[^/]+)?(\/[^"]*)$/, (m, o = '', p) => (/\/[:*]/.test(p) ? o + esc(path) : m))
947
+ h = h.replace(/(<link[^>]*rel=["']canonical["'][^>]*href=")([^"]*)(")/i, (m, a, v, b) => a + real(v) + b)
948
+ h = h.replace(/(<meta[^>]*property=["']og:url["'][^>]*content=")([^"]*)(")/i, (m, a, v, b) => a + real(v) + b)
949
+ }
950
+ if (!seo) return h
951
+ const meta = (attr, key, val) => {
952
+ const tag = `<meta ${attr}="${esc(key)}" content="${esc(val)}">`
953
+ const rx = new RegExp(`<meta[^>]*\\b${attr}=["']${key.replace(/[.*+?^${}()|[\]\\:]/g, '\\$&')}["'][^>]*>`, 'i')
954
+ h = rx.test(h) ? h.replace(rx, () => tag) : h.replace('</head>', () => tag + '</head>')
955
+ }
956
+ for (const [k, v] of Object.entries(seo)) {
957
+ // `schema` becomes JSON-LD below and `lastmod` feeds it: neither is a meta tag (prerender seo_head does the same)
958
+ if (v == null || k === '$head' || k === 'schema' || k === 'lastmod') continue
959
+ if (k === 'title') h = /<title>[\s\S]*?<\/title>/i.test(h) ? h.replace(/<title>[\s\S]*?<\/title>/i, () => `<title>${esc(v)}</title>`) : h.replace('</head>', () => `<title>${esc(v)}</title></head>`)
960
+ else if (k === 'canonical') {
961
+ const tag = `<link rel="canonical" href="${esc(v)}">`
962
+ h = /<link[^>]*rel=["']canonical["'][^>]*>/i.test(h) ? h.replace(/<link[^>]*rel=["']canonical["'][^>]*>/i, () => tag) : h.replace('</head>', () => tag + '</head>')
963
+ if (seo['og:url'] == null) meta('property', 'og:url', v) // og:url follows the canonical unless the view sets its own
964
+ } else if (k === 'jsonld') h = h.replace('</head>', () => `<script type="application/ld+json">${jsonInHtml(v)}</script></head>`)
965
+ else if (/^(og|twitter|article):/.test(k)) meta('property', k, v)
966
+ else if (typeof v === 'string') meta('name', k, v)
967
+ }
968
+ // `<meta seo schema="Article">` without `jsonld` (§32): one node with this request's values. It replaces the node
969
+ // the build wrote into the template from the static attributes (same @type), so the page carries exactly one.
970
+ if (typeof seo.schema === 'string' && seo.jsonld == null && /^[A-Za-z]+$/.test(seo.schema)) {
971
+ const T = seo.schema
972
+ const canon = /<link[^>]*rel=["']canonical["'][^>]*href="([^"]*)"/i.exec(h)
973
+ const node = { '@context': 'https://schema.org', '@type': T }
974
+ if (seo.title != null) node[/^(Article|BlogPosting|NewsArticle|TechArticle|Report)$/.test(T) ? 'headline' : 'name'] = String(seo.title).slice(0, 110)
975
+ if (seo.description != null) node.description = String(seo.description)
976
+ if (canon) node.url = canon[1].replace(/&amp;/g, '&')
977
+ if (seo['og:image'] != null) node.image = String(seo['og:image']).replace(/^\/(?!\/)/, () => (canon && /^https?:\/\/[^/]+/.exec(node.url) || [''])[0] + '/')
978
+ if (seo.lastmod != null) node.dateModified = String(seo.lastmod)
979
+ const own = new RegExp(`<script[^>]*type=["']application/ld\\+json["'][^>]*>\\s*\\{"@context":"https://schema\\.org","@type":"${T}"[\\s\\S]*?</script>`, 'i')
980
+ const tag = `<script type="application/ld+json">${jsonInHtml(node)}</script>`
981
+ h = own.test(h) ? h.replace(own, () => tag) : h.replace('</head>', () => tag + '</head>')
982
+ }
983
+ if (seo.$head) {
984
+ for (const [, attr, key] of seo.$head.matchAll(/<meta[^>]*\b(property|name)=["']([^"']+)["'][^>]*>/gi))
985
+ h = h.replace(new RegExp(`<meta[^>]*\\b${attr}=["']${key.replace(/[.*+?^${}()|[\]\\:]/g, '\\$&')}["'][^>]*>`, 'gi'), '')
986
+ if (/<link[^>]*rel=["']canonical["']/i.test(seo.$head)) h = h.replace(/<link[^>]*rel=["']canonical["'][^>]*>/gi, '')
987
+ h = h.replace('</head>', () => seo.$head + '</head>')
988
+ }
989
+ return h
990
+ }
991
+ // `/_b/<hash>.js` imports inside the chunks resolve to files in dist/ (same hook as the build's prerender)
992
+ let hooked = false
993
+ function hook(base, app = '/') {
994
+ if (hooked) return
995
+ hooked = true
996
+ // chunks import '/_b/x.js', or '/app/_b/x.js' under a base path: both are dist/_b/x.js
997
+ const own = s => {
998
+ if (app !== '/' && s.startsWith(app + '_b/')) s = s.slice(app.length - 1)
999
+ return s.startsWith('/_b/') && { url: new URL('.' + s, base).href, shortCircuit: true }
1000
+ }
1001
+ // registerHooks (Node 22.15+, in-thread) replaces the deprecated register (DEP0205)
1002
+ if (Module.registerHooks) return void Module.registerHooks({ resolve: (s, c, next) => own(s) || next(s, c) })
1003
+ Module.register(
1004
+ 'data:text/javascript,' +
1005
+ encodeURIComponent(`export async function resolve(s, c, n) { const a = ${json(app)}; if (a !== '/' && s.startsWith(a + '_b/')) s = s.slice(a.length - 1); return s.startsWith('/_b/') ? { url: new URL('.' + s, ${json(base)}).href, shortCircuit: true } : n(s, c) }`)
1006
+ )
1007
+ }
1008
+
1009
+ // `node server.mjs` (directly or under `brain start`); paths compared after symlinks (/var → /private/var on macOS)
1010
+ function real(p) {
1011
+ try {
1012
+ return realpathSync(resolve(p))
1013
+ } catch {
1014
+ return resolve(p)
1015
+ }
1016
+ }
1017
+ if (process.argv[1] && real(process.argv[1]) === real(fileURLToPath(import.meta.url))) {
1018
+ const s = createServer({ dist: fileURLToPath(new URL('.', import.meta.url)) })
1019
+ const fd = ENV.BRAIN_LISTEN_FD
1020
+ const where = fd ? { fd: Number(fd) } : { port: +(ENV.PORT || 3000), host: ENV.HOST || '127.0.0.1' }
1021
+ s.listen(where, () => {
1022
+ process.stdout.write(`brain:ready ${process.pid}\n`) // the supervisor waits for this line (§34)
1023
+ console.log(json({ t: new Date().toISOString(), level: 'info', msg: 'listening', ...(fd ? { fd: Number(fd) } : where) }))
1024
+ })
1025
+ // SIGTERM/SIGINT: stop accepting, answer what is in flight or arrives on open connections (each then closes), for up
1026
+ // to BRAIN_DRAIN_MS (10 s), then cut whatever is left; exit 0 either way
1027
+ const drain = () => {
1028
+ if (s.draining) return
1029
+ s.draining = true
1030
+ const ms = +(ENV.BRAIN_DRAIN_MS || 10000)
1031
+ const force = setTimeout(() => {
1032
+ s.closeAllConnections()
1033
+ process.exit(0)
1034
+ }, ms)
1035
+ force.unref()
1036
+ // Stop accepting but drop nothing (QA #120): a keep-alive connection between two requests, or one just accepted
1037
+ // whose request is not parsed yet, still gets its answer, with `Connection: close`. (http.Server#close would
1038
+ // destroy idle connections: under load that is an ECONNRESET per reload.) Idle sockets end by keepAliveTimeout.
1039
+ s.prependListener('request', (q, r) => {
1040
+ r.shouldKeepAlive = false
1041
+ })
1042
+ net.Server.prototype.close.call(s, () => process.exit(0))
1043
+ }
1044
+ process.on('SIGTERM', drain)
1045
+ process.on('SIGINT', drain)
1046
+ }