@christianirack/brainllm-runtime 0.4.0 → 0.6.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-ai.js +15 -7
- package/brain-analytics.js +401 -0
- package/brain-budget.js +46 -25
- package/brain-ch.js +35 -8
- package/brain-data.js +4 -1
- package/brain-env.js +5 -4
- package/brain-guard.js +6 -6
- package/brain-live.js +367 -0
- package/brain-mcp.js +87 -18
- package/brain-outbound.js +244 -0
- package/brain-router.js +8 -12
- package/brain-update.js +37 -0
- package/brain-url.js +7 -5
- package/brain.js +6 -3
- package/package.json +1 -1
- package/server/native-sockets.mjs +156 -0
- package/server/server.mjs +319 -85
- package/sw/brain-sw.js +51 -2
- package/ui/error-state.brain +26 -13
- package/ui/tabs.brain +4 -3
package/sw/brain-sw.js
CHANGED
|
@@ -7,12 +7,21 @@
|
|
|
7
7
|
// open tab still asks for: navigations are answered from the current build alone, so nothing old is shown by mistake.
|
|
8
8
|
// After taking over it tells every tab `brain:build <sig>` (the router then does full loads). Caches that are not per
|
|
9
9
|
// build are named `brain:*`. (`sw: false` is a separate kill worker emitted by the build.)
|
|
10
|
+
// `cache.routes` (brain.json) adds declarative data routes: { match, prefix, strategy: cache-first | network-first |
|
|
11
|
+
// stale-while-revalidate | network-only, maxEntries, maxAge (s), timeout (ms) }, each in its own `brain:route:<match>`
|
|
12
|
+
// cache that survives builds, bounded by entries and age; personal responses are never stored. `cache.update:
|
|
13
|
+
// "prompt"` keeps a new worker waiting (while an older one controls tabs) until a page posts `brain:skip-waiting`.
|
|
10
14
|
const B = self.BRAIN_BUILD || { hash: 'dev', precache: [], offline: null }
|
|
11
15
|
const BASE = B.base || '/' // app served under a base path (§23 #148); precache/offline URLs already carry it
|
|
12
16
|
const BUILD = 'brain-' + B.hash, META = 'brain:meta'
|
|
13
17
|
const NAV_TIMEOUT = 3000
|
|
14
18
|
// responses marked personal or uncacheable are never stored
|
|
15
19
|
const storable = r => r.ok && !/no-store|private/i.test(r.headers.get('cache-control') || '')
|
|
20
|
+
const ROUTES = (B.routes || []).map(r => ({ ...r, cache: 'brain:route:' + r.match + (r.prefix ? '*' : '') }))
|
|
21
|
+
const route = p => ROUTES.find(r => (r.prefix ? p.startsWith(r.match) : p === r.match))
|
|
22
|
+
// identity-bound: an Authorization header, a response that varies by cookie/authorization/*, or one that sets a cookie
|
|
23
|
+
const personal = (req, r) =>
|
|
24
|
+
!!req.headers?.has?.('authorization') || /(^|,)\s*(\*|cookie|authorization)\s*(,|$)/i.test(r.headers.get('vary') || '') || r.headers.has('set-cookie')
|
|
16
25
|
const OFFLINE =
|
|
17
26
|
'<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">' +
|
|
18
27
|
'<title>Offline</title><main style="font:16px/1.5 system-ui;max-width:32rem;margin:15vh auto;padding:0 16px">' +
|
|
@@ -25,7 +34,8 @@ self.addEventListener('install', e => {
|
|
|
25
34
|
// one failing URL must not abort the install: cache what answers, the rest is cached on first use
|
|
26
35
|
// default cache mode: the page just filled the HTTP cache with these immutable files, so this costs no bytes
|
|
27
36
|
await Promise.allSettled(B.precache.map(u => fetch(u).then(r => r.ok && c.put(u, r))))
|
|
28
|
-
|
|
37
|
+
// update: 'prompt' → an update waits for the page (appUpdate); the first install has nothing to replace
|
|
38
|
+
if (B.update !== 'prompt' || !self.registration.active) await self.skipWaiting()
|
|
29
39
|
})()
|
|
30
40
|
)
|
|
31
41
|
})
|
|
@@ -40,13 +50,18 @@ self.addEventListener('activate', e => {
|
|
|
40
50
|
const gens = r ? await r.json() : []
|
|
41
51
|
const keep = [...gens.filter(g => g !== BUILD).slice(-1), BUILD]
|
|
42
52
|
await meta.put('/__brain_generations', new Response(JSON.stringify(keep)))
|
|
43
|
-
for (const k of await caches.keys())
|
|
53
|
+
for (const k of await caches.keys())
|
|
54
|
+
if ((k.startsWith('brain-') && !keep.includes(k)) || (k.startsWith('brain:route:') && !ROUTES.some(r => r.cache === k))) await caches.delete(k)
|
|
44
55
|
await self.clients.claim()
|
|
45
56
|
for (const c of await self.clients.matchAll({ type: 'window', includeUncontrolled: true })) c.postMessage('brain:build ' + B.hash)
|
|
46
57
|
})()
|
|
47
58
|
)
|
|
48
59
|
})
|
|
49
60
|
|
|
61
|
+
self.addEventListener('message', e => {
|
|
62
|
+
if (e.data === 'brain:skip-waiting') self.skipWaiting()
|
|
63
|
+
})
|
|
64
|
+
|
|
50
65
|
self.addEventListener('fetch', e => {
|
|
51
66
|
const req = e.request
|
|
52
67
|
if (req.method !== 'GET') return
|
|
@@ -55,6 +70,8 @@ self.addEventListener('fetch', e => {
|
|
|
55
70
|
// offline: false (B.offline null) → navigations go straight to the network: no page cache, no offline page
|
|
56
71
|
if (req.mode === 'navigate') return B.offline ? e.respondWith(navigate(e)) : undefined
|
|
57
72
|
if (url.pathname.startsWith(BASE + '_b/')) return e.respondWith(hashed(req))
|
|
73
|
+
const rt = route(url.pathname)
|
|
74
|
+
if (rt) return e.respondWith(rt.strategy === 'network-only' ? fetch(req) : data(e, rt))
|
|
58
75
|
// only files the page itself loads (img, style, script, font, manifest…); fetch()/XHR data is never touched (§35:
|
|
59
76
|
// data is not cached by default)
|
|
60
77
|
if (req.destination) e.respondWith(revalidate(e))
|
|
@@ -101,3 +118,35 @@ async function revalidate(e) {
|
|
|
101
118
|
e.waitUntil(net.catch(() => {}))
|
|
102
119
|
return hit || net
|
|
103
120
|
}
|
|
121
|
+
|
|
122
|
+
// declared data route: entries carry their storage time (x-brain-at); older than maxAge are never served
|
|
123
|
+
async function data(e, rt) {
|
|
124
|
+
const req = e.request, c = await caches.open(rt.cache)
|
|
125
|
+
const fresh = async () => {
|
|
126
|
+
const h = await c.match(req)
|
|
127
|
+
return h && (!rt.maxAge || Date.now() - Number(h.headers.get('x-brain-at') || 0) <= rt.maxAge * 1000) ? h : null
|
|
128
|
+
}
|
|
129
|
+
const save = async r => {
|
|
130
|
+
if (!storable(r) || r.status !== 200 || personal(req, r)) return
|
|
131
|
+
const h = new Headers(r.headers)
|
|
132
|
+
h.set('x-brain-at', String(Date.now()))
|
|
133
|
+
await c.put(req, new Response(await r.blob(), { status: 200, statusText: r.statusText, headers: h }))
|
|
134
|
+
const keys = await c.keys() // insertion order: the oldest first
|
|
135
|
+
for (const k of keys.slice(0, Math.max(0, keys.length - rt.maxEntries))) await c.delete(k)
|
|
136
|
+
}
|
|
137
|
+
const net = () => {
|
|
138
|
+
const p = fetch(req).then(r => (e.waitUntil(save(r.clone()).catch(() => {})), r))
|
|
139
|
+
e.waitUntil(p.catch(() => {}))
|
|
140
|
+
return p
|
|
141
|
+
}
|
|
142
|
+
if (rt.strategy === 'cache-first') return (await fresh()) || net()
|
|
143
|
+
if (rt.strategy === 'network-first') {
|
|
144
|
+
const n = net()
|
|
145
|
+
const timeout = new Promise(ok => setTimeout(() => ok(null), rt.timeout || NAV_TIMEOUT))
|
|
146
|
+
const first = await Promise.race([n, timeout]).catch(() => null)
|
|
147
|
+
return first || (await fresh()) || n
|
|
148
|
+
}
|
|
149
|
+
const hit = await fresh() // stale-while-revalidate
|
|
150
|
+
const n = net()
|
|
151
|
+
return hit || n
|
|
152
|
+
}
|
package/ui/error-state.brain
CHANGED
|
@@ -3,18 +3,31 @@
|
|
|
3
3
|
// One message and one action per error kind (INTERNALS §40.2), for {:catch e} and failed resources. Offline and
|
|
4
4
|
// network errors retry by themselves when the connection comes back; 429 retries after its Retry-After. A cancelled
|
|
5
5
|
// request (kind 'aborted') shows nothing.
|
|
6
|
+
// Texts (§37): the app's dictionary wins when it has the key (`"error_state.offline": "…"` in i18n/<locale>.json),
|
|
7
|
+
// else the English default. The table is read directly (globalThis.__brainDict, filled by brain-i18n.js `add`), like
|
|
8
|
+
// the form.* messages, so a single-language page does not load brain-i18n.js.
|
|
9
|
+
// Keys: error_state.offline network timeout schema unauthorized forbidden not_found invalid rate_limited ({n}) server
|
|
10
|
+
// sign_in retry reference ({id}).
|
|
11
|
+
const say = (key, en, vars) => {
|
|
12
|
+
const D = globalThis.__brainDict, G = globalThis, h = G.document && G.document.documentElement
|
|
13
|
+
const loc = globalThis.__brainLocale || (h && h.getAttribute('lang')) || 'en'
|
|
14
|
+
const def = (h && h.getAttribute('data-default-lang')) || 'en'
|
|
15
|
+
const m = D && ((D[loc] && D[loc]['error_state.' + key]) ?? (D[def] && D[def]['error_state.' + key]))
|
|
16
|
+
const s = typeof m === 'string' ? m : en
|
|
17
|
+
return vars ? s.replace(/\{(\w+)\}/g, (x, k) => (k in vars ? String(vars[k]) : x)) : s
|
|
18
|
+
}
|
|
6
19
|
const text = (e) => {
|
|
7
20
|
if (!e) return ''
|
|
8
|
-
if (e.kind === 'offline') return 'You are offline.'
|
|
9
|
-
if (e.kind === 'network') return 'The connection was lost.'
|
|
10
|
-
if (e.kind === 'timeout') return 'The server took too long to answer.'
|
|
11
|
-
if (e.kind === 'schema') return 'The server sent something unexpected.'
|
|
12
|
-
if (e.status === 401) return 'Sign in to continue.'
|
|
13
|
-
if (e.status === 403) return 'You do not have permission to see this.'
|
|
14
|
-
if (e.status === 404) return 'Not found.'
|
|
15
|
-
if (e.status === 422) return 'Check the highlighted fields.'
|
|
16
|
-
if (e.status === 429) return 'Too many requests. Trying again in '
|
|
17
|
-
return 'Something went wrong on our side.'
|
|
21
|
+
if (e.kind === 'offline') return say('offline', 'You are offline.')
|
|
22
|
+
if (e.kind === 'network') return say('network', 'The connection was lost.')
|
|
23
|
+
if (e.kind === 'timeout') return say('timeout', 'The server took too long to answer.')
|
|
24
|
+
if (e.kind === 'schema') return say('schema', 'The server sent something unexpected.')
|
|
25
|
+
if (e.status === 401) return say('unauthorized', 'Sign in to continue.')
|
|
26
|
+
if (e.status === 403) return say('forbidden', 'You do not have permission to see this.')
|
|
27
|
+
if (e.status === 404) return say('not_found', 'Not found.')
|
|
28
|
+
if (e.status === 422) return say('invalid', 'Check the highlighted fields.')
|
|
29
|
+
if (e.status === 429) return say('rate_limited', 'Too many requests. Trying again in {n} seconds.', { n: e.retryAfter || 30 })
|
|
30
|
+
return say('server', 'Something went wrong on our side.')
|
|
18
31
|
}
|
|
19
32
|
const canRetry = (e) => !!retry && !!e && (e.kind !== 'http' || e.status >= 500)
|
|
20
33
|
const signIn = () => login + (globalThis.location ? '?next=' + encodeURIComponent(location.pathname + location.search) : '')
|
|
@@ -33,9 +46,9 @@
|
|
|
33
46
|
{#if error && error.kind !== 'aborted'}
|
|
34
47
|
<div class="error-state" role="alert">
|
|
35
48
|
<p>{text(error)}</p>
|
|
36
|
-
{#if error && error.status === 401}<a href={signIn()}>Sign in</a>{/if}
|
|
37
|
-
{#if canRetry(error)}<button type="button" on:click={retry()}>Retry</button>{/if}
|
|
38
|
-
{#if error && error.id && error.status >= 500}<small>Reference: {error.id}</small>{/if}
|
|
49
|
+
{#if error && error.status === 401}<a href={signIn()}>{say('sign_in', 'Sign in')}</a>{/if}
|
|
50
|
+
{#if canRetry(error)}<button type="button" on:click={retry()}>{say('retry', 'Retry')}</button>{/if}
|
|
51
|
+
{#if error && error.id && error.status >= 500}<small>{say('reference', 'Reference: {id}', { id: error.id })}</small>{/if}
|
|
39
52
|
</div>
|
|
40
53
|
{/if}
|
|
41
54
|
<style>
|
package/ui/tabs.brain
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
<meta props="tabs:list value:str~ label:str">
|
|
2
2
|
<script>
|
|
3
|
-
// tabs: [{ id: 'a', label: 'Overview' }] · the parent renders the panel of `value` in the slot
|
|
3
|
+
// tabs: [{ id: 'a', label: 'Overview' }] · the parent renders the panel of `value` in the slot; an unknown `value`
|
|
4
|
+
// selects the first tab
|
|
4
5
|
let list = ref()
|
|
5
6
|
const current = drv(() => tabs.find((t) => t.id === value) || tabs[0])
|
|
6
7
|
const keys = (e) => {
|
|
7
|
-
const i = tabs.
|
|
8
|
+
const i = tabs.indexOf(current)
|
|
8
9
|
let j = -1
|
|
9
10
|
if (e.key === 'ArrowRight') j = (i + 1) % tabs.length
|
|
10
11
|
else if (e.key === 'ArrowLeft') j = (i - 1 + tabs.length) % tabs.length
|
|
@@ -19,7 +20,7 @@
|
|
|
19
20
|
<div class="tabs">
|
|
20
21
|
<div class="list" role="tablist" aria-label={label} bind:ref={list} on:keydown={keys}>
|
|
21
22
|
{#each tabs as t (t.id)}
|
|
22
|
-
<button type="button" role="tab" aria-selected={t
|
|
23
|
+
<button type="button" role="tab" aria-selected={t === current ? 'true' : 'false'} tabindex={t === current ? 0 : -1} on:click={value = t.id}>{t.label}</button>
|
|
23
24
|
{/each}
|
|
24
25
|
</div>
|
|
25
26
|
<div class="panel" role="tabpanel" aria-label={current ? current.label : label} tabindex="0"><slot /></div>
|