@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/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
- await self.skipWaiting()
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()) if (k.startsWith('brain-') && !keep.includes(k)) await caches.delete(k)
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
+ }
@@ -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 ' + (e.retryAfter || 30) + ' seconds.'
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.findIndex((t) => t.id === value)
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.id === value ? 'true' : 'false'} tabindex={t.id === value ? 0 : -1} on:click={value = t.id}>{t.label}</button>
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>