@christianirack/brainllm-runtime 0.8.11 → 0.9.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/brain-links.js +4 -2
- package/brain-list.js +9 -0
- package/brain-prefetch-shared.js +1 -1
- package/brain-router.js +11 -4
- package/brain-text.js +56 -0
- package/brain-url.js +36 -3
- package/brain.js +14 -8
- package/package.json +1 -1
- package/server/server.mjs +371 -72
- package/shim/dom.js +8 -17
- package/sw/brain-sw.js +57 -3
- package/templates/apple-signin.js +117 -0
- package/ui/dialog.brain +2 -2
package/server/server.mjs
CHANGED
|
@@ -26,25 +26,30 @@ import { installNativeSockets } from './native-sockets.mjs'
|
|
|
26
26
|
import { llmClient } from './llm.mjs'
|
|
27
27
|
import { BlockList, isIP } from 'node:net'
|
|
28
28
|
import { Readable } from 'node:stream'
|
|
29
|
+
import { pipeline } from 'node:stream/promises'
|
|
29
30
|
import zlib from 'node:zlib'
|
|
30
31
|
import { createCipheriv, createDecipheriv, createHash, createHmac, hkdfSync, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'
|
|
31
|
-
import { existsSync, readFileSync, realpathSync, statSync, lstatSync } from 'node:fs'
|
|
32
|
+
import { constants as FS, existsSync, readFileSync, realpathSync, statSync, lstatSync } from 'node:fs'
|
|
33
|
+
import { open } from 'node:fs/promises'
|
|
32
34
|
import * as Module from 'node:module'
|
|
33
35
|
import { basename, dirname, extname, join, normalize, resolve, sep } from 'node:path'
|
|
34
36
|
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
35
37
|
|
|
36
38
|
const MIME = {
|
|
37
39
|
'.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8',
|
|
40
|
+
'.brain': 'text/html; charset=utf-8', '.jsbrain': 'text/javascript; charset=utf-8', '.cssbrain': 'text/css; charset=utf-8',
|
|
38
41
|
'.css': 'text/css; charset=utf-8', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png',
|
|
39
42
|
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.avif': 'image/avif', '.gif': 'image/gif',
|
|
40
43
|
'.ico': 'image/x-icon', '.woff2': 'font/woff2', '.woff': 'font/woff', '.txt': 'text/plain; charset=utf-8',
|
|
41
44
|
'.xml': 'application/xml', '.webmanifest': 'application/manifest+json', '.map': 'application/json',
|
|
42
45
|
'.mp4': 'video/mp4', '.webm': 'video/webm', '.pdf': 'application/pdf', '.wasm': 'application/wasm',
|
|
46
|
+
'.m3u8': 'application/vnd.apple.mpegurl', '.m3u': 'application/vnd.apple.mpegurl', '.m4s': 'video/iso.segment',
|
|
47
|
+
'.mpd': 'application/dash+xml', '.ts': 'video/mp2t', '.vtt': 'text/vtt; charset=utf-8', '.mp3': 'audio/mpeg',
|
|
43
48
|
'.md': 'text/markdown; charset=utf-8', // llms.txt copies of the pages (§33), as brain start's static server sends them
|
|
44
49
|
}
|
|
45
50
|
// feeds are .xml files with their own type (SEO, §32)
|
|
46
51
|
const FEEDS = { 'rss.xml': 'application/rss+xml; charset=utf-8', 'atom.xml': 'application/atom+xml; charset=utf-8' }
|
|
47
|
-
const
|
|
52
|
+
const DEFAULT_SECURITY = {
|
|
48
53
|
'strict-transport-security': 'max-age=63072000; includeSubDomains',
|
|
49
54
|
'x-content-type-options': 'nosniff',
|
|
50
55
|
'referrer-policy': 'strict-origin-when-cross-origin',
|
|
@@ -114,8 +119,10 @@ function safeLocation(value) {
|
|
|
114
119
|
function linkTo(route, path) {
|
|
115
120
|
const t = route && route.hrefs
|
|
116
121
|
if (!t || typeof path !== 'string' || path[0] !== '/' || path[1] === '/') return path
|
|
117
|
-
const m = /^[^?#]*/.exec(path)[0]
|
|
118
|
-
|
|
122
|
+
const m = /^[^?#]*/.exec(path)[0], key = m.replace(/\/+$/, '') || '/'
|
|
123
|
+
if (key === t.p || key.startsWith(t.p + '/')) return path
|
|
124
|
+
let n = t.s[key] || (new RegExp(t.re).test(key) && (key === '/' ? t.p : t.p + key))
|
|
125
|
+
if (n && key !== '/' && m.endsWith('/') && !n.endsWith('/')) n += '/'
|
|
119
126
|
return n ? n + path.slice(m.length) : path
|
|
120
127
|
}
|
|
121
128
|
function localizeLinks(route, html) {
|
|
@@ -194,13 +201,26 @@ const cacheSearch = url => {
|
|
|
194
201
|
return kept.size ? '?' + kept : ''
|
|
195
202
|
}
|
|
196
203
|
// Messages this file writes itself, safe to log as the cause; anything else in an error message may hold private data.
|
|
197
|
-
const
|
|
204
|
+
const AUTH_UNAVAILABLE_CAUSES = new Set(['the backend did not answer', 'the server is shutting down', '8 session checks are already in flight', 'the backend took more than 2 s'].map(why => `Native authentication is unavailable: ${why}`))
|
|
205
|
+
const BACKEND_UNAVAILABLE_CAUSES = new Set(['Backend request unavailable', 'Backend request timed out', 'Backend response exceeds its byte budget', 'Native backend is unavailable', 'Native file request failed', 'Native file response is invalid', 'Native resource response must be JSON', 'Native resource response is invalid', 'Native resource request failed', 'Native authentication response is unavailable', ...AUTH_UNAVAILABLE_CAUSES])
|
|
206
|
+
const OWN_CAUSES = new Set([...BACKEND_UNAVAILABLE_CAUSES, 'Backend request capacity is unavailable', 'Backend stream capacity is unavailable', 'Invalid same-site redirect location', 'Invalid redirect status', 'Invalid final response status', 'Invalid configured redirect location', 'Static file validators are busy; retry shortly', 'Static file validator deadline exceeded; retry shortly', 'Static file changed; retry shortly', 'Static file changed while computing its validator', 'timeout'])
|
|
198
207
|
const CODE_SHAPE = /^[A-Z][A-Z0-9_]{0,63}$/
|
|
199
208
|
const varyEncoding = v => (/accept-encoding/i.test(v || '') ? v : [v, 'Accept-Encoding'].filter(Boolean).join(', '))
|
|
200
209
|
function bodyEncoding(req, status, headers) {
|
|
201
210
|
if (status < 200 || status === 204 || status === 206 || status === 304 || headers['content-encoding'] || !COMPRESSIBLE.test(headers['content-type'] || '') || /no-transform/i.test(headers['cache-control'] || '')) return null
|
|
202
211
|
return acceptedEncodings(req.headers['accept-encoding'], DYNAMIC)[0] || ''
|
|
203
212
|
}
|
|
213
|
+
// One `bytes=` span of a file of `size` bytes (RFC 9110 §14): { start, end } inclusive, false when it cannot be
|
|
214
|
+
// satisfied (416), or null when the header is not one well-formed span (several, another unit, reversed): the whole
|
|
215
|
+
// file is sent then, which the RFC allows.
|
|
216
|
+
function byteRange(header, size) {
|
|
217
|
+
const m = /^bytes=(\d*)-(\d*)$/.exec(String(header).trim())
|
|
218
|
+
if (!m || (m[1] === '' && m[2] === '')) return null
|
|
219
|
+
if (m[1] === '') return Number(m[2]) > 0 && size > 0 ? { start: Math.max(0, size - Number(m[2])), end: size - 1 } : false
|
|
220
|
+
const start = Number(m[1])
|
|
221
|
+
if (m[2] !== '' && Number(m[2]) < start) return null
|
|
222
|
+
return start < size ? { start, end: m[2] === '' ? size - 1 : Math.min(Number(m[2]), size - 1) } : false
|
|
223
|
+
}
|
|
204
224
|
const encodeBody = (enc, buf) => enc === 'zstd' ? zlib.zstdCompressSync(buf) : enc === 'br' ? zlib.brotliCompressSync(buf, { params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 5, [zlib.constants.BROTLI_PARAM_SIZE_HINT]: buf.length } }) : zlib.gzipSync(buf)
|
|
205
225
|
const encodeStream = enc => enc === 'zstd' ? zlib.createZstdCompress() : enc === 'br' ? zlib.createBrotliCompress({ params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 5 } }) : zlib.createGzip()
|
|
206
226
|
|
|
@@ -220,11 +240,22 @@ function googleSignInPage(page) {
|
|
|
220
240
|
for (const item of tag.slice(5, -1).matchAll(/([^\s=/]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g)) {
|
|
221
241
|
const key = item[1].toLowerCase(); if (!attrs.has(key)) attrs.set(key, item[2] ?? item[3] ?? item[4] ?? '')
|
|
222
242
|
}
|
|
223
|
-
|
|
243
|
+
// Google's and Apple's sign-in popups both answer through window.opener.
|
|
244
|
+
if (['brain-google-signin', 'brain-apple-signin'].includes(attrs.get('name')?.toLowerCase()) && attrs.get('content') === '1') return true
|
|
224
245
|
}
|
|
225
246
|
return false
|
|
226
247
|
}
|
|
227
248
|
|
|
249
|
+
// One-use provider cookies (Google nonce, Apple nonce, LinkedIn state): relayed to the backend and cleared at logout.
|
|
250
|
+
const PROVIDER_COOKIES = ['__Host-brain-google', '__Host-brain-apple', '__Host-brain-linkedin']
|
|
251
|
+
// The native backend owns state, nonce and provider configuration. Only this fixed protocol destination is external.
|
|
252
|
+
function providerStartRedirect(method, path, status, location) {
|
|
253
|
+
if (!['GET', 'HEAD'].includes(method) || path !== '/_brain/auth/linkedin/start' || status !== 303 || typeof location !== 'string' || /[\\\x00-\x20\x7f]/.test(location)) return false
|
|
254
|
+
try {
|
|
255
|
+
const target = new URL(location)
|
|
256
|
+
return target.origin === 'https://www.linkedin.com' && target.pathname === '/oauth/v2/authorization' && !target.username && !target.password && !target.hash
|
|
257
|
+
} catch { return false }
|
|
258
|
+
}
|
|
228
259
|
// One configured loopback authority. No user-controlled target, proxy, redirect, auth cache or pending queue.
|
|
229
260
|
// ctx.backend.files(bucket): the same file calls as job and route handlers (docs/backend/files.md).
|
|
230
261
|
function bucketClient(target, key, bucket, readBudgetMs = () => 0) {
|
|
@@ -327,7 +358,15 @@ export function nativeAuthBridge(raw, refused, serviceKey, report, ownSession, r
|
|
|
327
358
|
}
|
|
328
359
|
const safeUser = user => {
|
|
329
360
|
if (!user || typeof user.id !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(user.id) || typeof user.email !== 'string' || user.email.length > 254 || typeof user.role !== 'string' || !/^[a-z][a-z0-9_]{0,47}$/.test(user.role) || ['public','owner'].includes(user.role) || typeof user.verified !== 'boolean') throw new HttpError(503,'Native authentication response is unavailable')
|
|
330
|
-
|
|
361
|
+
const clean = {id:user.id,email:user.email,role:user.role,verified:user.verified}
|
|
362
|
+
if (typeof user.name === 'string' && user.name.trim() && user.name.length <= 400 && [...user.name].length <= 200 && !/[\u0000-\u001f\u007f-\u009f]/.test(user.name)) clean.name = user.name.trim()
|
|
363
|
+
if (typeof user.picture === 'string' && Buffer.byteLength(user.picture) <= 2048 && !/[\u0000-\u001f\u007f-\u009f]/.test(user.picture)) {
|
|
364
|
+
try {
|
|
365
|
+
const url = new URL(user.picture)
|
|
366
|
+
if (url.protocol === 'https:' && url.hostname && !url.username && !url.password) clean.picture = user.picture
|
|
367
|
+
} catch {}
|
|
368
|
+
}
|
|
369
|
+
return Object.freeze(clean)
|
|
331
370
|
}
|
|
332
371
|
const unavailable = (why = 'the backend did not answer') => new HttpError(503, `Native authentication is unavailable: ${why}`)
|
|
333
372
|
const call = (incoming, response, path, method, rid, origin) => {
|
|
@@ -365,7 +404,7 @@ export function nativeAuthBridge(raw, refused, serviceKey, report, ownSession, r
|
|
|
365
404
|
for (const value of backend.headers['set-cookie'] || []) {
|
|
366
405
|
if (value.length > 8192 || /[\r\n]/.test(value)) return finish(true)
|
|
367
406
|
const name = value.split('=', 1)[0]
|
|
368
|
-
if (names.has(name) || name
|
|
407
|
+
if (names.has(name) || PROVIDER_COOKIES.includes(name)) cleared.push(value)
|
|
369
408
|
}
|
|
370
409
|
finish(false, { 'set-cookie': cleared, 'clear-site-data': '"storage"' })
|
|
371
410
|
})
|
|
@@ -439,14 +478,14 @@ export function nativeAuthBridge(raw, refused, serviceKey, report, ownSession, r
|
|
|
439
478
|
const selected = []
|
|
440
479
|
for (const part of String(req.headers.cookie || '').split(';')) {
|
|
441
480
|
const name = part.trim().split('=')[0]
|
|
442
|
-
if (['__Host-brain','__Host-brain-access','__Host-brain-refresh'
|
|
481
|
+
if (['__Host-brain','__Host-brain-access','__Host-brain-refresh',...PROVIDER_COOKIES].includes(name)) selected.push(part.trim())
|
|
443
482
|
}
|
|
444
483
|
if (selected.length) headers.cookie = selected.join('; ')
|
|
445
484
|
const client = forwardedFor(req)
|
|
446
485
|
if (client) headers['x-forwarded-for'] = client
|
|
447
486
|
return headers
|
|
448
487
|
}
|
|
449
|
-
const nativeCookies = values => (values || []).filter(value => /^(?:__Host-brain(?:-access|-refresh|-google)?)=/.test(value) && value.length <= 8192).slice(0,8)
|
|
488
|
+
const nativeCookies = values => (values || []).filter(value => /^(?:__Host-brain(?:-access|-refresh|-google|-apple|-linkedin)?)=/.test(value) && value.length <= 8192).slice(0,8)
|
|
450
489
|
// /_brain/files: both directions stream with backpressure. The bucket enforces its own maxBytes and access checks.
|
|
451
490
|
function fileStream(req, res, url, rid) {
|
|
452
491
|
if (stopped || streams >= 32) throw bridgeBusy('Backend stream capacity is unavailable')
|
|
@@ -526,7 +565,7 @@ export function nativeAuthBridge(raw, refused, serviceKey, report, ownSession, r
|
|
|
526
565
|
const cookies = nativeCookies(backend.headers['set-cookie'])
|
|
527
566
|
if (cookies.length) safe['set-cookie'] = cookies
|
|
528
567
|
if (backend.statusCode >= 500 && !res.$capture) res.$brainCause = 'answered by the native backend'
|
|
529
|
-
try { reply(res,backend.statusCode || 502,req.method === 'HEAD' ? '' : Buffer.concat(chunks,bytes),safe); resolve() } catch (e) { reject(e) }
|
|
568
|
+
try { reply(res,backend.statusCode || 502,req.method === 'HEAD' ? '' : Buffer.concat(chunks,bytes),safe,providerStartRedirect(req.method,url.pathname,backend.statusCode,safe.location)); resolve() } catch (e) { reject(e) }
|
|
530
569
|
}
|
|
531
570
|
const cancel = () => { outgoing?.destroy(); finish(true) }
|
|
532
571
|
const data = chunk => {
|
|
@@ -985,7 +1024,7 @@ export function createServer(o = {}) {
|
|
|
985
1024
|
// a thrown plain object, string or undefined has no stack: say what kind of value it was (never the value itself)
|
|
986
1025
|
if (!(e instanceof Error)) out.thrown = e === null ? 'null' : Array.isArray(e) ? 'array' : typeof e
|
|
987
1026
|
if (e && typeof e === 'object' && (e.temporary === true || e.status === 429 || e.status === 503)) out.temporary = true
|
|
988
|
-
if (e instanceof HttpError &&
|
|
1027
|
+
if (e instanceof HttpError && OWN_CAUSES.has(e.message)) out.cause = e.message
|
|
989
1028
|
if (e && e.code === 'ERR_MODULE_NOT_FOUND') out.fix = 'this process runs an older build whose files are gone; restart it after brain build'
|
|
990
1029
|
if (e && CODE_SHAPE.test(e.transport || '')) out.transport = e.transport
|
|
991
1030
|
if (e && typeof e.code === 'string' && CODE_SHAPE.test(e.code)) out.backendCode = e.code
|
|
@@ -996,6 +1035,11 @@ export function createServer(o = {}) {
|
|
|
996
1035
|
return out
|
|
997
1036
|
}
|
|
998
1037
|
const mf = readManifest(dist)
|
|
1038
|
+
// The build policy controls attribution independently of CSP and other security headers.
|
|
1039
|
+
let compilerBranding=mf.compilerBranding!==false
|
|
1040
|
+
try {const path=join(dist,'brain-build.json'),meta=lstatSync(path);if(meta.isFile()&&!meta.isSymbolicLink()&&meta.size<=16384&&JSON.parse(readFileSync(path,'utf8')).compilerBranding===false)compilerBranding=false}catch{}
|
|
1041
|
+
const SECURITY={...DEFAULT_SECURITY}
|
|
1042
|
+
if(!compilerBranding)delete SECURITY['x-generated-by']
|
|
999
1043
|
// brain.json `server` (§11.1), written into the manifest by the build; createServer options and env still win
|
|
1000
1044
|
const SL = mf.server || {}
|
|
1001
1045
|
const TIMEOUT = +(o.timeout ?? env.BRAIN_TIMEOUT_MS ?? SL.timeoutMs ?? 10000)
|
|
@@ -1027,6 +1071,48 @@ export function createServer(o = {}) {
|
|
|
1027
1071
|
}
|
|
1028
1072
|
return resolveClient(req.socket.remoteAddress, forwarded, proxies)
|
|
1029
1073
|
}
|
|
1074
|
+
// platform.access (docs/backend/access.md): /api, /_brain and /s answer this site only. Refused sources are counted
|
|
1075
|
+
// per client address (at most 512, the oldest dropped) for `brain access sources`, and logged at most 16 a minute.
|
|
1076
|
+
const access = accessPolicy(o.access ?? mf.access ?? {}, mf.site || env.PUBLIC_SITE_URL)
|
|
1077
|
+
const refusedSources = new Map()
|
|
1078
|
+
const budgetKey = `edge ${++servers} `
|
|
1079
|
+
let accessWindow = 0, accessLogs = 0, inFlight = 0, limitedWindow = 0, limited = 0, limitedLogs = 0
|
|
1080
|
+
// the dynamic budget (accessBudget) and the in-flight cap: past either the answer is an immediate 429 or 503 with
|
|
1081
|
+
// Retry-After, no queue, and one log line per minute sums what was turned away
|
|
1082
|
+
const overBudget = (req, res, path, client) => {
|
|
1083
|
+
const wait = access ? accessBudget(path, client, access, budgetKey) : 0
|
|
1084
|
+
const busy = !wait && access.concurrency !== null && !ACCESS_EXEMPT.test(path) && inFlight >= access.concurrency
|
|
1085
|
+
if (!wait && !busy) {
|
|
1086
|
+
if (access.concurrency !== null) { inFlight++; res.once('close', () => inFlight--) }
|
|
1087
|
+
return false
|
|
1088
|
+
}
|
|
1089
|
+
const minute = Math.floor(Date.now() / 60000)
|
|
1090
|
+
if (limitedWindow !== minute) {
|
|
1091
|
+
if (limited) log('warn', 'requests limited', { count: limited, minute: new Date(limitedWindow * 60000).toISOString(), fix: 'raise platform.access.perMinute or concurrency if real visitors are among them' })
|
|
1092
|
+
limitedWindow = minute; limited = 0; limitedLogs = 0
|
|
1093
|
+
}
|
|
1094
|
+
limited++
|
|
1095
|
+
if (limitedLogs++ < 4) log('warn', 'request limited', { ip: client, path: String(path).slice(0, 256), reason: busy ? 'concurrency' : 'perMinute', userAgent: typeof req.headers['user-agent'] === 'string' ? req.headers['user-agent'].slice(0, 256) : null })
|
|
1096
|
+
send(res, busy ? 503 : 429, busy ? 'Busy, try again shortly' : 'Too many requests', { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store', 'retry-after': String(busy ? 2 : wait) })
|
|
1097
|
+
return true
|
|
1098
|
+
}
|
|
1099
|
+
const accessRefused = (req, rid, path, client, reason) => {
|
|
1100
|
+
const bound = v => typeof v === 'string' ? v.slice(0, 256) : null
|
|
1101
|
+
const now = new Date().toISOString()
|
|
1102
|
+
let entry = refusedSources.get(client)
|
|
1103
|
+
if (entry) refusedSources.delete(client)
|
|
1104
|
+
else {
|
|
1105
|
+
if (refusedSources.size >= 512) refusedSources.delete(refusedSources.keys().next().value)
|
|
1106
|
+
entry = { ip: client, count: 0, first: now, reasons: {} }
|
|
1107
|
+
}
|
|
1108
|
+
entry.count++; entry.last = now; entry.reasons[reason] = (entry.reasons[reason] || 0) + 1
|
|
1109
|
+
Object.assign(entry, { path: bound(path), origin: bound(req.headers.origin), referer: bound(req.headers.referer), host: bound(req.headers['x-forwarded-host'] || req.headers.host), userAgent: bound(req.headers['user-agent']), country: bound(req.headers['cf-ipcountry']) })
|
|
1110
|
+
refusedSources.set(client, entry)
|
|
1111
|
+
const minute = Math.floor(Date.now() / 60000)
|
|
1112
|
+
if (accessWindow !== minute) { accessWindow = minute; accessLogs = 0 }
|
|
1113
|
+
if (accessLogs++ >= 16) return
|
|
1114
|
+
log('warn', 'access refused', { code: 'B0752', reason, rid, ip: client, path: entry.path, origin: entry.origin, referer: entry.referer, secFetchSite: bound(req.headers['sec-fetch-site']), host: entry.host, userAgent: entry.userAgent, fix: 'the native API answers this site only; a page of the site, an API key, or an address in platform.access.ips passes. Run brain explain B0752' })
|
|
1115
|
+
}
|
|
1030
1116
|
// server/routes/* of the native backend, listed by the build: only these exact shapes cross the bridge
|
|
1031
1117
|
const nativeRoutes = (Array.isArray(mf.nativeRoutes) ? mf.nativeRoutes : []).map(route => {
|
|
1032
1118
|
if (typeof route?.path !== 'string' || !Array.isArray(route.headers)) throw Error('Manifest nativeRoutes must list path and headers')
|
|
@@ -1127,17 +1213,22 @@ export function createServer(o = {}) {
|
|
|
1127
1213
|
// HTML: `no-cache` + ETag (strong for files, weak for SSR) → a repeat visit gets 304 with no body (§35); the ETag is
|
|
1128
1214
|
// taken before the per-response nonce is injected, so it is stable
|
|
1129
1215
|
const html = (res, status, page, headers = {}, etag) => {
|
|
1216
|
+
// A 304 updates the cached document's policy too; keep it identical to its 200 without minting a CSP nonce.
|
|
1217
|
+
headers = { 'cache-control': 'no-cache', ...(googleSignInPage(page) ? { 'cross-origin-opener-policy': 'same-origin-allow-popups' } : {}), ...(mf.build ? { 'x-brain-build': mf.build } : {}), ...headers }
|
|
1130
1218
|
if (etag && status === 200) {
|
|
1131
1219
|
headers = { etag, ...headers }
|
|
1132
1220
|
const inm = res.req.headers['if-none-match']
|
|
1133
1221
|
if (inm && inm.split(',').some(t => t.trim().replace(/^W\//, '') === etag.replace(/^W\//, ''))) return send(res, 304, '', headers)
|
|
1134
1222
|
}
|
|
1223
|
+
// RFC 9111 section 4.3.5 lets HEAD update cached GET headers without replacing its body.
|
|
1224
|
+
// As with 304, omit a new body-bound policy so the cached script nonce remains authorized.
|
|
1225
|
+
if (res.req.method === 'HEAD') return send(res, status, '', { 'content-type': MIME['.html'], ...headers })
|
|
1135
1226
|
const nonce = randomBytes(16).toString('base64')
|
|
1136
1227
|
// behavior analytics (docs/backend/analytics.md): with a native backend every page loads its collector as an async
|
|
1137
1228
|
// module served by that backend, outside the build and the route budgets; BRAIN_ANALYTICS=off skips the tag
|
|
1138
1229
|
if (analyticsTag && !page.includes(analyticsTag)) page = page.includes('</head>') ? page.replace('</head>', analyticsTag + '</head>') : page + analyticsTag
|
|
1139
1230
|
const body = page.replace(/<script(?![^>]*\bnonce=)/g, `<script nonce="${nonce}"`)
|
|
1140
|
-
send(res, status, body, { 'content-type': MIME['.html'], 'content-security-policy': withNonce(mf.csp || CSP0, nonce),
|
|
1231
|
+
send(res, status, body, { 'content-type': MIME['.html'], 'content-security-policy': withNonce(mf.csp || CSP0, nonce), ...headers })
|
|
1141
1232
|
}
|
|
1142
1233
|
const notFound = (req, res) => failPage(res, 404, res.getHeader('x-request-id'))
|
|
1143
1234
|
// `gone()` from load (§33): 410 tells crawlers the URL was removed for good; views/410.brain, else the 404 page
|
|
@@ -1150,7 +1241,7 @@ export function createServer(o = {}) {
|
|
|
1150
1241
|
}
|
|
1151
1242
|
// `/403`, `/404.html`, `/503`…: the status when the app has that page, else 0 and the path is an ordinary one
|
|
1152
1243
|
const reservedStatus = pathname => {
|
|
1153
|
-
const m = /^\/(403|404|410|5\d\d)(?:\.html)?$/.exec(pathname)
|
|
1244
|
+
const m = /^\/(403|404|410|5\d\d)(?:\.(?:html|brain))?$/.exec(pathname)
|
|
1154
1245
|
if (!m) return 0
|
|
1155
1246
|
const n = +m[1]
|
|
1156
1247
|
return file(`${n}.html`) || (mf.errors && mf.errors[n]) || (n === 403 && mf.forbidden) || (n === 404 && mf.notFound) ? n : 0
|
|
@@ -1301,16 +1392,30 @@ export function createServer(o = {}) {
|
|
|
1301
1392
|
}
|
|
1302
1393
|
const anonymous = req => (req.method === 'GET' || req.method === 'HEAD') && !req.headers.authorization && !req.headers.cookie
|
|
1303
1394
|
|
|
1395
|
+
const preloadHints = new WeakMap()
|
|
1304
1396
|
async function ssr(req, res, route, params, url, rid, forms = null, status = 200, fixed = null, nocache = false) {
|
|
1305
1397
|
// the route's preloads (the same Link values as _headers): on the response, where hosts turn them into early hints,
|
|
1306
1398
|
// capped so a long list cannot trip a proxy's header buffer. A 103 (#232) only when opted in, and never to HTTP/1.0.
|
|
1307
1399
|
if (route.links && route.links.length && !res.$capture && !res.$hinted) {
|
|
1308
1400
|
res.$hinted = true
|
|
1309
|
-
|
|
1310
|
-
if (
|
|
1311
|
-
|
|
1401
|
+
let selected = preloadHints.get(route)
|
|
1402
|
+
if (!selected) {
|
|
1403
|
+
selected = []
|
|
1404
|
+
let bytes = 0
|
|
1405
|
+
// Preserve graph order within a priority; render-blocking styles and fonts precede script hints.
|
|
1406
|
+
const priority = value => /(?:^|;)\s*as=(?:style|font)(?:;|$)/.test(value) ? 0 : 1
|
|
1407
|
+
for (const value of [...route.links].sort((a, b) => priority(a) - priority(b))) {
|
|
1408
|
+
const size = Buffer.byteLength(value) + (selected.length ? 2 : 0)
|
|
1409
|
+
if (bytes + size > 2048) continue
|
|
1410
|
+
selected.push(value); bytes += size
|
|
1411
|
+
}
|
|
1412
|
+
preloadHints.set(route, selected)
|
|
1413
|
+
if (selected.length < route.links.length) log('warn', 'preload hints trimmed to 2048 bytes; fix: reduce the route preload graph', { route: route.path, kept: selected.length, dropped: route.links.length - selected.length })
|
|
1414
|
+
}
|
|
1415
|
+
if (selected.length && !res.headersSent) res.setHeader('link', selected.join(', '))
|
|
1416
|
+
if (selected.length && EARLY_HINTS && res.writeEarlyHints && req.httpVersion !== '1.0') {
|
|
1312
1417
|
try {
|
|
1313
|
-
res.writeEarlyHints({ link:
|
|
1418
|
+
res.writeEarlyHints({ link: selected })
|
|
1314
1419
|
} catch {}
|
|
1315
1420
|
}
|
|
1316
1421
|
}
|
|
@@ -1389,7 +1494,7 @@ export function createServer(o = {}) {
|
|
|
1389
1494
|
// a page behind a guard passes it only for its visitor: it stays out of the index even when it renders (§33)
|
|
1390
1495
|
if (route.guards && route.guards.length) res.setHeader('x-robots-tag', NOINDEX)
|
|
1391
1496
|
// with seo.trailingSlash "ignore" both forms answer: the canonical takes the form the sitemap lists (no slash)
|
|
1392
|
-
let out = head(page, seo, SLASH === 'always' || url.pathname === '/' ? url.pathname : url.pathname.replace(/\/+$/, '') || '/', url.search)
|
|
1497
|
+
let out = head(page, seo, SLASH === 'always' || url.pathname === '/' ? url.pathname : url.pathname.replace(/\/+$/, '') || '/', url.search, route, BASE.slice(0, -1))
|
|
1393
1498
|
// route params for route() in the browser (the client has no route table)
|
|
1394
1499
|
if (params && Object.keys(params).length) out = out.replace('</head>', () => `<meta name="brain-params" content="${esc(JSON.stringify(params))}"></head>`)
|
|
1395
1500
|
if (forms) out = out.replace('</body>', () => `<script type="application/json" id="brain-forms">${jsonInHtml(forms)}</script></body>`)
|
|
@@ -1427,9 +1532,18 @@ export function createServer(o = {}) {
|
|
|
1427
1532
|
const seoF = sm.seo || (await load(route.view)).default.seo
|
|
1428
1533
|
if (route.i18n) await load(route.i18n)
|
|
1429
1534
|
const nonce = randomBytes(16).toString('base64'), n = h => h.replace(/<script(?![^>]*\bnonce=)/g, `<script nonce="${nonce}"`)
|
|
1535
|
+
globalThis.__brainLocale = route.locale || null
|
|
1536
|
+
let seo0 = null
|
|
1537
|
+
try {
|
|
1538
|
+
seo0 = typeof seoF === 'function' ? seoF({}) : null
|
|
1539
|
+
} finally {
|
|
1540
|
+
globalThis.__brainLocale = null
|
|
1541
|
+
}
|
|
1542
|
+
const initialHead = head(top, seo0, url.pathname, url.search, route, BASE.slice(0, -1))
|
|
1430
1543
|
const enc = COMPRESS && req.method !== 'HEAD' ? acceptedEncodings(req.headers['accept-encoding'], DYNAMIC)[0] : null
|
|
1431
1544
|
res.writeHead(200, {
|
|
1432
|
-
...SECURITY, 'content-type': MIME['.html'], 'content-security-policy': withNonce(mf.csp || CSP0, nonce), 'cache-control': 'no-cache',
|
|
1545
|
+
...SECURITY, 'content-type': MIME['.html'], ...(req.method === 'HEAD' ? {} : { 'content-security-policy': withNonce(mf.csp || CSP0, nonce) }), 'cache-control': 'no-cache',
|
|
1546
|
+
...(googleSignInPage(initialHead) ? { 'cross-origin-opener-policy': 'same-origin-allow-popups' } : {}),
|
|
1433
1547
|
...(mf.build ? { 'x-brain-build': mf.build } : {}), ...extra, ...ctx.session.headers(),
|
|
1434
1548
|
...(COMPRESS ? { vary: varyEncoding(extra.vary) } : {}), ...(enc ? { 'content-encoding': enc } : {}),
|
|
1435
1549
|
})
|
|
@@ -1437,15 +1551,8 @@ export function createServer(o = {}) {
|
|
|
1437
1551
|
const zs = enc ? encodeStream(enc) : null
|
|
1438
1552
|
if (zs) zs.pipe(res)
|
|
1439
1553
|
const write = s => (zs ? (zs.write(s), zs.flush()) : res.write(s))
|
|
1440
|
-
globalThis.__brainLocale = route.locale || null
|
|
1441
|
-
let seo0 = null
|
|
1442
|
-
try {
|
|
1443
|
-
seo0 = typeof seoF === 'function' ? seoF({}) : null
|
|
1444
|
-
} finally {
|
|
1445
|
-
globalThis.__brainLocale = null
|
|
1446
|
-
}
|
|
1447
1554
|
const at = { path: url.pathname, params: params || {}, query: Object.fromEntries(url.searchParams) }
|
|
1448
|
-
write(n(
|
|
1555
|
+
write(n(initialHead))
|
|
1449
1556
|
// QA #253: the folder's _loading.brain goes in the region with the first chunk (SPEC: shows while load runs),
|
|
1450
1557
|
// between two marker comments; the last chunk starts with a script that removes them and what is between
|
|
1451
1558
|
let clear = ''
|
|
@@ -1723,7 +1830,9 @@ export function createServer(o = {}) {
|
|
|
1723
1830
|
}
|
|
1724
1831
|
// §33 S01: every sitemap file holds at most 50 000 URLs and SITEMAP_BYTES bytes of UTF-8 XML; the dynamic URLs fill
|
|
1725
1832
|
// /sitemap-dynamic.xml, /sitemap-dynamic-2.xml, … in sitemap() order, so the split is deterministic
|
|
1726
|
-
const
|
|
1833
|
+
const XML_SIGNATURE = compilerBranding ? '<!-- by BRAINLLM https://chrisifm.github.io/BRAINLLM/ -->' : ''
|
|
1834
|
+
const XML_HEADER = `<?xml version="1.0" encoding="UTF-8"?>\n${XML_SIGNATURE}\n`
|
|
1835
|
+
const SET_HEAD = XML_HEADER + '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
|
|
1727
1836
|
const SET_FOOT = '</urlset>\n'
|
|
1728
1837
|
const bytes = t => Buffer.byteLength(t)
|
|
1729
1838
|
function pages(urls) {
|
|
@@ -1741,7 +1850,9 @@ export function createServer(o = {}) {
|
|
|
1741
1850
|
async function serveSitemap(req, res, url) {
|
|
1742
1851
|
const f = file('/sitemap.xml')
|
|
1743
1852
|
if (!f) return false
|
|
1744
|
-
const
|
|
1853
|
+
const original = readFileSync(f, 'utf8')
|
|
1854
|
+
const declaration = original.startsWith('<?xml') ? original.indexOf('?>') + 2 : 0
|
|
1855
|
+
const text = !XML_SIGNATURE || original.includes(XML_SIGNATURE) ? original : declaration > 1 ? original.slice(0, declaration) + '\n' + XML_SIGNATURE + original.slice(declaration) : XML_SIGNATURE + '\n' + original
|
|
1745
1856
|
const origin = mf.site || [...text.matchAll(/<loc>(https?:\/\/[^<]+)<\/loc>/g)].map(m => URL.canParse(m[1]) && new URL(m[1]).origin).find(Boolean)
|
|
1746
1857
|
if (!origin) return false
|
|
1747
1858
|
const index = text.includes('<sitemapindex')
|
|
@@ -1761,13 +1872,47 @@ export function createServer(o = {}) {
|
|
|
1761
1872
|
index
|
|
1762
1873
|
? text.replace('</sitemapindex>', `${kids}</sitemapindex>`)
|
|
1763
1874
|
: split
|
|
1764
|
-
?
|
|
1875
|
+
? `${XML_HEADER}<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${child('/sitemap-static.xml')}${kids}</sitemapindex>\n`
|
|
1765
1876
|
: text.replace('</urlset>', sets[0] + '</urlset>'),
|
|
1766
1877
|
)
|
|
1767
1878
|
return true
|
|
1768
1879
|
}
|
|
1769
1880
|
|
|
1770
1881
|
// ---------- static ----------
|
|
1882
|
+
// Content validators use bounded buffers and a bounded metadata cache; a full media file never enters the heap.
|
|
1883
|
+
const fileTags = new Map(), hashingFiles = new Map()
|
|
1884
|
+
async function fileTag(handle, stat) {
|
|
1885
|
+
const key = `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}`
|
|
1886
|
+
if (fileTags.has(key)) return fileTags.get(key)
|
|
1887
|
+
if (hashingFiles.has(key)) return hashingFiles.get(key)
|
|
1888
|
+
if (hashingFiles.size >= 8) throw new HttpError(503, 'Static file validators are busy; retry shortly')
|
|
1889
|
+
const task = (async () => {
|
|
1890
|
+
const hash = createHash('sha256'), buf = Buffer.alloc(65536), until = performance.now() + 30000
|
|
1891
|
+
let offset = 0
|
|
1892
|
+
while (offset < stat.size) {
|
|
1893
|
+
if (performance.now() > until) throw new HttpError(503, 'Static file validator deadline exceeded; retry shortly')
|
|
1894
|
+
const { bytesRead } = await handle.read(buf, 0, Math.min(buf.length, stat.size - offset), offset)
|
|
1895
|
+
if (!bytesRead) throw new HttpError(503, 'Static file changed while computing its validator')
|
|
1896
|
+
hash.update(buf.subarray(0, bytesRead)); offset += bytesRead
|
|
1897
|
+
}
|
|
1898
|
+
const after = await handle.stat()
|
|
1899
|
+
if (after.size !== stat.size || after.mtimeMs !== stat.mtimeMs || after.ctimeMs !== stat.ctimeMs) throw new HttpError(503, 'Static file changed; retry shortly')
|
|
1900
|
+
const etag = '"' + hash.digest('base64url') + '"'
|
|
1901
|
+
fileTags.set(key, etag)
|
|
1902
|
+
if (fileTags.size > 256) fileTags.delete(fileTags.keys().next().value)
|
|
1903
|
+
return etag
|
|
1904
|
+
})()
|
|
1905
|
+
hashingFiles.set(key, task)
|
|
1906
|
+
try { return await task } finally { hashingFiles.delete(key) }
|
|
1907
|
+
}
|
|
1908
|
+
async function streamFile(req, res, handle, status, headers, span) {
|
|
1909
|
+
if (req.method === 'HEAD') return send(res, status, '', headers)
|
|
1910
|
+
res.writeHead(status, { ...SECURITY, ...(server.draining ? { connection: 'close' } : {}), ...headers })
|
|
1911
|
+
// The descriptor stays pinned from stat and hashing through transmission, including atomic deployments.
|
|
1912
|
+
const length = Number(headers['content-length'])
|
|
1913
|
+
if (!length) return res.end()
|
|
1914
|
+
await pipeline(handle.createReadStream({ start: 0, end: length - 1, ...span, autoClose: false }), res)
|
|
1915
|
+
}
|
|
1771
1916
|
// Never served (brain-pack private.rs holds the same list for the Rust handler and every generated host config).
|
|
1772
1917
|
const PRIVATE_PREFIXES = ['_server', '_brain', 'server.mjs']
|
|
1773
1918
|
const PRIVATE_FILES = ['_headers', '_redirects']
|
|
@@ -1795,7 +1940,7 @@ export function createServer(o = {}) {
|
|
|
1795
1940
|
// site configured) or with a per-request pattern gets no header, so this never invents a URL.
|
|
1796
1941
|
function twinCanonical(path) {
|
|
1797
1942
|
const stem = path.replace(/\.md$/, '').replace(/\/+$/, '')
|
|
1798
|
-
const page = file(stem ? stem + '.html' : 'index.html') || file(stem + '/index.html')
|
|
1943
|
+
const page = file(stem ? stem + '.html' : 'index.html') || file(stem + '/index.html') || file(stem ? stem + '.brain' : 'index.brain') || file(stem + '/index.brain')
|
|
1799
1944
|
if (!page) return {}
|
|
1800
1945
|
const tag = /<link\b[^>]*\brel=["']canonical["'][^>]*>/i.exec(readFileSync(page, 'utf8'))?.[0]
|
|
1801
1946
|
const href = tag && /\bhref="(https?:\/\/[^"<>\s]+)"/.exec(tag)?.[1].replaceAll('&', '&')
|
|
@@ -1804,21 +1949,21 @@ export function createServer(o = {}) {
|
|
|
1804
1949
|
// What exists whatever the routes say: generated sitemaps, built files and the icon browsers ask for by habit.
|
|
1805
1950
|
async function serveFixed(req, res, url) {
|
|
1806
1951
|
if (DYN.length && /^\/sitemap(-static|-dynamic(-\d+)?)?\.xml$/.test(url.pathname) && (await serveSitemap(req, res, url))) return true
|
|
1807
|
-
if (serveStatic(req, res, url)) return true
|
|
1952
|
+
if (await serveStatic(req, res, url)) return true
|
|
1808
1953
|
if (url.pathname !== '/favicon.ico' || !file('favicon.svg')) return false
|
|
1809
1954
|
send(res, 301, '', { location: withBase('/favicon.svg'), 'cache-control': 'public, max-age=86400' })
|
|
1810
1955
|
return true
|
|
1811
1956
|
}
|
|
1812
1957
|
|
|
1813
|
-
function serveStatic(req, res, url) {
|
|
1958
|
+
async function serveStatic(req, res, url) {
|
|
1814
1959
|
const p = publicStaticPath(url.pathname)
|
|
1815
1960
|
if (p === null) return false
|
|
1816
|
-
const candidates = p.endsWith('/') ? [p + 'index.html', ...(p.length > 1 ? [p.slice(0, -1) + '.html'] : [])] : [p, p + '.html', p + '/index.html']
|
|
1961
|
+
const candidates = p.endsWith('/') ? [p + 'index.html', p + 'index.brain', ...(p.length > 1 ? [p.slice(0, -1) + '.html', p.slice(0, -1) + '.brain'] : [])] : [p, p + '.html', p + '/index.html', p + '.brain', p + '/index.brain']
|
|
1817
1962
|
for (const c of candidates) {
|
|
1818
1963
|
const f = file(c)
|
|
1819
1964
|
if (!f) continue
|
|
1820
1965
|
const ext = extname(f), type = FEEDS[basename(f)] || MIME[ext] || 'application/octet-stream'
|
|
1821
|
-
if (ext === '.html') {
|
|
1966
|
+
if (ext === '.html' || ext === '.brain') {
|
|
1822
1967
|
// §33: the page's Markdown twin (`/pricing` → pricing.md) when the client prefers text/markdown
|
|
1823
1968
|
if (serveTwin(req, res, url)) return true
|
|
1824
1969
|
const md = file(mdPath(p))
|
|
@@ -1830,21 +1975,39 @@ export function createServer(o = {}) {
|
|
|
1830
1975
|
...(ext === '.md' ? twinCanonical(p) : {}),
|
|
1831
1976
|
'content-type': type,
|
|
1832
1977
|
// the worker script must be revalidated on every check, or a CDN could keep an old worker for an hour (§35.7)
|
|
1833
|
-
'cache-control': p.startsWith('/_b/') ? 'public, max-age=31536000, immutable' : p === '/sw.js' ? 'no-cache' : 'public, max-age=3600',
|
|
1978
|
+
'cache-control': p.startsWith('/_b/') ? 'public, max-age=31536000, immutable' : p === '/sw.js' || /\.(m3u8?|mpd)$/i.test(p) ? 'no-cache' : 'public, max-age=3600',
|
|
1834
1979
|
vary: 'accept-encoding',
|
|
1835
1980
|
}
|
|
1836
1981
|
// the smallest precompressed sibling the client accepts (the build keeps only those smaller than the file)
|
|
1837
1982
|
let best = null
|
|
1838
|
-
for (const enc of acceptedEncodings(req.headers['accept-encoding'])) {
|
|
1983
|
+
for (const enc of req.headers.range ? [] : acceptedEncodings(req.headers['accept-encoding'])) {
|
|
1839
1984
|
const encoded = file(c + (enc === 'br' ? '.br' : enc === 'zstd' ? '.zst' : '.gz'))
|
|
1840
1985
|
const size = encoded ? statSync(encoded).size : Infinity
|
|
1841
1986
|
if (encoded && (!best || size < best.size)) best = { enc, encoded, size }
|
|
1842
1987
|
}
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1988
|
+
const selected = best ? best.encoded : f
|
|
1989
|
+
const handle = await open(selected, FS.O_RDONLY | (FS.O_NOFOLLOW || 0) | (FS.O_NONBLOCK || 0))
|
|
1990
|
+
try {
|
|
1991
|
+
const stat = await handle.stat()
|
|
1992
|
+
if (!stat.isFile()) throw new Error('Static output must be a regular file')
|
|
1993
|
+
headers.etag = await fileTag(handle, stat)
|
|
1994
|
+
if (res.destroyed) return true
|
|
1995
|
+
if (!best) headers['accept-ranges'] = 'bytes'
|
|
1996
|
+
const inm = req.headers['if-none-match']
|
|
1997
|
+
if (inm && inm.split(',').some(t => t.trim() === '*' || t.trim().replace(/^W\//, '') === headers.etag)) return send(res, 304, '', headers), true
|
|
1998
|
+
const span = req.headers.range && (!req.headers['if-range'] || req.headers['if-range'] === headers.etag) ? byteRange(req.headers.range, stat.size) : null
|
|
1999
|
+
if (span === false) return send(res, 416, '', { ...headers, 'content-range': `bytes */${stat.size}` }), true
|
|
2000
|
+
if (span) {
|
|
2001
|
+
headers['content-range'] = `bytes ${span.start}-${span.end}/${stat.size}`
|
|
2002
|
+
headers['content-length'] = String(span.end - span.start + 1)
|
|
2003
|
+
} else {
|
|
2004
|
+
headers['content-length'] = String(stat.size)
|
|
2005
|
+
if (best) headers['content-encoding'] = best.enc
|
|
2006
|
+
}
|
|
2007
|
+
await streamFile(req, res, handle, span ? 206 : 200, headers, span)
|
|
2008
|
+
} finally {
|
|
2009
|
+
await handle.close()
|
|
1846
2010
|
}
|
|
1847
|
-
send(res, 200, req.method === 'HEAD' ? '' : readFileSync(f), headers)
|
|
1848
2011
|
return true
|
|
1849
2012
|
}
|
|
1850
2013
|
return false
|
|
@@ -1936,6 +2099,11 @@ export function createServer(o = {}) {
|
|
|
1936
2099
|
let full
|
|
1937
2100
|
try { full = new URL(req.url, 'http://' + host) } catch { return send(res, 400, 'Bad Request') }
|
|
1938
2101
|
// /_health stays at the root: the supervisor calls it there whatever the base
|
|
2102
|
+
// refused API sources, for an operator on this machine only (a loopback socket and no forwarded hop)
|
|
2103
|
+
if (full.pathname === '/_access/sources' && req.method === 'GET') {
|
|
2104
|
+
if (!LOOPBACK(plainIp(req.socket.remoteAddress ?? '') || '') || req.headers['x-forwarded-for']) return notFound(req, res)
|
|
2105
|
+
return send(res, 200, json({ sources: [...refusedSources.values()].sort((a, b) => b.count - a.count) }), { 'content-type': MIME['.json'], 'cache-control': 'no-store' })
|
|
2106
|
+
}
|
|
1939
2107
|
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' })
|
|
1940
2108
|
let url = full
|
|
1941
2109
|
if (BASE !== '/') {
|
|
@@ -1950,9 +2118,20 @@ export function createServer(o = {}) {
|
|
|
1950
2118
|
const probe = brainProbe(url.pathname)
|
|
1951
2119
|
if (probe) return await answerProbe(req, res, new URL(probe, url), rid)
|
|
1952
2120
|
if (native && /^\/_brain\/files(?:\/|$)/.test(url.pathname)) return await native.fileStream(req,res,url,rid)
|
|
1953
|
-
if (native && /^\/(?:api(?:\/|$)|_brain(?:\/|$)|s\/)/.test(url.pathname))
|
|
2121
|
+
if (native && /^\/(?:api(?:\/|$)|_brain(?:\/|$)|s\/)/.test(url.pathname)) {
|
|
2122
|
+
const client = ipOf(req), refused = accessRefusal(req, url.pathname, client, access)
|
|
2123
|
+
if (refused) {
|
|
2124
|
+
accessRefused(req, rid, url.pathname, client, refused)
|
|
2125
|
+
return send(res, 403, json({ error: 'This API answers its own site only', code: 'B0752' }), { 'content-type': MIME['.json'], 'cache-control': 'no-store', 'x-brain-code': 'B0752' })
|
|
2126
|
+
}
|
|
2127
|
+
if (overBudget(req, res, url.pathname, client)) return
|
|
2128
|
+
return await native.proxy(req,res,url,rid,send)
|
|
2129
|
+
}
|
|
1954
2130
|
const declaredRoute = native && nativeRoutes.find(route => route.rx.test(url.pathname))
|
|
1955
|
-
if (declaredRoute)
|
|
2131
|
+
if (declaredRoute) {
|
|
2132
|
+
if (overBudget(req, res, url.pathname, ipOf(req))) return
|
|
2133
|
+
return await native.proxy(req,res,url,rid,send,undefined,declaredRoute)
|
|
2134
|
+
}
|
|
1956
2135
|
// redirects.json (manifest `redirects`, brain-seo server_json): exact sources first, then patterns, on the app path;
|
|
1957
2136
|
// same-site targets get the base, the query is kept when the target has none; slash policy and host in the same hop
|
|
1958
2137
|
const to = (req.method === 'GET' || req.method === 'HEAD') && canonicalOf(req, url)
|
|
@@ -1964,7 +2143,7 @@ export function createServer(o = {}) {
|
|
|
1964
2143
|
const post = req.method === 'POST'
|
|
1965
2144
|
if (!post && req.method !== 'GET' && req.method !== 'HEAD') return send(res, 405, 'Method not allowed', { allow: 'GET, HEAD, POST' })
|
|
1966
2145
|
// a status page opened directly answers its own status: `/403` is never a 200 that a crawler or a probe can trust
|
|
1967
|
-
const reserved = post ? 0 : reservedStatus(url.pathname)
|
|
2146
|
+
const reserved = post ? 0 : reservedStatus(publicStaticPath(url.pathname) ?? url.pathname)
|
|
1968
2147
|
if (reserved === 403) return forbidden(req, res, url, rid, '')
|
|
1969
2148
|
if (reserved) {
|
|
1970
2149
|
// Anyone can open it, so the note about it is bounded and is not an error.
|
|
@@ -1977,14 +2156,16 @@ export function createServer(o = {}) {
|
|
|
1977
2156
|
for (const route of routes) {
|
|
1978
2157
|
const m = route.rx.exec(url.pathname)
|
|
1979
2158
|
if (!m) continue
|
|
2159
|
+
const protectedRoute = route.guards?.length || route.middleware?.length
|
|
1980
2160
|
// a form page without load is prerendered: GET serves that file (build ETag); only its POST needs the server
|
|
1981
|
-
if (!post && route.forms && serveStatic(req, res, url)) return
|
|
2161
|
+
if (!post && !protectedRoute && route.forms && (await serveStatic(req, res, url))) return
|
|
1982
2162
|
// a parameter never hides a real file: `/[code]` must not swallow /favicon.svg, /sitemap.xml or a prerendered /login
|
|
1983
|
-
if (!post && m.groups && (await serveFixed(req, res, url))) return
|
|
1984
|
-
if (!post && serveTwin(req, res, url)) return
|
|
2163
|
+
if (!post && !protectedRoute && m.groups && (await serveFixed(req, res, url))) return
|
|
2164
|
+
if (!post && !protectedRoute && serveTwin(req, res, url)) return
|
|
1985
2165
|
const paged = post ? null : pageParameter(route, url)
|
|
1986
2166
|
if (paged === 404) return notFound(req, res)
|
|
1987
2167
|
if (paged) return send(res, 301, '', { location: withBase(paged), 'cache-control': 'public, max-age=3600' })
|
|
2168
|
+
if (overBudget(req, res, url.pathname, ipOf(req))) return
|
|
1988
2169
|
return await (post ? formPost : ssr)(req, res, route, m.groups ? { ...m.groups } : {}, url, rid)
|
|
1989
2170
|
}
|
|
1990
2171
|
if (post) return send(res, 405, 'Method not allowed', { allow: 'GET, HEAD' })
|
|
@@ -1994,7 +2175,7 @@ export function createServer(o = {}) {
|
|
|
1994
2175
|
} catch (e) {
|
|
1995
2176
|
const busy = e instanceof HttpError && e.code === 'BRIDGE_BUSY'
|
|
1996
2177
|
if (busy) log('warn', 'backend bridge busy', { rid, path: req.$brainPath || String(req.url || '').split('?')[0], fix: e.fix })
|
|
1997
|
-
else if (e instanceof HttpError &&
|
|
2178
|
+
else if (e instanceof HttpError && e.status >= 500 && BACKEND_UNAVAILABLE_CAUSES.has(e.message)) log('warn', 'backend unavailable', { rid, path: req.$brainPath || String(req.url || '').split('?')[0], status: e.status, cause: e.message, ...(CODE_SHAPE.test(e.transport || '') ? { transport: e.transport } : {}) })
|
|
1998
2179
|
else log('error', 'unhandled', { rid, path: req.url, ...trace(e) })
|
|
1999
2180
|
try {
|
|
2000
2181
|
if (busy && !res.headersSent) { res.setHeader('retry-after', String(e.retryAfter)); failPage(res, 503, rid, { code: e.code, message: 'Service Unavailable', fix: e.fix }) }
|
|
@@ -2203,6 +2384,7 @@ export async function runChain({ guards = [], middleware = [], ctx }) {
|
|
|
2203
2384
|
// key past HITS_KEY_MAX characters is stored as its digest. The map stays ordered by window start: a new window is
|
|
2204
2385
|
// re-inserted at the end, so the expired entries are always the first ones.
|
|
2205
2386
|
const HITS = new Map() // key → [windowStart, count]
|
|
2387
|
+
let servers = 0 // each createServer keeps its own dynamic budget (accessBudget) in HITS
|
|
2206
2388
|
export const HITS_MAX = 50000, HITS_KEY_MAX = 256
|
|
2207
2389
|
export const rateLimitKeys = () => HITS.size
|
|
2208
2390
|
const WINDOW = 60000
|
|
@@ -2424,6 +2606,94 @@ export function trustedProxies(list = []) {
|
|
|
2424
2606
|
* The client is the right-most forwarded hop that is not a trusted proxy, and only when the TCP peer is trusted: hops
|
|
2425
2607
|
* left of it are client-supplied and never consulted. Same walk as resolve_client (brain-backend/src/edge_http.rs).
|
|
2426
2608
|
*/
|
|
2609
|
+
/**
|
|
2610
|
+
* platform.access (docs/backend/access.md): who may reach the dynamic answers and how much each client may take.
|
|
2611
|
+
* `mode` "open" (default) lets any client in, scripts and headless browsers included; "own-host" refuses /api, /_brain
|
|
2612
|
+
* and /s to anything without evidence of this site. Nothing is limited unless the app sets `perMinute` (dynamic
|
|
2613
|
+
* requests per client and minute) or `concurrency` (dynamic requests in flight for the whole process). Normalizes `{ mode, perMinute, concurrency, origins, hosts, ips,
|
|
2614
|
+
* local }`; `site` is the site origin (PUBLIC_SITE_URL), whose host is always one of the hosts.
|
|
2615
|
+
* Same limits as the backend's parse_access (brain-backend/src/platform.rs).
|
|
2616
|
+
*/
|
|
2617
|
+
export function accessPolicy(raw = {}, site) {
|
|
2618
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) throw Error('platform.access must be an object')
|
|
2619
|
+
for (const key of Object.keys(raw)) if (!['mode', 'perMinute', 'concurrency', 'origins', 'hosts', 'ips', 'local'].includes(key)) throw Error(`platform.access.${key} is not a setting; use mode, perMinute, concurrency, origins, hosts, ips or local`)
|
|
2620
|
+
const list = (key, max) => {
|
|
2621
|
+
const value = raw[key] ?? []
|
|
2622
|
+
if (!Array.isArray(value) || value.length > max || value.some(v => typeof v !== 'string' || !v || v.length > 256)) throw Error(`platform.access.${key} must be an array of at most ${max} strings`)
|
|
2623
|
+
return value
|
|
2624
|
+
}
|
|
2625
|
+
const origins = new Set()
|
|
2626
|
+
for (const origin of list('origins', 32)) {
|
|
2627
|
+
let parsed
|
|
2628
|
+
try { parsed = new URL(origin) } catch { throw Error(`platform.access.origins entry \`${origin}\` must be an origin such as https://admin.example.com`) }
|
|
2629
|
+
if (parsed.origin !== origin || !/^https?:$/.test(parsed.protocol)) throw Error(`platform.access.origins entry \`${origin}\` must be exactly scheme://host[:port], without a path or a trailing slash`)
|
|
2630
|
+
origins.add(origin)
|
|
2631
|
+
}
|
|
2632
|
+
const hosts = new Set()
|
|
2633
|
+
for (const host of list('hosts', 32)) {
|
|
2634
|
+
if (!/^(?=.{1,253}$)[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*$/.test(host)) throw Error(`platform.access.hosts entry \`${host}\` must be a lowercase host name without scheme, port or wildcard`)
|
|
2635
|
+
hosts.add(host)
|
|
2636
|
+
}
|
|
2637
|
+
try { if (site) hosts.add(new URL(site).hostname) } catch {}
|
|
2638
|
+
const ips = list('ips', 64)
|
|
2639
|
+
const nets = ips.length ? trustedProxies(ips) : null
|
|
2640
|
+
if (raw.local !== undefined && typeof raw.local !== 'boolean') throw Error('platform.access.local must be true or false')
|
|
2641
|
+
const mode = raw.mode ?? 'open'
|
|
2642
|
+
if (mode !== 'open' && mode !== 'own-host') throw Error('platform.access.mode must be "open" or "own-host"')
|
|
2643
|
+
const count = (key, max) => {
|
|
2644
|
+
const value = raw[key] ?? null
|
|
2645
|
+
if (value !== null && (!Number.isInteger(value) || value < 1 || value > max)) throw Error(`platform.access.${key} must be a whole number from 1 to ${max}`)
|
|
2646
|
+
return value
|
|
2647
|
+
}
|
|
2648
|
+
return Object.freeze({ mode, perMinute: count('perMinute', 100000), concurrency: count('concurrency', 10000), origins, hosts, nets, local: raw.local !== false })
|
|
2649
|
+
}
|
|
2650
|
+
// Paths a third party reaches on purpose: links, the analytics script, signed inbound webhooks and Apple's
|
|
2651
|
+
// server-to-server notifications. Each carries its own check.
|
|
2652
|
+
const ACCESS_EXEMPT = /^\/(?:s\/|_brain\/analytics\.js$|_brain\/webhooks\/in\/[a-z][a-z0-9_]{0,47}$|_brain\/auth\/apple\/notifications$)/
|
|
2653
|
+
const LOOPBACK = ip => /^(?:127\.|::1$)/.test(ip)
|
|
2654
|
+
/**
|
|
2655
|
+
* Why a request to /api, /_brain or /s may not reach the backend, or null when it may. It passes with browser evidence
|
|
2656
|
+
* of this site (an Origin of this host or of access.origins, Sec-Fetch-Site same-origin, a same-host Referer), a
|
|
2657
|
+
* non-ambient credential the backend verifies (Authorization, X-Api-Key), a client address in access.ips, or a
|
|
2658
|
+
* loopback client while access.local is on. Headers can be forged: this keeps other sites' pages, foreign hosts and
|
|
2659
|
+
* plain scripts out, and the per-client rate limits bound what a forger reads.
|
|
2660
|
+
*/
|
|
2661
|
+
export function accessRefusal(req, path, client, policy) {
|
|
2662
|
+
if (!policy || policy.mode !== 'own-host' || ACCESS_EXEMPT.test(path) || req.method === 'OPTIONS') return null
|
|
2663
|
+
const h = req.headers
|
|
2664
|
+
const host = String(h['x-forwarded-host'] || h.host || '').toLowerCase()
|
|
2665
|
+
const hostname = host.replace(/:\d+$/, '').replace(/^\[|\]$/g, '')
|
|
2666
|
+
const local = policy.local && LOOPBACK(client)
|
|
2667
|
+
if (policy.hosts.size && !policy.hosts.has(hostname) && !(local && ['localhost', '127.0.0.1', '::1'].includes(hostname))) return 'host'
|
|
2668
|
+
if (local) return null
|
|
2669
|
+
if (h.authorization || h['x-api-key']) return null
|
|
2670
|
+
if (policy.nets && client !== '?' && policy.nets.check(client, isIP(client) === 4 ? 'ipv4' : 'ipv6')) return null
|
|
2671
|
+
const own = value => {
|
|
2672
|
+
try {
|
|
2673
|
+
const url = new URL(value)
|
|
2674
|
+
return url.host === host || policy.origins.has(url.origin)
|
|
2675
|
+
} catch { return false }
|
|
2676
|
+
}
|
|
2677
|
+
if (h.origin) return own(h.origin) ? null : 'origin'
|
|
2678
|
+
const site = h['sec-fetch-site']
|
|
2679
|
+
const navigation = (req.method === 'GET' || req.method === 'HEAD') && h['sec-fetch-mode'] === 'navigate'
|
|
2680
|
+
if (site === 'same-origin') return null
|
|
2681
|
+
// a person who opened the URL, or a sign-in provider sending the browser back to its callback
|
|
2682
|
+
if (navigation && (site === 'none' || path.startsWith('/_brain/auth/'))) return null
|
|
2683
|
+
if (h.referer && own(h.referer)) return null
|
|
2684
|
+
return site ? 'cross-site' : 'no-browser-evidence'
|
|
2685
|
+
}
|
|
2686
|
+
/**
|
|
2687
|
+
* Whether a dynamic request (a page render, a form post, /api, /_brain, /s) stays inside its budget: 0, or the seconds
|
|
2688
|
+
* to wait. Checked before any body is read or the backend is asked, so a refused client costs one map lookup. A
|
|
2689
|
+
* loopback client while access.local is on, an address in access.ips and the exempt callbacks are not counted.
|
|
2690
|
+
*/
|
|
2691
|
+
export function accessBudget(path, client, policy, key = 'edge ') {
|
|
2692
|
+
if (!policy || !policy.perMinute || ACCESS_EXEMPT.test(path)) return 0
|
|
2693
|
+
if (policy.local && LOOPBACK(client)) return 0
|
|
2694
|
+
if (policy.nets && client !== '?' && policy.nets.check(client, isIP(client) === 4 ? 'ipv4' : 'ipv6')) return 0
|
|
2695
|
+
return overLimit(key + client, policy.perMinute)
|
|
2696
|
+
}
|
|
2427
2697
|
export function resolveClient(peer, forwarded, nets) {
|
|
2428
2698
|
const socketPeer = plainIp(peer ?? '')
|
|
2429
2699
|
const trusted = ip => nets.check(ip, isIP(ip) === 4 ? 'ipv4' : 'ipv6')
|
|
@@ -2499,10 +2769,23 @@ export function prefersMarkdown(accept) {
|
|
|
2499
2769
|
if (html < 0) (html = any), (htmlAt = Infinity) // an explicit type beats a wildcard of the same weight
|
|
2500
2770
|
return md > 0 && (md > html || (md === html && mdAt < htmlAt))
|
|
2501
2771
|
}
|
|
2772
|
+
// Root-relative image metadata follows the app base; bare references follow the canonical document URL.
|
|
2773
|
+
export function metadataImage(root, canonical, value) {
|
|
2774
|
+
const encode = s => encodeURI(s.toWellFormed()).replaceAll('%25', '%')
|
|
2775
|
+
value = value.trim()
|
|
2776
|
+
if (!value) return ''
|
|
2777
|
+
if (/^[A-Za-z][A-Za-z0-9+.-]*:/.test(value) || !root) return encode(value)
|
|
2778
|
+
try {
|
|
2779
|
+
const home = new URL(root.endsWith('/') ? root : root + '/')
|
|
2780
|
+
const prefix = home.pathname.replace(/\/$/, '')
|
|
2781
|
+
if (/^\/(?!\/)/.test(value)) value = new URL(value === prefix || value.startsWith(prefix + '/') ? value : prefix + value, home.origin).href
|
|
2782
|
+
return new URL(value, canonical || home).href
|
|
2783
|
+
} catch { return encode(value) }
|
|
2784
|
+
}
|
|
2502
2785
|
// SEO head from View.seo(props): title, description, canonical, og:*/twitter:*, JSON-LD and the view's <head> block
|
|
2503
2786
|
// F5: the build writes an SSR route's canonical/og:url from its pattern (/posts/:slug); the request has the real path.
|
|
2504
2787
|
// A view's own <head> tags replace the generated ones with the same name/property (one tag per key, SPEC «SEO»).
|
|
2505
|
-
function head(page, seo, path, search = '') {
|
|
2788
|
+
function head(page, seo, path, search = '', route, base = '') {
|
|
2506
2789
|
let h = page
|
|
2507
2790
|
// `<meta seo query="page">`: these parameters make a different page; any other (a dialog, a tracking tag) does not
|
|
2508
2791
|
let identity = '', query = ''
|
|
@@ -2514,11 +2797,13 @@ function head(page, seo, path, search = '') {
|
|
|
2514
2797
|
})
|
|
2515
2798
|
if (path) {
|
|
2516
2799
|
// the attribute is already escaped HTML: only the request path that replaces a pattern is escaped
|
|
2517
|
-
const real = v => v.replace(/^(https?:\/\/[^/]+)?(\/[^"]*)$/, (m, o = '', p) => (/\/[:*]/.test(p) ? o + esc(path) : m)) + identity
|
|
2800
|
+
const real = v => v.replace(/^(https?:\/\/[^/]+)?(\/[^"]*)$/, (m, o = '', p) => (/\/[:*]/.test(p) ? o + esc(base + path) : m)) + identity
|
|
2518
2801
|
h = h.replace(/(<link[^>]*rel=["']canonical["'][^>]*href=")([^"]*)(")/i, (m, a, v, b) => a + real(v) + b)
|
|
2519
2802
|
h = h.replace(/(<meta[^>]*property=["']og:url["'][^>]*content=")([^"]*)(")/i, (m, a, v, b) => a + real(v) + b)
|
|
2520
2803
|
}
|
|
2521
|
-
if (!seo) return graph(h, path, query)
|
|
2804
|
+
if (!seo) return graph(h, path ? base + path : path, query)
|
|
2805
|
+
// Match prerendering: blank optional fields are absent; zero and false remain distinct values.
|
|
2806
|
+
seo = Object.fromEntries(Object.entries(seo).filter(([, v]) => v != null && !(typeof v === 'string' && !v.trim())))
|
|
2522
2807
|
const meta = (attr, key, val, escaped) => {
|
|
2523
2808
|
const tag = `<meta ${attr}="${esc(key)}" content="${escaped ? val.replace(/"/g, '"') : esc(val)}">`
|
|
2524
2809
|
const rx = new RegExp(`<meta[^>]*\\b${attr}=["']${key.replace(/[.*+?^${}()|[\]\\:]/g, '\\$&')}["'][^>]*>`, 'i')
|
|
@@ -2531,20 +2816,27 @@ function head(page, seo, path, search = '') {
|
|
|
2531
2816
|
else if (k === 'canonical') {
|
|
2532
2817
|
// a root-relative canonical takes the origin the build wrote into the template (PUBLIC_SITE_URL), as it does on
|
|
2533
2818
|
// a prerendered page: a relative canonical is ignored by search engines
|
|
2534
|
-
const
|
|
2535
|
-
const
|
|
2819
|
+
const relative = base && (v + '/').startsWith(base + '/') ? v.slice(base.length) || '/' : v
|
|
2820
|
+
const translated = linkTo(route, relative)
|
|
2821
|
+
const local = /^\/(?!\/)/.test(translated) ? base + translated : translated
|
|
2822
|
+
const built = /^\/(?!\/)/.test(local) && /<link[^>]*rel=["']canonical["'][^>]*href="(https?:\/\/[^/"]+)/i.exec(h)?.[1]
|
|
2823
|
+
const url = built ? built + local : local
|
|
2536
2824
|
const tag = `<link rel="canonical" href="${esc(url)}">`
|
|
2537
2825
|
h = /<link[^>]*rel=["']canonical["'][^>]*>/i.test(h) ? h.replace(/<link[^>]*rel=["']canonical["'][^>]*>/i, () => tag) : h.replace('</head>', () => tag + '</head>')
|
|
2538
2826
|
if (seo['og:url'] == null) meta('property', 'og:url', url) // og:url follows the canonical unless the view sets its own
|
|
2539
2827
|
} else if (k === 'jsonld') h = h.replace('</head>', () => `<script type="application/ld+json">${jsonInHtml(v)}</script></head>`)
|
|
2540
|
-
else if (/^(og|twitter|article):/.test(k)) meta('property', k, v)
|
|
2828
|
+
else if (/^(og|twitter|article):/.test(k)) meta(k.startsWith('twitter:') ? 'name' : 'property', k, v)
|
|
2541
2829
|
else if (typeof v === 'string') meta('name', k, v)
|
|
2542
2830
|
}
|
|
2543
2831
|
// What the build writes for a prerendered page and can only be known here: the social card of this request's values
|
|
2544
2832
|
// (brain-seo social::head, prerender seo_head). The view's own value always wins.
|
|
2545
2833
|
const shared = /<title>([\s\S]*?)<\/title>/i.exec(h)?.[1]
|
|
2546
2834
|
const origin = /<link[^>]*rel=["']canonical["'][^>]*href="(https?:\/\/[^/"]+)/i.exec(h)?.[1]
|
|
2547
|
-
|
|
2835
|
+
const canonical = /<link[^>]*rel=["']canonical["'][^>]*href="([^"]+)"/i.exec(h)?.[1].replaceAll('&', '&')
|
|
2836
|
+
for (const key of ['og:image', 'og:image:url', 'og:image:secure_url', 'twitter:image', 'twitter:image:src']) if (typeof seo[key] === 'string') {
|
|
2837
|
+
seo[key] = metadataImage(origin ? origin + base : null, canonical, seo[key])
|
|
2838
|
+
meta(key.startsWith('twitter:') ? 'name' : 'property', key, seo[key])
|
|
2839
|
+
}
|
|
2548
2840
|
if (seo.title != null && seo['og:title'] == null) meta('property', 'og:title', shared, true)
|
|
2549
2841
|
if (seo.description != null && seo['og:description'] == null) meta('property', 'og:description', seo.description)
|
|
2550
2842
|
if (seo['og:image'] != null) {
|
|
@@ -2560,19 +2852,21 @@ function head(page, seo, path, search = '') {
|
|
|
2560
2852
|
if (seo.title != null) node[/^(Article|BlogPosting|NewsArticle|TechArticle|Report)$/.test(T) ? 'headline' : 'name'] = String(seo.title).slice(0, 110)
|
|
2561
2853
|
if (seo.description != null) node.description = String(seo.description)
|
|
2562
2854
|
if (canon) node.url = canon[1].replace(/&/g, '&')
|
|
2563
|
-
if (seo['og:image'] != null) node.image =
|
|
2855
|
+
if (seo['og:image'] != null) node.image = seo['og:image']
|
|
2564
2856
|
if (seo.lastmod != null) node.dateModified = String(seo.lastmod)
|
|
2565
2857
|
const own = new RegExp(`<script[^>]*type=["']application/ld\\+json["'][^>]*>\\s*\\{"@context":"https://schema\\.org","@type":"${T}"[\\s\\S]*?</script>`, 'i')
|
|
2566
2858
|
const tag = `<script type="application/ld+json">${jsonInHtml(node)}</script>`
|
|
2567
2859
|
h = own.test(h) ? h.replace(own, () => tag) : h.replace('</head>', () => tag + '</head>')
|
|
2568
2860
|
}
|
|
2569
2861
|
if (seo.$head) {
|
|
2570
|
-
for (const [, attr, key] of seo.$head.matchAll(/<meta[^>]*\b(property|name)=["']([^"']+)["'][^>]*>/gi))
|
|
2571
|
-
|
|
2862
|
+
for (const [, attr, key] of seo.$head.matchAll(/<meta[^>]*\b(property|name)=["']([^"']+)["'][^>]*>/gi)) {
|
|
2863
|
+
const attributes = key.startsWith('twitter:') ? '(?:property|name)' : attr
|
|
2864
|
+
h = h.replace(new RegExp(`<meta[^>]*\\b${attributes}=["']${key.replace(/[.*+?^${}()|[\]\\:]/g, '\\$&')}["'][^>]*>`, 'gi'), '')
|
|
2865
|
+
}
|
|
2572
2866
|
if (/<link[^>]*rel=["']canonical["']/i.test(seo.$head)) h = h.replace(/<link[^>]*rel=["']canonical["'][^>]*>/gi, '')
|
|
2573
2867
|
h = h.replace('</head>', () => seo.$head + '</head>')
|
|
2574
2868
|
}
|
|
2575
|
-
return graph(h, path, query, seo)
|
|
2869
|
+
return graph(h, path ? base + path : path, query, seo)
|
|
2576
2870
|
}
|
|
2577
2871
|
// The WebSite + WebPage graph the build wrote into the template (brain-seo ServerHead::apply): its pattern URLs become
|
|
2578
2872
|
// this request's, like the canonical, and the WebPage takes this request's title and description.
|
|
@@ -2588,27 +2882,32 @@ function graph(h, path, query, seo) {
|
|
|
2588
2882
|
if ([node['@type']].flat().includes('WebPage')) {
|
|
2589
2883
|
if (title && node.name == null) node.name = title.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, '&')
|
|
2590
2884
|
if (seo?.description != null && node.description == null) node.description = String(seo.description)
|
|
2885
|
+
if (typeof seo?.['og:image'] === 'string' && seo.jsonld == null) {
|
|
2886
|
+
const image = node.primaryImageOfPage
|
|
2887
|
+
if (!image || (image['@type'] === 'ImageObject' && !image['@id'])) node.primaryImageOfPage = { '@type': 'ImageObject', ...image, url: seo['og:image'] }
|
|
2888
|
+
}
|
|
2591
2889
|
}
|
|
2592
2890
|
}
|
|
2593
2891
|
return a + jsonInHtml(g) + b
|
|
2594
2892
|
})
|
|
2595
2893
|
}
|
|
2596
|
-
//
|
|
2894
|
+
// Only compiler-owned chunks receive the own-extension module format; ordinary Node imports keep their loader.
|
|
2895
|
+
function resolveChunk(s, c, next, base, app) {
|
|
2896
|
+
if (app !== '/' && s.startsWith(app + '_b/')) s = s.slice(app.length - 1)
|
|
2897
|
+
const mapped = s.startsWith('/_b/')
|
|
2898
|
+
const url = mapped ? new URL('.' + s, base) : s.startsWith('file:') ? new URL(s) : s.startsWith('.') && c.parentURL?.startsWith('file:') ? new URL(s, c.parentURL) : null
|
|
2899
|
+
const owned = new URL('_b/', base).href
|
|
2900
|
+
if (url?.href.startsWith(owned) && url.pathname.endsWith('.jsbrain')) return { url: url.href, format: 'module', shortCircuit: true }
|
|
2901
|
+
if (mapped) return { url: url.href, shortCircuit: true }
|
|
2902
|
+
return next(s, c)
|
|
2903
|
+
}
|
|
2597
2904
|
let hooked = false
|
|
2598
2905
|
function hook(base, app = '/') {
|
|
2599
2906
|
if (hooked) return
|
|
2600
2907
|
hooked = true
|
|
2601
|
-
//
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
return s.startsWith('/_b/') && { url: new URL('.' + s, base).href, shortCircuit: true }
|
|
2605
|
-
}
|
|
2606
|
-
// registerHooks (Node 22.15+, in-thread) replaces the deprecated register (DEP0205)
|
|
2607
|
-
if (Module.registerHooks) return void Module.registerHooks({ resolve: (s, c, next) => own(s) || next(s, c) })
|
|
2608
|
-
Module.register(
|
|
2609
|
-
'data:text/javascript,' +
|
|
2610
|
-
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) }`)
|
|
2611
|
-
)
|
|
2908
|
+
// registerHooks (Node 22.15+, in-thread) and the compatibility loader share exactly the same resolver.
|
|
2909
|
+
if (Module.registerHooks) return void Module.registerHooks({ resolve: (s, c, next) => resolveChunk(s, c, next, base, app) })
|
|
2910
|
+
Module.register('data:text/javascript,' + encodeURIComponent(`${resolveChunk.toString()};export async function resolve(s,c,n){return resolveChunk(s,c,n,${json(base)},${json(app)})}`))
|
|
2612
2911
|
}
|
|
2613
2912
|
|
|
2614
2913
|
const LISTEN_WAIT_MS = 10000
|