@christianirack/brainllm-runtime 0.8.12 → 0.10.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-analytics.js +29 -5
- package/brain-consent.js +78 -17
- package/brain-google.js +113 -0
- package/brain-lazy.js +4 -1
- package/brain-links.js +4 -2
- package/brain-list.js +9 -0
- package/brain-native-vitals.js +78 -0
- package/brain-prefetch-shared.js +1 -1
- package/brain-router.js +11 -4
- package/brain-sentry.js +71 -0
- package/brain-text.js +56 -0
- package/brain-url.js +36 -3
- package/brain-vitals.js +8 -2
- package/brain.js +17 -10
- package/package.json +9 -2
- package/server/redis-cache.mjs +173 -0
- package/server/sentry.mjs +156 -0
- package/server/server.mjs +427 -139
- package/server/warmup-watchdog.mjs +9 -0
- package/server/warmup.mjs +134 -0
- package/shim/dom.js +8 -17
- package/sw/brain-sw.js +35 -2
- package/ui/dialog.brain +2 -2
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// One opt-in startup watchdog. It cannot outlive this process or target any other process.
|
|
2
|
+
import { workerData, parentPort } from 'node:worker_threads'
|
|
3
|
+
import { writeSync } from 'node:fs'
|
|
4
|
+
const remaining = Math.max(0, Number(BigInt(workerData.deadline) - process.hrtime.bigint()) / 1e6)
|
|
5
|
+
setTimeout(() => {
|
|
6
|
+
try { writeSync(2, '{"event":"startup-warmup-failed","code":"WARMUP_DEADLINE","private":true,"valuesCaptured":false}\n') } catch {}
|
|
7
|
+
process.kill(process.pid, 'SIGKILL')
|
|
8
|
+
}, remaining)
|
|
9
|
+
parentPort.postMessage('armed')
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// Opt-in anonymous read-only SSR warmup. No background work or outbound request exists when it is absent.
|
|
2
|
+
import { createServer, request } from 'node:http'
|
|
3
|
+
import { Worker } from 'node:worker_threads'
|
|
4
|
+
const MAX_BODY = 256 * 1024, MAX_TOTAL = 2 * 1024 * 1024
|
|
5
|
+
const configError = () => Object.assign(new Error('server.warmup requires up to eight exact public cached SSR paths, a valid site origin, timeoutMs from 100 to 10000, and onError continue or abort'), { code:'WARMUP_CONFIG_INVALID' })
|
|
6
|
+
export function safePath(path) {
|
|
7
|
+
if (typeof path !== 'string' || Buffer.byteLength(path) > 256 || !path.startsWith('/') || /[\\?#\s\u0000-\u0020\u007f-\u009f]/.test(path) || path.includes('//') || /%2f|%5c/i.test(path)) return false
|
|
8
|
+
let decoded
|
|
9
|
+
try { decoded = decodeURIComponent(path) } catch { return false }
|
|
10
|
+
if (/[\\?#\s\u0000-\u0020\u007f-\u009f]/.test(decoded) || decoded.includes('//') || decoded.split('/').some(part => part === '.' || part === '..')) return false
|
|
11
|
+
return new URL(path, 'http://warmup.invalid').pathname === path
|
|
12
|
+
}
|
|
13
|
+
export function warmupSettings(value, routes, base, site, redirects = {}) {
|
|
14
|
+
if (value === undefined) return null
|
|
15
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some(key => !['paths','timeoutMs','onError'].includes(key))) throw configError()
|
|
16
|
+
const paths = value.paths, timeoutMs = value.timeoutMs ?? 8000, onError = value.onError ?? 'continue'
|
|
17
|
+
if (!Array.isArray(paths) || paths.length < 1 || paths.length > 8 || new Set(paths).size !== paths.length || !Number.isInteger(timeoutMs) || timeoutMs < 100 || timeoutMs > 10000 || !['continue','abort'].includes(onError)) throw configError()
|
|
18
|
+
let origin
|
|
19
|
+
try {
|
|
20
|
+
if (typeof site !== 'string' || /[\s\u0000-\u0020\u007f-\u009f]/.test(site)) throw configError()
|
|
21
|
+
origin = new URL(site)
|
|
22
|
+
if (!['http:','https:'].includes(origin.protocol) || origin.username || origin.password || origin.search || origin.hash || origin.pathname !== '/' || !origin.hostname || origin.hostname.length > 253 || origin.host.length > 260 || /(?:^|\.)(?:example\.(?:com|org|net)|example|invalid|test)$/.test(origin.hostname)) throw configError()
|
|
23
|
+
} catch { throw configError() }
|
|
24
|
+
for (const path of paths) {
|
|
25
|
+
if (!safePath(path) || base !== '/' && !path.startsWith(base)) throw configError()
|
|
26
|
+
const local = base === '/' ? path : '/' + path.slice(base.length)
|
|
27
|
+
if (/^\/(?:api|_brain|_r|_server|s|_health|health)(?:\/|$)/.test(local)) throw configError()
|
|
28
|
+
const route = routes.find(route => {
|
|
29
|
+
const canonical = redirects.slash === 'always' && route.path !== '/' && !route.path.endsWith('/') && !/\.[^/]*$/.test(route.path) ? route.path + '/' : route.path
|
|
30
|
+
return canonical === local
|
|
31
|
+
})
|
|
32
|
+
if (typeof route?.server !== 'string' || !route.server || !Number.isSafeInteger(route.cache) || route.cache <= 0 || /[:*\[\]]/.test(route.path) || route.forms || route.guards?.length || route.middleware?.length) throw configError()
|
|
33
|
+
}
|
|
34
|
+
return Object.freeze({paths:Object.freeze([...paths]), timeoutMs, onError, host:origin.host})
|
|
35
|
+
}
|
|
36
|
+
const monotonic = () => process.hrtime.bigint()
|
|
37
|
+
const delay = ms => new Promise(resolve => setTimeout(resolve, ms))
|
|
38
|
+
|
|
39
|
+
// Called only by an explicitly warming production instance, before it binds its public listener.
|
|
40
|
+
export async function runWarmup({ server, settings, warm, prepare, log, started = monotonic() }) {
|
|
41
|
+
const deadline = started + BigInt(settings.timeoutMs) * 1000000n
|
|
42
|
+
const reserve = Math.min(1000, Math.max(25, Math.floor(settings.timeoutMs / 8)))
|
|
43
|
+
const requestDeadline = deadline - BigInt(reserve) * 1000000n
|
|
44
|
+
const state = { pending:new Set(), controllers:new Set(), deadline:requestDeadline }
|
|
45
|
+
const watchdog = new Worker(new URL('./warmup-watchdog.mjs', import.meta.url), {workerData:{deadline:String(deadline)},env:{},execArgv:[]})
|
|
46
|
+
const owned = new Set(), peers = new Map(), sockets = new Set(), handlers = new Set()
|
|
47
|
+
let total = 0, failed = 0, stopped = false, timer, privateServer, modules
|
|
48
|
+
const abort = () => {
|
|
49
|
+
stopped = true
|
|
50
|
+
for (const controller of state.controllers) controller.abort()
|
|
51
|
+
for (const outgoing of owned) outgoing.destroy()
|
|
52
|
+
for (const socket of sockets) socket.destroy()
|
|
53
|
+
}
|
|
54
|
+
const track = (promise, set) => {
|
|
55
|
+
set.add(promise)
|
|
56
|
+
promise.then(() => set.delete(promise), () => set.delete(promise))
|
|
57
|
+
return promise
|
|
58
|
+
}
|
|
59
|
+
const secondsLeft = until => Math.max(0, Number(until - monotonic()) / 1e6)
|
|
60
|
+
let failure
|
|
61
|
+
try {
|
|
62
|
+
await new Promise((resolve, reject) => { watchdog.once('message', message => message === 'armed' ? resolve() : reject(configError())); watchdog.once('error', reject); watchdog.once('exit', () => reject(configError())) })
|
|
63
|
+
timer = setTimeout(abort, secondsLeft(requestDeadline))
|
|
64
|
+
const controller = new AbortController()
|
|
65
|
+
state.controllers.add(controller)
|
|
66
|
+
modules = await track(Promise.resolve().then(() => warm(controller.signal)), state.pending)
|
|
67
|
+
if (stopped) throw Object.assign(new Error('Startup warmup deadline'), {code:'WARMUP_TIMEOUT'})
|
|
68
|
+
const dispatch = server.listeners('request')[0]
|
|
69
|
+
privateServer = createServer((incoming, response) => {
|
|
70
|
+
const peer = peers.get(incoming.socket.remotePort)
|
|
71
|
+
if (!peer || peer.path !== incoming.url || incoming.method !== 'GET' || incoming.headers.authorization || incoming.headers.cookie || handlers.size >= 2) { response.writeHead(403,{connection:'close'}); response.end(); return }
|
|
72
|
+
prepare(incoming, peer.controller, state)
|
|
73
|
+
const handling = Promise.resolve().then(() => dispatch.call(server, incoming, response))
|
|
74
|
+
track(handling, handlers).catch(() => { if (!response.headersSent) response.writeHead(500); response.end() })
|
|
75
|
+
})
|
|
76
|
+
privateServer.on('connection', socket => { sockets.add(socket); socket.once('close', () => sockets.delete(socket)) })
|
|
77
|
+
privateServer.maxConnections = 2
|
|
78
|
+
privateServer.maxRequestsPerSocket = 1
|
|
79
|
+
privateServer.maxHeadersCount = 16
|
|
80
|
+
privateServer.headersTimeout = settings.timeoutMs
|
|
81
|
+
privateServer.requestTimeout = settings.timeoutMs
|
|
82
|
+
await new Promise((resolve, reject) => { privateServer.once('error', reject); privateServer.listen(0, '127.0.0.1', resolve) })
|
|
83
|
+
const port = privateServer.address().port
|
|
84
|
+
const one = path => new Promise(resolve => {
|
|
85
|
+
if (stopped) return resolve()
|
|
86
|
+
const controller = new AbortController()
|
|
87
|
+
state.controllers.add(controller)
|
|
88
|
+
let outgoing, settled = false, localPort
|
|
89
|
+
const finish = ok => {
|
|
90
|
+
if (settled) return
|
|
91
|
+
settled = true
|
|
92
|
+
if (!ok) { failed++; controller.abort() }
|
|
93
|
+
owned.delete(outgoing); peers.delete(localPort); state.controllers.delete(controller)
|
|
94
|
+
resolve()
|
|
95
|
+
}
|
|
96
|
+
outgoing = request({host:'127.0.0.1',port,path,method:'GET',agent:false,headers:{host:settings.host,accept:'text/html','accept-encoding':'identity',connection:'close'}}, response => {
|
|
97
|
+
let bytes = 0
|
|
98
|
+
const rejected = response.statusCode !== 200 || response.headers['set-cookie'] || /(?:private|no-store|no-cache)/i.test(response.headers['cache-control'] || '') || response.headers['content-encoding'] || Number(response.headers['content-length']) > MAX_BODY
|
|
99
|
+
if (rejected) { response.destroy(); outgoing.destroy(); return finish(false) }
|
|
100
|
+
response.on('data', chunk => {
|
|
101
|
+
bytes += chunk.length; total += chunk.length
|
|
102
|
+
if (bytes > MAX_BODY || total > MAX_TOTAL) { response.destroy(); outgoing.destroy(); finish(false) }
|
|
103
|
+
})
|
|
104
|
+
response.once('end', () => finish(true))
|
|
105
|
+
response.once('error', () => finish(false))
|
|
106
|
+
response.once('aborted', () => finish(false))
|
|
107
|
+
})
|
|
108
|
+
owned.add(outgoing)
|
|
109
|
+
outgoing.once('socket', socket => socket.once('connect', () => { localPort = socket.localPort; peers.set(localPort, {path,controller}) }))
|
|
110
|
+
outgoing.once('error', () => finish(false))
|
|
111
|
+
outgoing.end()
|
|
112
|
+
})
|
|
113
|
+
let next = 0
|
|
114
|
+
await Promise.all(Array.from({length:Math.min(2,settings.paths.length)}, async () => { while (!stopped && next < settings.paths.length) await one(settings.paths[next++]) }))
|
|
115
|
+
if (stopped) throw Object.assign(new Error('Startup warmup deadline'), {code:'WARMUP_TIMEOUT'})
|
|
116
|
+
if (failed && settings.onError === 'abort') throw Object.assign(new Error('Required startup warmup failed'), {code:'WARMUP_FAILED'})
|
|
117
|
+
} catch (error) { failure = error }
|
|
118
|
+
finally {
|
|
119
|
+
abort()
|
|
120
|
+
if (privateServer) {
|
|
121
|
+
privateServer.closeAllConnections()
|
|
122
|
+
await new Promise(resolve => { if (!privateServer.listening) return resolve(); privateServer.close(resolve) })
|
|
123
|
+
}
|
|
124
|
+
// A load/import that ignores cancellation is not allowed to run behind a ready process.
|
|
125
|
+
// The independent watchdog terminates this owned instance if cleanup cannot finish within the absolute budget.
|
|
126
|
+
while (owned.size || sockets.size || handlers.size || state.pending.size) await delay(1)
|
|
127
|
+
clearTimeout(timer)
|
|
128
|
+
await watchdog.terminate()
|
|
129
|
+
}
|
|
130
|
+
if (monotonic() >= deadline) throw Object.assign(new Error('Startup warmup cleanup exceeded its absolute deadline'),{code:'WARMUP_DEADLINE'})
|
|
131
|
+
log?.(failed || failure ? 'warn' : 'info', 'startup data warmup', {event:'startup-data-warmup',requested:settings.paths.length,failed,bytes:total,code:failure?.code || (failed ? 'WARMUP_PARTIAL' : 'WARMUP_READY'),private:true,valuesCaptured:false})
|
|
132
|
+
if (failure && settings.onError === 'abort') throw failure
|
|
133
|
+
return {...modules,dataPaths:settings.paths.length,dataFailed:failed}
|
|
134
|
+
}
|
package/shim/dom.js
CHANGED
|
@@ -6,28 +6,19 @@ const VOID = new Set(['area', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link'
|
|
|
6
6
|
const esc = s => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
7
7
|
const unesc = s => s.replace(/&(amp|lt|gt|quot|#39);/g, (_, e) => ({ amp: '&', lt: '<', gt: '>', quot: '"', '#39': "'" })[e])
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
// Keep Node's native event brand: SSR and its test DOM share a process with fetch/WebSocket EventTargets.
|
|
10
|
+
const NativeEvent = globalThis.Event
|
|
11
|
+
export class Event extends NativeEvent {
|
|
10
12
|
constructor(type, init = {}) {
|
|
11
|
-
|
|
12
|
-
this.bubbles = !!init.bubbles
|
|
13
|
-
this.cancelable = !!init.cancelable
|
|
14
|
-
this.target = null
|
|
15
|
-
this.currentTarget = null
|
|
16
|
-
this.cancelBubble = false
|
|
17
|
-
this.defaultPrevented = false
|
|
13
|
+
super(type, init)
|
|
18
14
|
this._p = []
|
|
19
15
|
}
|
|
20
16
|
composedPath() {
|
|
21
|
-
return this._p
|
|
17
|
+
return this._p.length ? this._p : super.composedPath()
|
|
22
18
|
}
|
|
23
19
|
stopImmediatePropagation() {
|
|
24
|
-
this.
|
|
25
|
-
|
|
26
|
-
stopPropagation() {
|
|
27
|
-
this.cancelBubble = true
|
|
28
|
-
}
|
|
29
|
-
preventDefault() {
|
|
30
|
-
this.defaultPrevented = true
|
|
20
|
+
this._stop = true
|
|
21
|
+
super.stopImmediatePropagation()
|
|
31
22
|
}
|
|
32
23
|
}
|
|
33
24
|
export class MouseEvent extends Event {}
|
|
@@ -162,7 +153,7 @@ class Node {
|
|
|
162
153
|
if (i >= 0) a.splice(i, 1)
|
|
163
154
|
}
|
|
164
155
|
dispatchEvent(e) {
|
|
165
|
-
e
|
|
156
|
+
Object.defineProperty(e, 'target', { configurable: true, writable: true, value: this })
|
|
166
157
|
const path = []
|
|
167
158
|
for (let n = this; n; n = n.parentNode || n.host) path.push(n)
|
|
168
159
|
if (path[path.length - 1].nodeType === 9 && path[path.length - 1].defaultView) path.push(path[path.length - 1].defaultView)
|
package/sw/brain-sw.js
CHANGED
|
@@ -14,9 +14,16 @@
|
|
|
14
14
|
// cache that survives builds, bounded by entries and age; personal responses are never stored. `cache.update:
|
|
15
15
|
// "prompt"` keeps a new worker waiting (while an older one controls tabs) until a page posts `brain:skip-waiting`.
|
|
16
16
|
const B = self.BRAIN_BUILD || { hash: 'dev', precache: [], offline: null }
|
|
17
|
+
const IMMUTABLE = Array.isArray(B.immutable) ? new Set(B.immutable) : null
|
|
18
|
+
const isHashed = path => IMMUTABLE ? IMMUTABLE.has(path) : path.startsWith(BASE + '_b/')
|
|
17
19
|
const BASE = B.base || '/' // app served under a base path (§23 #148); precache/offline URLs already carry it
|
|
18
20
|
const NS = 'brain' + BASE.replace(/\/$/, '') // `brain` at the root, `brain/app` under /app/
|
|
19
21
|
const BUILD = NS + '-' + B.hash, META = NS + ':meta'
|
|
22
|
+
let priorImmutable
|
|
23
|
+
const previousAssets = () => priorImmutable ||= caches.open(META).then(cache => cache.match('/__brain_immutable')).then(async response => {
|
|
24
|
+
const manifests = response ? await response.json() : {}
|
|
25
|
+
return new Set(Object.values(manifests).filter(Array.isArray).flat().filter(path => typeof path === 'string'))
|
|
26
|
+
}).catch(() => new Set())
|
|
20
27
|
const NAV_TIMEOUT = 3000
|
|
21
28
|
// precache downloads at once: hundreds in one burst tripped per-IP proxy rate limits (429) and competed with the page
|
|
22
29
|
const PRECACHE_PARALLEL = 4
|
|
@@ -40,7 +47,7 @@ self.addEventListener('install', e => {
|
|
|
40
47
|
// update downloads only what changed
|
|
41
48
|
const olds = (await caches.keys()).filter(k => k.startsWith(NS + '-') && k !== BUILD)
|
|
42
49
|
const kept = async u => {
|
|
43
|
-
if (!u
|
|
50
|
+
if (!isHashed(u)) return null
|
|
44
51
|
for (const k of olds) {
|
|
45
52
|
const r = await (await caches.open(k)).match(u)
|
|
46
53
|
if (r) return r
|
|
@@ -74,6 +81,24 @@ self.addEventListener('activate', e => {
|
|
|
74
81
|
const gens = r ? await r.json() : []
|
|
75
82
|
const keep = [...gens.filter(g => g !== BUILD).slice(-1), BUILD]
|
|
76
83
|
await meta.put('/__brain_generations', new Response(JSON.stringify(keep)))
|
|
84
|
+
// Keep exact custom asset identities for the same two generations as their bytes, including old open tabs.
|
|
85
|
+
const stored = await meta.match('/__brain_immutable')
|
|
86
|
+
if (IMMUTABLE || stored) {
|
|
87
|
+
const decoded = stored ? await stored.json().catch(() => ({})) : {}
|
|
88
|
+
const old = decoded && typeof decoded === 'object' ? decoded : {}
|
|
89
|
+
const manifests = {}
|
|
90
|
+
for (const generation of keep) {
|
|
91
|
+
if (generation === BUILD) manifests[generation] = [...(IMMUTABLE || [])]
|
|
92
|
+
else if (Array.isArray(old[generation])) manifests[generation] = old[generation]
|
|
93
|
+
else if (generation.startsWith(NS + '-')) {
|
|
94
|
+
// A pre-inventory worker treated only its historical namespace as immutable; infer no other cached URL.
|
|
95
|
+
const entries = await (await caches.open(generation)).keys()
|
|
96
|
+
manifests[generation] = entries.map(entry => new URL(entry.url, self.location.origin)).filter(url => url.origin === self.location.origin && url.pathname.startsWith(BASE + '_b/')).map(url => url.pathname)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
await meta.put('/__brain_immutable', new Response(JSON.stringify(manifests)))
|
|
100
|
+
priorImmutable = Promise.resolve(new Set(Object.values(manifests).flat()))
|
|
101
|
+
}
|
|
77
102
|
for (const k of await caches.keys())
|
|
78
103
|
if ((k.startsWith(NS + '-') && !keep.includes(k)) || (k.startsWith(NS + ':route:') && !ROUTES.some(r => r.cache === k))) await caches.delete(k)
|
|
79
104
|
await self.clients.claim()
|
|
@@ -91,10 +116,18 @@ self.addEventListener('fetch', e => {
|
|
|
91
116
|
if (req.method !== 'GET') return
|
|
92
117
|
const url = new URL(req.url)
|
|
93
118
|
if (url.origin !== self.location.origin || url.pathname.startsWith(BASE + '_r/') || url.pathname === BASE + 'sw.js') return
|
|
119
|
+
// A cached whole response cannot satisfy a byte range; live manifests must never lag one revalidation behind.
|
|
120
|
+
if (req.headers?.has?.('range') || /\.(m3u8?|mpd)$/i.test(url.pathname)) return
|
|
94
121
|
// offline: false (B.offline null) → navigations go straight to the network: no page cache, no offline page
|
|
95
122
|
if (req.mode === 'navigate') return B.offline ? e.respondWith(navigate(e)) : undefined
|
|
96
|
-
if (url.pathname
|
|
123
|
+
if (isHashed(url.pathname)) return e.respondWith(hashed(e))
|
|
97
124
|
const rt = route(url.pathname)
|
|
125
|
+
// A custom layout can change between builds; only exact retained inventory entries use old immutable bytes.
|
|
126
|
+
if (IMMUTABLE) return e.respondWith((async () => {
|
|
127
|
+
if ((await previousAssets()).has(url.pathname)) return hashed(e)
|
|
128
|
+
if (rt) return rt.strategy === 'network-only' ? fetch(req) : data(e, rt)
|
|
129
|
+
return req.destination ? revalidate(e) : fetch(req)
|
|
130
|
+
})())
|
|
98
131
|
if (rt) return e.respondWith(rt.strategy === 'network-only' ? fetch(req) : data(e, rt))
|
|
99
132
|
// only files the page itself loads (img, style, script, font, manifest…); fetch()/XHR data is never touched (§35:
|
|
100
133
|
// data is not cached by default)
|
package/ui/dialog.brain
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
<meta props="title:str closeLabel:str='Close' urlKey:str=''" emits="close" expose="open close">
|
|
1
|
+
<meta props="title:str closeLabel:str='Close' urlKey:str='' dismissible:bool=false" emits="close" expose="open close">
|
|
2
2
|
<script>
|
|
3
3
|
let dlg = ref()
|
|
4
4
|
// native modal <dialog>: focus moves inside, the rest of the page is inert, Escape closes
|
|
5
5
|
const open = () => { if (!dlg.open) dlg.showModal() }
|
|
6
6
|
const close = () => { if (dlg.open) dlg.close() }
|
|
7
|
-
fx(() => { if (dlg) return urlDialog(dlg, urlKey) })
|
|
7
|
+
fx(() => { if (dlg) return urlDialog(dlg, urlKey, { dismissible }) })
|
|
8
8
|
</script>
|
|
9
9
|
<dialog bind:ref={dlg} aria-labelledby="dlg-title" on:close={emit('close')}>
|
|
10
10
|
<header>
|