@christianirack/brainllm-runtime 0.0.0-stage → 0.3.1
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/LICENSE +21 -0
- package/README.md +4 -2
- package/brain-ai.js +129 -0
- package/brain-anim.js +21 -0
- package/brain-ch.js +254 -0
- package/brain-consent.js +38 -0
- package/brain-data.js +549 -0
- package/brain-dev.js +100 -0
- package/brain-env.js +160 -0
- package/brain-err.js +242 -0
- package/brain-form.js +275 -0
- package/brain-guard.js +242 -0
- package/brain-hydrate.js +72 -0
- package/brain-i18n.js +111 -0
- package/brain-inspect.js +745 -0
- package/brain-island.js +106 -0
- package/brain-lazy.js +88 -0
- package/brain-mcp.js +73 -0
- package/brain-mock.js +80 -0
- package/brain-overlay.js +123 -0
- package/brain-persist.js +65 -0
- package/brain-router.js +503 -0
- package/brain-rpc.js +20 -0
- package/brain-schema.js +93 -0
- package/brain-wc.js +69 -0
- package/brain-why.js +198 -0
- package/brain-worker.js +66 -0
- package/brain.js +421 -0
- package/package.json +20 -4
- package/server/server.mjs +1046 -0
- package/shim/dom.js +448 -0
- package/shim/probe.js +111 -0
- package/sw/brain-sw.js +103 -0
- package/themes/contrast.cssbrain +39 -0
- package/themes/dark.cssbrain +39 -0
- package/themes/glass.cssbrain +39 -0
- package/themes/paper.cssbrain +39 -0
- package/ui/button.brain +31 -0
- package/ui/center.brain +11 -0
- package/ui/checkbox.brain +13 -0
- package/ui/container.brain +11 -0
- package/ui/dialog.brain +29 -0
- package/ui/error-state.brain +53 -0
- package/ui/field.brain +34 -0
- package/ui/grid.brain +11 -0
- package/ui/input.brain +23 -0
- package/ui/lang-switch.brain +38 -0
- package/ui/menu.brain +64 -0
- package/ui/row.brain +12 -0
- package/ui/select.brain +29 -0
- package/ui/sidebar.brain +17 -0
- package/ui/spacer.brain +12 -0
- package/ui/stack.brain +10 -0
- package/ui/table.brain +73 -0
- package/ui/tabs.brain +33 -0
- package/ui/toast.brain +45 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Christian Irack Flores Maldonado
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @christianirack/brainllm-runtime
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The runtime of [BRAINLLM](https://chrisifm.github.io/BRAINLLM/), the web compiler for LLMs built in Rust: dependency-free ES modules (signals, DOM, router, data, forms, channels, service worker, server).
|
|
4
|
+
The `brain` binary embeds this same version and serves it at `/@brain/*`; install this package only for tooling that
|
|
5
|
+
needs the modules on disk.
|
package/brain-ai.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Built-in AI (Chrome's on-device Gemini Nano, INTERNALS §19, docs checked 2026-10-29): `ai` is a primitive, so a view
|
|
2
|
+
// uses it without an import. Every call feature-detects the browser API; on the server or in a browser without it,
|
|
3
|
+
// `ai.ready()` answers 'unavailable' and the other calls throw an Error with `code: 'unavailable'`, so the app decides
|
|
4
|
+
// what to show instead. What the model does is up to the app: the runtime only opens the door.
|
|
5
|
+
// await ai.ready() 'available' | 'downloadable' | 'downloading' | 'unavailable' (Prompt API)
|
|
6
|
+
// await ai.ready('summarizer') same for summarizer, detector, writer, rewriter, proofreader
|
|
7
|
+
// await ai.ready('translator', { from: 'en', to: 'es' }) the translator needs its two languages
|
|
8
|
+
// await ai.prompt('Name three colors', { system, schema, signal, onProgress }) → string (stateless call)
|
|
9
|
+
// for await (const part of ai.stream('Tell a story')) … → text chunks as they arrive
|
|
10
|
+
// const chat = await ai.session({ system }) native session: chat.prompt(), append(), clone(), destroy()
|
|
11
|
+
// await ai.summarize(text, { type, format, length, context }) → string
|
|
12
|
+
// await ai.translate(text, { from: 'en', to: 'es' }) → string
|
|
13
|
+
// await ai.detect(text) → [{ detectedLanguage, confidence }]
|
|
14
|
+
// await ai.write(task) · ai.rewrite(text, { tone }) · ai.proofread(text)
|
|
15
|
+
// Status (Chrome 138+ stable): Summarizer, Translator and LanguageDetector on the web; the Prompt API is an origin
|
|
16
|
+
// trial on the web (Chrome 148) and stable in extensions; Writer, Rewriter and Proofreader are trials. The Prompt API
|
|
17
|
+
// runs in the top window and same-origin iframes (`allow="language-model"` for others), not in Web Workers. A model
|
|
18
|
+
// that is 'downloadable' starts downloading on the first create, which Chrome only allows after a user gesture: call
|
|
19
|
+
// these from an event handler the first time; `onProgress(0..1)` follows the download.
|
|
20
|
+
const G = globalThis
|
|
21
|
+
const API = {
|
|
22
|
+
prompt: 'LanguageModel',
|
|
23
|
+
summarizer: 'Summarizer',
|
|
24
|
+
translator: 'Translator',
|
|
25
|
+
detector: 'LanguageDetector',
|
|
26
|
+
writer: 'Writer',
|
|
27
|
+
rewriter: 'Rewriter',
|
|
28
|
+
proofreader: 'Proofreader',
|
|
29
|
+
}
|
|
30
|
+
const native = kind => {
|
|
31
|
+
const name = API[kind]
|
|
32
|
+
if (!name) throw Error(`ai: unknown model \`${kind}\`; use one of ${Object.keys(API).join(', ')}`)
|
|
33
|
+
return G[name] || null
|
|
34
|
+
}
|
|
35
|
+
const missing = kind => Object.assign(Error(`Built-in AI is not available in this browser (${API[kind]})`), { code: 'unavailable' })
|
|
36
|
+
|
|
37
|
+
// `{ from, to }` are the translator's languages, as in ai.translate (Translator.availability needs them)
|
|
38
|
+
const langs = ({ from, to, ...o } = {}) => ({ ...o, ...(from ? { sourceLanguage: from } : {}), ...(to ? { targetLanguage: to } : {}) })
|
|
39
|
+
async function ready(kind = 'prompt', options) {
|
|
40
|
+
const N = native(kind)
|
|
41
|
+
if (!N || !N.availability) return 'unavailable'
|
|
42
|
+
try {
|
|
43
|
+
return await N.availability(langs(options))
|
|
44
|
+
} catch {
|
|
45
|
+
return 'unavailable'
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// create(options) with the download progress wired to onProgress (e.loaded is 0..1)
|
|
50
|
+
async function create(kind, o = {}) {
|
|
51
|
+
const N = native(kind)
|
|
52
|
+
if (!N || !N.create) throw missing(kind)
|
|
53
|
+
const { onProgress, ...rest } = o
|
|
54
|
+
if (onProgress) rest.monitor = m => m.addEventListener('downloadprogress', e => onProgress(e.loaded))
|
|
55
|
+
return N.create(rest)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// one base session per system prompt; each stateless call works on a clone, so calls never share history
|
|
59
|
+
const bases = new Map()
|
|
60
|
+
async function base(o) {
|
|
61
|
+
const k = o.system || ''
|
|
62
|
+
if (!bases.has(k)) {
|
|
63
|
+
const p = create('prompt', { ...(o.system ? { initialPrompts: [{ role: 'system', content: o.system }] } : {}), ...(o.onProgress ? { onProgress: o.onProgress } : {}) })
|
|
64
|
+
p.catch(() => bases.delete(k))
|
|
65
|
+
bases.set(k, p)
|
|
66
|
+
}
|
|
67
|
+
return (await bases.get(k)).clone()
|
|
68
|
+
}
|
|
69
|
+
const opts = o => ({ ...(o.signal ? { signal: o.signal } : {}), ...(o.schema ? { responseConstraint: o.schema } : {}) })
|
|
70
|
+
|
|
71
|
+
async function prompt(text, o = {}) {
|
|
72
|
+
const s = await base(o)
|
|
73
|
+
try {
|
|
74
|
+
const out = await s.prompt(text, opts(o))
|
|
75
|
+
return o.schema && typeof out === 'string' ? JSON.parse(out) : out
|
|
76
|
+
} finally {
|
|
77
|
+
s.destroy && s.destroy()
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function* stream(text, o = {}) {
|
|
82
|
+
const s = await base(o)
|
|
83
|
+
try {
|
|
84
|
+
for await (const part of s.promptStreaming(text, opts(o))) yield part
|
|
85
|
+
} finally {
|
|
86
|
+
s.destroy && s.destroy()
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// a native session that keeps history (a chat); `system` becomes its first prompt
|
|
91
|
+
const session = (o = {}) => {
|
|
92
|
+
const { system, ...rest } = o
|
|
93
|
+
return create('prompt', { ...(system ? { initialPrompts: [{ role: 'system', content: system }, ...(rest.initialPrompts || [])] } : {}), ...rest })
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// one model per kind and options, created on first use and reused
|
|
97
|
+
const tools = new Map()
|
|
98
|
+
const tool = (kind, o) => {
|
|
99
|
+
const { onProgress, signal, ...rest } = o || {}
|
|
100
|
+
const k = kind + JSON.stringify(rest)
|
|
101
|
+
if (!tools.has(k)) {
|
|
102
|
+
const p = create(kind, { ...rest, ...(onProgress ? { onProgress } : {}) })
|
|
103
|
+
p.catch(() => tools.delete(k))
|
|
104
|
+
tools.set(k, p)
|
|
105
|
+
}
|
|
106
|
+
return tools.get(k)
|
|
107
|
+
}
|
|
108
|
+
const call = (kind, method, keys) => async (text, o = {}) => {
|
|
109
|
+
const make = {}, run = {}
|
|
110
|
+
for (const [k, v] of Object.entries(o)) (keys.includes(k) ? run : make)[k] = v
|
|
111
|
+
if (o.signal) run.signal = o.signal
|
|
112
|
+
return (await tool(kind, make))[method](text, run)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export const ai = {
|
|
116
|
+
ready,
|
|
117
|
+
prompt,
|
|
118
|
+
stream,
|
|
119
|
+
session,
|
|
120
|
+
summarize: call('summarizer', 'summarize', ['context']),
|
|
121
|
+
translate: (text, o = {}) => {
|
|
122
|
+
const { from, to, ...rest } = o
|
|
123
|
+
return call('translator', 'translate', ['signal'])(text, { sourceLanguage: from, targetLanguage: to, ...rest })
|
|
124
|
+
},
|
|
125
|
+
detect: call('detector', 'detect', []),
|
|
126
|
+
write: call('writer', 'write', ['context']),
|
|
127
|
+
rewrite: call('rewriter', 'rewrite', ['context']),
|
|
128
|
+
proofread: call('proofreader', 'proofread', []),
|
|
129
|
+
}
|
package/brain-anim.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// BRAINLLM transitions (INTERNALS §41.6): `transition:fade|slide|scale` on an element inside {#if}/{#each} compiles to
|
|
2
|
+
// `transition(el, 'fade', { ms, in, out })` right after the element is created. The enter animation plays once the
|
|
3
|
+
// element is in the document; the exit one is left on the element as `el.$out` (→ Promise), which the core's removal
|
|
4
|
+
// (when/list) waits for before taking the node out. Web Animations API; nothing with prefers-reduced-motion, without
|
|
5
|
+
// `el.animate` or while the server renders.
|
|
6
|
+
const G = globalThis
|
|
7
|
+
const K = {
|
|
8
|
+
fade: [{ opacity: 0 }, { opacity: 1 }],
|
|
9
|
+
scale: [{ opacity: 0, transform: 'scale(.95)' }, { opacity: 1, transform: 'none' }],
|
|
10
|
+
slide: [{ opacity: 0, transform: 'translateY(-8px)' }, { opacity: 1, transform: 'none' }],
|
|
11
|
+
}
|
|
12
|
+
const still = () => !!(G.matchMedia && G.matchMedia('(prefers-reduced-motion: reduce)').matches)
|
|
13
|
+
|
|
14
|
+
export function transition(el, kind = 'fade', o = {}) {
|
|
15
|
+
const k = K[kind]
|
|
16
|
+
if (!k) throw Error(`transition: unknown kind "${kind}" (fade, slide, scale)`)
|
|
17
|
+
if (!el.animate || G.__brainSSR || G.__brainRoute || still()) return
|
|
18
|
+
const opts = { duration: o.ms ?? 200, easing: 'cubic-bezier(.22,1,.36,1)' }
|
|
19
|
+
if (o.in !== false) queueMicrotask(() => el.isConnected && el.animate(k, opts))
|
|
20
|
+
if (o.out !== false) el.$out = () => el.animate([...k].reverse(), { ...opts, fill: 'forwards' }).finished.catch(() => {})
|
|
21
|
+
}
|
package/brain-ch.js
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
// BRAINLLM channels (INTERNALS §12): context (provide/use/key), store, bus and exposed API. Props, the `~` model,
|
|
2
|
+
// events (`emit` → `$p["on:x"]`) and slots are resolved by the codegen with no extra runtime.
|
|
3
|
+
// Every channel use notifies `seen` (fn(kind, name, role, owner)): the inspector builds the Graph view without this
|
|
4
|
+
// module retaining owners.
|
|
5
|
+
import { _rt, sig, fx, batch, untrack } from './brain.js'
|
|
6
|
+
|
|
7
|
+
const G = globalThis
|
|
8
|
+
const I = _rt((pairs, f) => {
|
|
9
|
+
for (const [k, v] of pairs) provide(k, v)
|
|
10
|
+
return f()
|
|
11
|
+
})
|
|
12
|
+
export const seen = new Set()
|
|
13
|
+
const note = (k, n, role) => {
|
|
14
|
+
if (seen.size) for (const f of seen) f(k, n, role, I.owner())
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// ---------- context ----------
|
|
18
|
+
// The key is an object exported from channels/*.jsbrain (`export const Theme = key('theme')`): no collisions.
|
|
19
|
+
export const key = (name, d) => ({ name, d })
|
|
20
|
+
const CTX = new WeakMap() // owner N → Map(key → value)
|
|
21
|
+
// Components built afterwards under the same owner see it. To isolate siblings, the codegen wraps the component that
|
|
22
|
+
// calls provide with `scope(C)`: that way the value stays only in its subtree.
|
|
23
|
+
export function provide(k, v) {
|
|
24
|
+
const o = I.owner()
|
|
25
|
+
if (!o) throw Error('provide outside a component')
|
|
26
|
+
const m = CTX.get(o) || (CTX.set(o, new Map()), CTX.get(o))
|
|
27
|
+
m.set(k, v)
|
|
28
|
+
note('ctx', k.name, 'provide')
|
|
29
|
+
return v
|
|
30
|
+
}
|
|
31
|
+
export function use(k) {
|
|
32
|
+
note('ctx', k.name, 'use')
|
|
33
|
+
for (let o = I.owner(); o; o = o.p) {
|
|
34
|
+
const m = CTX.get(o)
|
|
35
|
+
if (m && m.has(k)) return m.get(k)
|
|
36
|
+
}
|
|
37
|
+
if (k.d !== undefined) return k.d
|
|
38
|
+
throw Object.assign(Error(`B0807: use(${k.name}) without provide(${k.name}) in the ancestors`), { code: 'B0807' })
|
|
39
|
+
}
|
|
40
|
+
// Own owner for a component (the codegen emits it if the component calls provide).
|
|
41
|
+
export const scope = C => Object.assign($p => I.root(() => C($p), I.owner()), { C, h: C.h, f: C.f, p: C.p, form: C.form })
|
|
42
|
+
// Tests and app.brain: mount with provided values (`withProvide([[Api, fakeApi]], () => Comp(props))`).
|
|
43
|
+
export const withProvide = (pairs, f) =>
|
|
44
|
+
I.root(() => {
|
|
45
|
+
for (const [k, v] of pairs) provide(k, v)
|
|
46
|
+
return f()
|
|
47
|
+
}, I.owner())
|
|
48
|
+
|
|
49
|
+
// Reactive context read (codegen): `let theme = use(Theme)` → `drv(((u) => () => val(u))(use(Theme)))`. A provided
|
|
50
|
+
// sig/drv is read (tracked); any other value, including objects with a `v` field, is returned as is.
|
|
51
|
+
const N = sig(0).constructor
|
|
52
|
+
export const val = x => (x instanceof N ? x.v : x)
|
|
53
|
+
|
|
54
|
+
// state from another tab, the server or storage is untrusted JSON: prototype keys are dropped (§31)
|
|
55
|
+
const safe = (k, v) => (k === '__proto__' || k === 'constructor' || k === 'prototype' ? undefined : v)
|
|
56
|
+
|
|
57
|
+
// ---------- store ----------
|
|
58
|
+
// `store({ items: [], total: 0 })` → object with a reactive getter/setter per field (`cart.items = [...]`).
|
|
59
|
+
// `$set(patch)` writes several in one batch; `$snap()` flat copy; `$reset()` restores init. Valid in channels/ (one
|
|
60
|
+
// global instance) and inside `provide(K, store(…))` (one instance per subtree, §41.2). sync: 'tab' (BroadcastChannel) | 'storage'
|
|
61
|
+
// (localStorage + storage event) | 'ws' (WebSocket at `url`, see ws() below). `id` is set by the codegen (channel
|
|
62
|
+
// path); if missing, it is derived from init.
|
|
63
|
+
export function store(init, o = {}, id) {
|
|
64
|
+
const name = id || o.name || 'store:' + Object.keys(init).join(',')
|
|
65
|
+
const S = {}, s = {}
|
|
66
|
+
let last = '' // JSON of the last state sent or received: cuts the echo between tabs
|
|
67
|
+
for (const k of Object.keys(init)) {
|
|
68
|
+
S[k] = sig(init[k], name + '.' + k) // named: dev tools (brain_why, HMR) see `cart.total`
|
|
69
|
+
Object.defineProperty(s, k, {
|
|
70
|
+
enumerable: true,
|
|
71
|
+
get: () => (note('store', name, 'read'), S[k].v),
|
|
72
|
+
set: v => (note('store', name, 'write'), (S[k].v = v)),
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
const snap = () => {
|
|
76
|
+
const r = {}
|
|
77
|
+
for (const k in S) r[k] = untrack(() => S[k].v)
|
|
78
|
+
return r
|
|
79
|
+
}
|
|
80
|
+
const apply = d => {
|
|
81
|
+
const n = snap()
|
|
82
|
+
for (const k in d) if (k in S) n[k] = d[k]
|
|
83
|
+
last = JSON.stringify(n) // before the batch: the sync fx runs when it closes and must see the echo
|
|
84
|
+
batch(() => {
|
|
85
|
+
for (const k in n) S[k].v = n[k]
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
Object.defineProperties(s, {
|
|
89
|
+
$set: { value: d => batch(() => { for (const k in d) if (k in S) s[k] = d[k] }) },
|
|
90
|
+
$snap: { value: snap },
|
|
91
|
+
$reset: { value: () => apply({ ...init }) }, // back to the initial state (persisted and synced like any write)
|
|
92
|
+
})
|
|
93
|
+
const K = 'brain:' + name
|
|
94
|
+
if (o.persist) {
|
|
95
|
+
// Persistence (INTERNALS §35.4), versioned by the shape of init. Small states live in localStorage and are restored
|
|
96
|
+
// here, synchronously, before the first render; brain-persist.js (loaded on demand) handles IndexedDB for large ones
|
|
97
|
+
// and the writes. `ready()` resolves once every persisted store is restored.
|
|
98
|
+
const P = 'brain:p:' + name, v = shape(init)
|
|
99
|
+
let big = false
|
|
100
|
+
try {
|
|
101
|
+
const r = JSON.parse(localStorage.getItem(P), safe)
|
|
102
|
+
if (r && r.v === v) r.idb ? (big = true) : apply(r.d)
|
|
103
|
+
else if (r) localStorage.removeItem(P) // another shape: the old data is discarded, never applied
|
|
104
|
+
} catch {}
|
|
105
|
+
const base = JSON.stringify(snap()) // the state as created: changes made before the module loads are still saved
|
|
106
|
+
READY.push(import('./brain-persist.js').then(m => m.persist({ key: P, v, big, apply, snap, S, base })))
|
|
107
|
+
}
|
|
108
|
+
if (o.sync === 'storage' && G.localStorage) {
|
|
109
|
+
try {
|
|
110
|
+
const v = localStorage.getItem(K)
|
|
111
|
+
if (v) apply(JSON.parse(v, safe))
|
|
112
|
+
} catch {}
|
|
113
|
+
G.addEventListener && G.addEventListener('storage', e => e.key === K && e.newValue && apply(JSON.parse(e.newValue, safe)))
|
|
114
|
+
}
|
|
115
|
+
const bc = o.sync === 'tab' && G.BroadcastChannel ? new BroadcastChannel(K) : null
|
|
116
|
+
const w = o.sync === 'ws' && G.WebSocket ? ws(o.url || '/_ws', K, apply) : null
|
|
117
|
+
if (w) Object.defineProperty(s, '$close', { value: w.close })
|
|
118
|
+
if (bc) {
|
|
119
|
+
bc.onmessage = e => (e.data === '?' ? bc.postMessage(snap()) : apply(e.data))
|
|
120
|
+
bc.postMessage('?') // a new tab asks the open ones for the state
|
|
121
|
+
}
|
|
122
|
+
last = JSON.stringify(snap()) // the initial state is not broadcast: a new tab asks for it with '?'
|
|
123
|
+
// a store created inside provide() lives in that subtree: its sync effect is disposed with it
|
|
124
|
+
if (o.sync)
|
|
125
|
+
I.root(() =>
|
|
126
|
+
fx(() => {
|
|
127
|
+
const d = {}
|
|
128
|
+
for (const k in S) d[k] = S[k].v // reads everything: re-runs on any change
|
|
129
|
+
const j = JSON.stringify(d)
|
|
130
|
+
if (j === last) return
|
|
131
|
+
last = j
|
|
132
|
+
if (bc) bc.postMessage(d)
|
|
133
|
+
else if (w) w.send(d)
|
|
134
|
+
else
|
|
135
|
+
try {
|
|
136
|
+
if (j.length > 2e6) throw Error('over 2 MB') // localStorage is ~5 MB per origin for the whole app
|
|
137
|
+
localStorage.setItem(K, j)
|
|
138
|
+
} catch (e) {
|
|
139
|
+
// full or too big: the state stays in memory, the page keeps working, and the author is told why (§31)
|
|
140
|
+
console.warn(`store ${name}: not saved to localStorage (${e && e.message}); it is kept in memory only`)
|
|
141
|
+
}
|
|
142
|
+
}),
|
|
143
|
+
I.owner())
|
|
144
|
+
return s
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Real-time sync (INTERNALS §22). One socket per url, shared by its stores. Protocol (JSON text frames):
|
|
148
|
+
// client → server {s, d} local change (full state) · {s, q:1} asks for the current state (after each connect)
|
|
149
|
+
// server → client {s, d} state of store s (the server keeps the last one per s and relays it to the others)
|
|
150
|
+
// While offline, the latest local state waits and is sent on reconnect BEFORE asking (the server's reply then matches it).
|
|
151
|
+
// Reconnection: exponential backoff 0.5 s → 30 s with ±50 % jitter, reset on open; `online` and returning to a
|
|
152
|
+
// visible tab retry at once. `$close()` stops it.
|
|
153
|
+
const WS = new Map()
|
|
154
|
+
function ws(url, s, apply) {
|
|
155
|
+
let c = WS.get(url)
|
|
156
|
+
if (!c) {
|
|
157
|
+
const subs = new Map(), wait = new Map()
|
|
158
|
+
let sock, n = 0, t, off = false
|
|
159
|
+
const u = new URL(url, G.location && location.href).href.replace(/^http/, 'ws')
|
|
160
|
+
const put = (k, d) => sock && sock.readyState === 1 && (sock.send(JSON.stringify(d ? { s: k, d } : { s: k, q: 1 })), true)
|
|
161
|
+
const open = () => {
|
|
162
|
+
clearTimeout(t)
|
|
163
|
+
if (off || (sock && sock.readyState < 2)) return
|
|
164
|
+
sock = new WebSocket(u)
|
|
165
|
+
sock.onopen = () => {
|
|
166
|
+
n = 0
|
|
167
|
+
for (const [k, d] of wait) put(k, d)
|
|
168
|
+
wait.clear()
|
|
169
|
+
for (const k of subs.keys()) put(k)
|
|
170
|
+
}
|
|
171
|
+
sock.onmessage = e => {
|
|
172
|
+
let m
|
|
173
|
+
try {
|
|
174
|
+
m = JSON.parse(e.data, safe)
|
|
175
|
+
} catch {
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
const f = m && subs.get(m.s)
|
|
179
|
+
if (f && m.d && typeof m.d === 'object') f(m.d)
|
|
180
|
+
}
|
|
181
|
+
sock.onclose = () => {
|
|
182
|
+
if (off) return
|
|
183
|
+
const ms = Math.min(30000, 500 * 2 ** n++)
|
|
184
|
+
t = setTimeout(open, ms * (0.5 + Math.random()))
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
const now = () => ((n = 0), open())
|
|
188
|
+
G.addEventListener && G.addEventListener('online', now)
|
|
189
|
+
G.document && document.addEventListener && document.addEventListener('visibilitychange', () => document.visibilityState === 'visible' && open())
|
|
190
|
+
c = {
|
|
191
|
+
subs,
|
|
192
|
+
sub(k, f) {
|
|
193
|
+
subs.set(k, f)
|
|
194
|
+
if (!put(k)) open()
|
|
195
|
+
},
|
|
196
|
+
send: (k, d) => put(k, d) || (wait.set(k, d), open()),
|
|
197
|
+
close() {
|
|
198
|
+
off = true
|
|
199
|
+
clearTimeout(t)
|
|
200
|
+
sock && sock.close()
|
|
201
|
+
WS.delete(url)
|
|
202
|
+
G.removeEventListener && G.removeEventListener('online', now)
|
|
203
|
+
},
|
|
204
|
+
}
|
|
205
|
+
WS.set(url, c)
|
|
206
|
+
}
|
|
207
|
+
c.sub(s, apply)
|
|
208
|
+
return { send: d => c.send(s, d), close: () => (c.subs.delete(s), c.subs.size || c.close()) }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Shape of a store: field names + kind of each initial value. A persisted state with another shape is discarded.
|
|
212
|
+
const kind = x => (Array.isArray(x) ? 'list' : x === null ? 'null' : typeof x)
|
|
213
|
+
const shape = init => Object.keys(init).sort().map(k => k + ':' + kind(init[k])).join(',')
|
|
214
|
+
const READY = []
|
|
215
|
+
export const ready = () => Promise.all(READY)
|
|
216
|
+
|
|
217
|
+
// ---------- bus ----------
|
|
218
|
+
// Stateless notification: `toast.send(msg)` · `toast.on(fn)` → off. Inside a component it unsubscribes itself on
|
|
219
|
+
// unmount. A failing handler does not stop the others (reportError → onerror → brain-err).
|
|
220
|
+
export function bus(name) {
|
|
221
|
+
const subs = new Set()
|
|
222
|
+
return {
|
|
223
|
+
name,
|
|
224
|
+
send(m) {
|
|
225
|
+
note('bus', name, 'send')
|
|
226
|
+
for (const f of [...subs])
|
|
227
|
+
try {
|
|
228
|
+
f(m)
|
|
229
|
+
} catch (e) {
|
|
230
|
+
G.reportError ? reportError(e) : queueMicrotask(() => { throw e })
|
|
231
|
+
}
|
|
232
|
+
},
|
|
233
|
+
on(f) {
|
|
234
|
+
note('bus', name, 'on')
|
|
235
|
+
subs.add(f)
|
|
236
|
+
const off = () => subs.delete(f)
|
|
237
|
+
if (I.owner()) fx(() => off)
|
|
238
|
+
return off
|
|
239
|
+
},
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ---------- exposed API ----------
|
|
244
|
+
// Child with `<meta expose="open close">`: the codegen emits `expose($p, { open, close })` at the end of setup; the parent
|
|
245
|
+
// `<Modal bind:api={m} />` passes `"bind:api": a => (m = a)`. When the child unmounts, the parent receives null.
|
|
246
|
+
export function expose($p, api) {
|
|
247
|
+
const f = $p['bind:api']
|
|
248
|
+
note('api', Object.keys(api).join(' '), 'expose')
|
|
249
|
+
if (f) {
|
|
250
|
+
f(Object.freeze(api))
|
|
251
|
+
if (I.owner()) fx(() => () => f(null))
|
|
252
|
+
}
|
|
253
|
+
return api
|
|
254
|
+
}
|
package/brain-consent.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// BRAINLLM consent (INTERNALS §32, Google Consent Mode v2). The build injects Analytics/Tag Manager with every consent
|
|
2
|
+
// type denied by default; the app grants what the visitor accepted:
|
|
3
|
+
// consent('analytics') · consent('ads') · consent('analytics', false) (revoke) · consented('analytics') → true/false
|
|
4
|
+
// The answer is remembered in localStorage and applied again on the next visit as soon as this module loads.
|
|
5
|
+
const G = globalThis
|
|
6
|
+
const KEY = 'brain:consent'
|
|
7
|
+
const TYPES = {
|
|
8
|
+
analytics: ['analytics_storage'],
|
|
9
|
+
ads: ['ad_storage', 'ad_user_data', 'ad_personalization'],
|
|
10
|
+
functional: ['functionality_storage'],
|
|
11
|
+
personal: ['personalization_storage'],
|
|
12
|
+
}
|
|
13
|
+
const read = () => {
|
|
14
|
+
try {
|
|
15
|
+
return JSON.parse(G.localStorage.getItem(KEY)) || {}
|
|
16
|
+
} catch {
|
|
17
|
+
return {}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
const apply = state => {
|
|
21
|
+
const u = {}
|
|
22
|
+
for (const k in state) for (const t of TYPES[k] || []) u[t] = state[k] ? 'granted' : 'denied'
|
|
23
|
+
if (!Object.keys(u).length) return
|
|
24
|
+
if (typeof G.gtag === 'function') G.gtag('consent', 'update', u)
|
|
25
|
+
else if (G.dataLayer) G.dataLayer.push(['consent', 'update', u]) // gtag not loaded yet: queued the way gtag reads it
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function consent(kind, granted = true) {
|
|
29
|
+
if (!TYPES[kind]) throw Error(`consent: unknown kind "${kind}" (${Object.keys(TYPES).join(', ')})`)
|
|
30
|
+
const s = { ...read(), [kind]: !!granted }
|
|
31
|
+
try {
|
|
32
|
+
G.localStorage.setItem(KEY, JSON.stringify(s))
|
|
33
|
+
} catch {}
|
|
34
|
+
apply({ [kind]: !!granted })
|
|
35
|
+
}
|
|
36
|
+
export const consented = kind => !!read()[kind]
|
|
37
|
+
|
|
38
|
+
apply(read()) // a returning visitor: their earlier answer, before any tag reads the defaults
|