@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.
- package/LICENSE +21 -0
- package/README.md +4 -2
- package/brain-anim.js +21 -0
- package/brain-ch.js +254 -0
- package/brain-consent.js +38 -0
- package/brain-data.js +547 -0
- package/brain-dev.js +100 -0
- package/brain-env.js +160 -0
- package/brain-err.js +242 -0
- package/brain-form.js +260 -0
- package/brain-guard.js +242 -0
- package/brain-hydrate.js +72 -0
- package/brain-i18n.js +111 -0
- package/brain-inspect.js +745 -0
- package/brain-island.js +106 -0
- package/brain-lazy.js +88 -0
- package/brain-mcp.js +58 -0
- package/brain-mock.js +80 -0
- package/brain-overlay.js +123 -0
- package/brain-persist.js +65 -0
- package/brain-router.js +353 -0
- package/brain-rpc.js +17 -0
- package/brain-schema.js +69 -0
- package/brain-wc.js +69 -0
- package/brain-why.js +198 -0
- package/brain-worker.js +66 -0
- package/brain.js +421 -0
- package/package.json +20 -4
- package/server/server.mjs +882 -0
- package/shim/dom.js +448 -0
- package/shim/probe.js +111 -0
- package/sw/brain-sw.js +103 -0
- package/themes/contrast.cssbrain +39 -0
- package/themes/dark.cssbrain +39 -0
- package/themes/glass.cssbrain +39 -0
- package/themes/paper.cssbrain +39 -0
- package/ui/button.brain +31 -0
- package/ui/center.brain +11 -0
- package/ui/checkbox.brain +13 -0
- package/ui/container.brain +11 -0
- package/ui/dialog.brain +29 -0
- package/ui/error-state.brain +53 -0
- package/ui/field.brain +34 -0
- package/ui/grid.brain +11 -0
- package/ui/input.brain +23 -0
- package/ui/lang-switch.brain +38 -0
- package/ui/menu.brain +64 -0
- package/ui/row.brain +12 -0
- package/ui/select.brain +29 -0
- package/ui/sidebar.brain +17 -0
- package/ui/spacer.brain +12 -0
- package/ui/stack.brain +10 -0
- package/ui/table.brain +73 -0
- package/ui/tabs.brain +33 -0
- 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
|
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
|
+
}
|