@christianirack/brainllm-runtime 0.0.0-stage → 0.1.0

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