@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/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,10 +14,19 @@
|
|
|
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
|
|
28
|
+
// precache downloads at once: hundreds in one burst tripped per-IP proxy rate limits (429) and competed with the page
|
|
29
|
+
const PRECACHE_PARALLEL = 4
|
|
21
30
|
// responses marked personal or uncacheable are never stored
|
|
22
31
|
const storable = r => r.ok && !/no-store|private/i.test(r.headers.get('cache-control') || '')
|
|
23
32
|
const ROUTES = (B.routes || []).map(r => ({ ...r, cache: NS + ':route:' + r.match + (r.prefix ? '*' : '') }))
|
|
@@ -34,9 +43,28 @@ self.addEventListener('install', e => {
|
|
|
34
43
|
e.waitUntil(
|
|
35
44
|
(async () => {
|
|
36
45
|
const c = await caches.open(BUILD)
|
|
46
|
+
// a _b/ file is content-hashed: the same URL in an earlier build of this app holds the same bytes, so an
|
|
47
|
+
// update downloads only what changed
|
|
48
|
+
const olds = (await caches.keys()).filter(k => k.startsWith(NS + '-') && k !== BUILD)
|
|
49
|
+
const kept = async u => {
|
|
50
|
+
if (!isHashed(u)) return null
|
|
51
|
+
for (const k of olds) {
|
|
52
|
+
const r = await (await caches.open(k)).match(u)
|
|
53
|
+
if (r) return r
|
|
54
|
+
}
|
|
55
|
+
return null
|
|
56
|
+
}
|
|
57
|
+
// default cache mode: the page may have just filled the HTTP cache with these immutable files
|
|
58
|
+
const one = async u => {
|
|
59
|
+
const r = (await kept(u)) || (await fetch(u))
|
|
60
|
+
if (r.ok) await c.put(u, r)
|
|
61
|
+
}
|
|
37
62
|
// one failing URL must not abort the install: cache what answers, the rest is cached on first use
|
|
38
|
-
|
|
39
|
-
|
|
63
|
+
const queue = [...B.precache]
|
|
64
|
+
const lane = async () => {
|
|
65
|
+
while (queue.length) await one(queue.shift()).catch(() => {})
|
|
66
|
+
}
|
|
67
|
+
await Promise.all(Array.from({ length: Math.min(PRECACHE_PARALLEL, queue.length) }, lane))
|
|
40
68
|
// update: 'prompt' → an update waits for the page (appUpdate); the first install has nothing to replace
|
|
41
69
|
if (B.update !== 'prompt' || !self.registration.active) await self.skipWaiting()
|
|
42
70
|
})()
|
|
@@ -53,6 +81,24 @@ self.addEventListener('activate', e => {
|
|
|
53
81
|
const gens = r ? await r.json() : []
|
|
54
82
|
const keep = [...gens.filter(g => g !== BUILD).slice(-1), BUILD]
|
|
55
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
|
+
}
|
|
56
102
|
for (const k of await caches.keys())
|
|
57
103
|
if ((k.startsWith(NS + '-') && !keep.includes(k)) || (k.startsWith(NS + ':route:') && !ROUTES.some(r => r.cache === k))) await caches.delete(k)
|
|
58
104
|
await self.clients.claim()
|
|
@@ -70,10 +116,18 @@ self.addEventListener('fetch', e => {
|
|
|
70
116
|
if (req.method !== 'GET') return
|
|
71
117
|
const url = new URL(req.url)
|
|
72
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
|
|
73
121
|
// offline: false (B.offline null) → navigations go straight to the network: no page cache, no offline page
|
|
74
122
|
if (req.mode === 'navigate') return B.offline ? e.respondWith(navigate(e)) : undefined
|
|
75
|
-
if (url.pathname
|
|
123
|
+
if (isHashed(url.pathname)) return e.respondWith(hashed(e))
|
|
76
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
|
+
})())
|
|
77
131
|
if (rt) return e.respondWith(rt.strategy === 'network-only' ? fetch(req) : data(e, rt))
|
|
78
132
|
// only files the page itself loads (img, style, script, font, manifest…); fetch()/XHR data is never touched (§35:
|
|
79
133
|
// data is not cached by default)
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// Application-owned Sign in with Apple JS adapter (popup flow). No tokens, private errors or session data are logged or stored.
|
|
2
|
+
const SDK = 'https://appleid.cdn-apple.com/appleauth/static/jsapi/appleid/1/en_US/appleid.auth.js'
|
|
3
|
+
let loading = null, active = null
|
|
4
|
+
const sdk = () => {
|
|
5
|
+
if (globalThis.AppleID?.auth) return Promise.resolve(globalThis.AppleID.auth)
|
|
6
|
+
if (loading) return loading
|
|
7
|
+
loading = new Promise((resolve, reject) => {
|
|
8
|
+
const doc = globalThis.document, existing = doc.querySelector(`script[src="${SDK}"]`)
|
|
9
|
+
const script = existing || doc.createElement('script')
|
|
10
|
+
const done = error => {
|
|
11
|
+
clearTimeout(timer); script.removeEventListener('load', loaded); script.removeEventListener('error', failed)
|
|
12
|
+
if (error) { if (!existing) script.remove(); reject(Error('Apple sign-in is unavailable')) }
|
|
13
|
+
else resolve(globalThis.AppleID.auth)
|
|
14
|
+
}
|
|
15
|
+
const loaded = () => globalThis.AppleID?.auth ? done(false) : done(true), failed = () => done(true)
|
|
16
|
+
const timer = setTimeout(failed, 6000)
|
|
17
|
+
script.addEventListener('load', loaded); script.addEventListener('error', failed)
|
|
18
|
+
if (!existing) { script.src = SDK; script.async = true; script.defer = true; doc.head.appendChild(script) }
|
|
19
|
+
}).catch(error => { loading = null; throw error })
|
|
20
|
+
return loading
|
|
21
|
+
}
|
|
22
|
+
// Apple sends the name only on the first authorization and unsigned: it is a display hint, never an identity.
|
|
23
|
+
const name = user => {
|
|
24
|
+
const first = typeof user?.name?.firstName === 'string' ? user.name.firstName.trim() : ''
|
|
25
|
+
const last = typeof user?.name?.lastName === 'string' ? user.name.lastName.trim() : ''
|
|
26
|
+
const full = `${first} ${last}`.trim()
|
|
27
|
+
return full && full.length <= 200 ? full : undefined
|
|
28
|
+
}
|
|
29
|
+
export default {
|
|
30
|
+
mount(element, initial = {}) {
|
|
31
|
+
if (active) throw Error('Only one Apple sign-in widget may be active')
|
|
32
|
+
let props = initial, alive = true, busy = false, pending = null
|
|
33
|
+
const owner = {}; active = owner
|
|
34
|
+
const notify = (message, waiting, signedIn = false) => {
|
|
35
|
+
if (!alive) return
|
|
36
|
+
busy = waiting; element.setAttribute('aria-busy', String(waiting))
|
|
37
|
+
if ('inert' in element) element.inert = waiting
|
|
38
|
+
if (typeof props.onState === 'function') props.onState({ message, busy: waiting, signedIn })
|
|
39
|
+
}
|
|
40
|
+
const request = async (path, body, method = 'POST') => {
|
|
41
|
+
const controller = new AbortController(); pending = controller
|
|
42
|
+
const timer = setTimeout(() => controller.abort(), 8000)
|
|
43
|
+
try {
|
|
44
|
+
const base = globalThis.document?.querySelector('meta[name=brain-base]')?.getAttribute('content') || ''
|
|
45
|
+
if (base && (!base.startsWith('/') || base.startsWith('//') || /[\\?#\x00-\x20]/.test(base))) throw Error('Invalid application base')
|
|
46
|
+
const response = await fetch(base.replace(/\/$/, '') + path, { method, credentials: 'same-origin', headers: body == null ? {} : { 'content-type': 'application/json' }, ...(body == null ? {} : { body: JSON.stringify(body) }), signal: controller.signal })
|
|
47
|
+
if (!response.ok) return { $status: response.status }
|
|
48
|
+
return await response.json()
|
|
49
|
+
} finally { clearTimeout(timer); if (pending === controller) pending = null }
|
|
50
|
+
}
|
|
51
|
+
const failed = () => notify('Sign-in could not be completed. Retry or use email sign-in.', false)
|
|
52
|
+
const start = async () => {
|
|
53
|
+
if (!alive || busy) return
|
|
54
|
+
notify('Preparing secure sign-in.', true)
|
|
55
|
+
try {
|
|
56
|
+
const current = await request('/_brain/auth/me', null, 'GET')
|
|
57
|
+
if (!alive) return
|
|
58
|
+
if (current?.user) { element.replaceChildren(); return notify('You are signed in.', false, true) }
|
|
59
|
+
if (current?.$status !== 401) return failed()
|
|
60
|
+
const renewed = await request('/_brain/auth/refresh')
|
|
61
|
+
if (!alive) return
|
|
62
|
+
if (renewed?.user) { notify('You are signed in.', false, true); globalThis.location?.reload(); return }
|
|
63
|
+
if (renewed?.$status !== 401) return failed()
|
|
64
|
+
const challenge = await request('/_brain/auth/apple/challenge')
|
|
65
|
+
if (!alive) return
|
|
66
|
+
if (!challenge || !/^[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+$/.test(challenge.client_id) || !/^[a-f0-9]{64}$/.test(challenge.nonce) || typeof challenge.redirect_uri !== 'string') return failed()
|
|
67
|
+
const auth = await sdk()
|
|
68
|
+
if (!alive) return
|
|
69
|
+
auth.init({ clientId: challenge.client_id, scope: 'name email', redirectURI: challenge.redirect_uri, nonce: challenge.nonce, usePopup: true })
|
|
70
|
+
const button = globalThis.document.createElement('button')
|
|
71
|
+
button.type = 'button'; button.className = 'brain-apple-signin'; button.textContent = 'Sign in with Apple'
|
|
72
|
+
button.addEventListener('click', async () => {
|
|
73
|
+
if (!alive || busy) return
|
|
74
|
+
notify('Completing secure sign-in.', true)
|
|
75
|
+
try {
|
|
76
|
+
const result = await auth.signIn()
|
|
77
|
+
const token = result?.authorization?.id_token
|
|
78
|
+
if (!alive) return
|
|
79
|
+
if (typeof token !== 'string' || token.length > 16384) return failed()
|
|
80
|
+
const session = await request('/_brain/auth/apple', { id_token: token, ...(name(result.user) ? { name: name(result.user) } : {}) })
|
|
81
|
+
if (!alive) return
|
|
82
|
+
if (!session?.user) return failed()
|
|
83
|
+
notify('You are signed in.', false, true)
|
|
84
|
+
// A full auth-boundary reload forgets all prior user-specific in-memory application state.
|
|
85
|
+
globalThis.location?.reload()
|
|
86
|
+
} catch {
|
|
87
|
+
// A closed popup (`popup_closed_by_user`) is a choice, not an error.
|
|
88
|
+
if (alive) notify('Choose Apple sign-in or use email sign-in.', false)
|
|
89
|
+
}
|
|
90
|
+
})
|
|
91
|
+
element.replaceChildren(button)
|
|
92
|
+
notify('Choose Apple sign-in or use email sign-in.', false)
|
|
93
|
+
} catch { if (alive) failed() }
|
|
94
|
+
}
|
|
95
|
+
void start()
|
|
96
|
+
return {
|
|
97
|
+
update(next) {
|
|
98
|
+
props = next || {}
|
|
99
|
+
if (!alive || busy) return
|
|
100
|
+
if (props.action === 'logout') {
|
|
101
|
+
notify('Signing out.', true)
|
|
102
|
+
void request('/_brain/auth/logout').then(result => {
|
|
103
|
+
if (!alive) return
|
|
104
|
+
if (!result?.ok) return failed()
|
|
105
|
+
notify('You are signed out.', false); globalThis.location?.reload()
|
|
106
|
+
}, () => { if (alive) failed() })
|
|
107
|
+
} else void start()
|
|
108
|
+
},
|
|
109
|
+
destroy() {
|
|
110
|
+
if (!alive) return
|
|
111
|
+
alive = false; pending?.abort(); element.replaceChildren(); element.removeAttribute('aria-busy')
|
|
112
|
+
if ('inert' in element) element.inert = false
|
|
113
|
+
if (active === owner) active = null
|
|
114
|
+
},
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
}
|
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>
|