@christianirack/brainllm-runtime 0.3.1 → 0.5.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/THIRD_PARTY_NOTICES.txt +211 -0
- package/brain-budget.js +142 -0
- package/brain-ch.js +18 -4
- package/brain-data.js +13 -3
- package/brain-dev.js +191 -28
- package/brain-err.js +168 -43
- package/brain-form.js +58 -23
- package/brain-hash.js +17 -0
- package/brain-inspect.js +6 -4
- package/brain-interop.js +50 -0
- package/brain-live.js +364 -0
- package/brain-overlay.js +12 -0
- package/brain-preferences.js +64 -0
- package/brain-prefetch-shared.js +36 -0
- package/brain-prefetch.js +19 -0
- package/brain-router.js +144 -78
- package/brain-schema.js +69 -18
- package/brain-sqlite.js +122 -0
- package/brain-storage.js +143 -0
- package/brain-threads.js +49 -0
- package/brain-time.js +77 -0
- package/brain-url.js +125 -0
- package/brain-worker.js +105 -13
- package/brain.js +5 -4
- package/package.json +45 -1
- package/server/native-sockets.mjs +156 -0
- package/server/server.mjs +449 -41
- package/server/templates/gcs-adapter.mjs +99 -0
- package/templates/google-signin.js +102 -0
- package/ui/button.brain +1 -1
- package/ui/checkbox.brain +2 -1
- package/ui/dialog.brain +5 -3
- package/ui/error-state.brain +1 -1
- package/ui/input.brain +4 -3
- package/ui/menu.brain +4 -1
- package/ui/select.brain +2 -2
- package/ui/toast.brain +4 -1
package/brain-form.js
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
// Unsaved changes (§41.5): `f.dirty` is true while values differ from the last saved ones (init, reset() or a successful
|
|
16
16
|
// submit); leaving the page (client navigation or closing the tab) then asks first. No API.
|
|
17
17
|
import { sig, fx, batch, untrack, _rt } from './brain.js'
|
|
18
|
-
import { rule, check, msg } from './brain-schema.js'
|
|
18
|
+
import { rule, check, checkText, checkScalar, msg } from './brain-schema.js'
|
|
19
19
|
|
|
20
20
|
export { rule, check } // server.mjs validates posted forms with the same rules (§38)
|
|
21
21
|
|
|
@@ -41,9 +41,18 @@ if (globalThis.addEventListener && !globalThis.__brainDirtyHook) {
|
|
|
41
41
|
for (const f of DIRTY) if (f.dirty && !f.busy) return void (e.preventDefault(), (e.returnValue = ''))
|
|
42
42
|
})
|
|
43
43
|
}
|
|
44
|
-
const empty = v => v == null || v === '' ||
|
|
44
|
+
const empty = v => v == null || typeof v === 'string' && v.trim() === '' || Array.isArray(v) && !v.length
|
|
45
45
|
const init0 = r => (r.list ? [] : r.t === 'bool' ? false : '')
|
|
46
|
-
const out = (r, v) =>
|
|
46
|
+
const out = (r, v) => {
|
|
47
|
+
if (r.list) {
|
|
48
|
+
if (!Array.isArray(v)) return []
|
|
49
|
+
if (r.t !== 'num') return v
|
|
50
|
+
const numbers = new Array(v.length)
|
|
51
|
+
for (let i = 0; i < v.length; i++) numbers[i] = Number(v[i])
|
|
52
|
+
return numbers
|
|
53
|
+
}
|
|
54
|
+
return r.t === 'num' ? (empty(v) ? null : Number(v)) : r.t === 'bool' ? !!v : v
|
|
55
|
+
}
|
|
47
56
|
|
|
48
57
|
// state left by the server: globalThis.__brainForms while it renders, or the page's #brain-forms JSON
|
|
49
58
|
let SEEN
|
|
@@ -62,6 +71,46 @@ function posted(id) {
|
|
|
62
71
|
}
|
|
63
72
|
|
|
64
73
|
export function form(schema, o = {}) {
|
|
74
|
+
if (rule(schema).t !== 'obj') throw Error('form: schema must be a nested field object')
|
|
75
|
+
return createForm(schema, o, rule, check, remoteCheck)
|
|
76
|
+
}
|
|
77
|
+
// Compiler-only flat literal schema bytecode. Dynamic/public form() keeps the full parser.
|
|
78
|
+
export function formStatic(schema, o = {}) {
|
|
79
|
+
return compiledForm(schema, o, ['str', 'email'], checkText, remoteCheck)
|
|
80
|
+
}
|
|
81
|
+
export function formScalars(schema, o = {}) {
|
|
82
|
+
return compiledForm(schema, o, ['str', 'email', 'bool', 'num'], checkScalar, remoteCheck)
|
|
83
|
+
}
|
|
84
|
+
// Compiler-only options proven not to configure or dynamically install async checks.
|
|
85
|
+
export function formStaticSync(schema, o = {}) {
|
|
86
|
+
return compiledForm(schema, o, ['str', 'email'], checkText, null)
|
|
87
|
+
}
|
|
88
|
+
export function formScalarsSync(schema, o = {}) {
|
|
89
|
+
return compiledForm(schema, o, ['str', 'email', 'bool', 'num'], checkScalar, null)
|
|
90
|
+
}
|
|
91
|
+
function compiledForm(schema, o, types, validate, remote) {
|
|
92
|
+
if (remote === null && ('check' in o || 'validate' in o)) throw Error('form: invalid compiled synchronous options')
|
|
93
|
+
if (!schema || Object.getPrototypeOf(schema) !== Object.prototype || Object.keys(schema).length > 4096) throw Error('form: invalid compiled schema')
|
|
94
|
+
return createForm(schema, o, (value, key) => {
|
|
95
|
+
if (['__proto__', 'prototype', 'constructor'].includes(key) || !Array.isArray(value) || value.length !== 2 || !types.includes(value[0]) || typeof value[1] !== 'boolean') throw Error('form: invalid compiled schema')
|
|
96
|
+
return { t: value[0], req: value[1], list: false, min: null, max: null }
|
|
97
|
+
}, validate, remote)
|
|
98
|
+
}
|
|
99
|
+
// Only public/dynamic or check-configured forms retain this optional capability.
|
|
100
|
+
function remoteCheck(o, x, checking) {
|
|
101
|
+
const c = o.check && o.check[x.path], v = untrack(() => x.s.v)
|
|
102
|
+
if (!c || untrack(() => x.e.v)) return Promise.resolve(untrack(() => x.e.v))
|
|
103
|
+
checking.v++
|
|
104
|
+
return Promise.resolve()
|
|
105
|
+
.then(() => c(v))
|
|
106
|
+
.then(m => m || '', e => (e && e.kind === 'aborted' ? '' : (e && e.message) || msg('form.check_failed', 'Could not check this value.')))
|
|
107
|
+
.then(m => {
|
|
108
|
+
if (untrack(() => x.s.v) === v) x.e.v = m
|
|
109
|
+
return untrack(() => x.e.v)
|
|
110
|
+
})
|
|
111
|
+
.finally(() => checking.v--)
|
|
112
|
+
}
|
|
113
|
+
function createForm(schema, o, readRule, validateRule, remote) {
|
|
65
114
|
const id = 'f' + ++seq, owner = I.owner() // aria effects are created later (in handlers) but live with the component
|
|
66
115
|
const busy = sig(false), error = sig(''), shown = sig(0) // shown: bumped when touched set changes
|
|
67
116
|
const checking = sig(0), saved = sig('') // async checks running · JSON of the last saved values
|
|
@@ -72,7 +121,7 @@ export function form(schema, o = {}) {
|
|
|
72
121
|
function build(sch, base, init) {
|
|
73
122
|
const v = {}, err = {}
|
|
74
123
|
for (const k of Object.keys(sch)) {
|
|
75
|
-
const r =
|
|
124
|
+
const r = readRule(sch[k], k), path = base ? base + '.' + k : k
|
|
76
125
|
if (r.t === 'obj') {
|
|
77
126
|
const sub = build(r.s, path, (init && init[k]) || {})
|
|
78
127
|
Object.defineProperty(v, k, { enumerable: true, value: sub.v })
|
|
@@ -115,23 +164,9 @@ export function form(schema, o = {}) {
|
|
|
115
164
|
const touch = x => {
|
|
116
165
|
validate(x)
|
|
117
166
|
if (!x.touched) (x.touched = true), shown.v++
|
|
118
|
-
remote(x)
|
|
119
|
-
}
|
|
120
|
-
// async check of one field (only when its own rule passes); resolves to its message
|
|
121
|
-
const remote = x => {
|
|
122
|
-
const c = o.check && o.check[x.path], v = untrack(() => x.s.v)
|
|
123
|
-
if (!c || untrack(() => x.e.v)) return Promise.resolve(untrack(() => x.e.v))
|
|
124
|
-
checking.v++
|
|
125
|
-
return Promise.resolve()
|
|
126
|
-
.then(() => c(v))
|
|
127
|
-
.then(m => m || '', e => (e && e.kind === 'aborted' ? '' : (e && e.message) || msg('form.check_failed', 'Could not check this value.')))
|
|
128
|
-
.then(m => {
|
|
129
|
-
if (untrack(() => x.s.v) === v) x.e.v = m // a stale answer (the value changed meanwhile) is ignored
|
|
130
|
-
return untrack(() => x.e.v)
|
|
131
|
-
})
|
|
132
|
-
.finally(() => checking.v--)
|
|
167
|
+
if (remote) remote(o, x, checking)
|
|
133
168
|
}
|
|
134
|
-
const validate = x => (x.e.v = untrack(() =>
|
|
169
|
+
const validate = x => (x.e.v = untrack(() => validateRule(x.r, x.s.v)))
|
|
135
170
|
const fid = p => `${id}-${p.replace(/\./g, '-')}-err`
|
|
136
171
|
|
|
137
172
|
// A form posted without JS (§38) comes back from the server with its values and messages: start from them, already
|
|
@@ -196,7 +231,7 @@ export function form(schema, o = {}) {
|
|
|
196
231
|
return checking.v > 0
|
|
197
232
|
},
|
|
198
233
|
get ok() {
|
|
199
|
-
for (const x of F) if (
|
|
234
|
+
for (const x of F) if (validateRule(x.r, x.s.v)) return false
|
|
200
235
|
return true
|
|
201
236
|
},
|
|
202
237
|
id: path => fid(path),
|
|
@@ -244,8 +279,8 @@ export function form(schema, o = {}) {
|
|
|
244
279
|
busy.v = true
|
|
245
280
|
if (fm) fm.setAttribute('aria-busy', 'true') // assistive tech hears the form is working
|
|
246
281
|
try {
|
|
247
|
-
if (o.check) {
|
|
248
|
-
await Promise.all(F.map(remote))
|
|
282
|
+
if (remote && o.check) {
|
|
283
|
+
await Promise.all(F.map(x => remote(o, x, checking)))
|
|
249
284
|
first = F.find(x => untrack(() => x.e.v))
|
|
250
285
|
if (first) return void (shown.v++, first.el && first.el.focus && first.el.focus())
|
|
251
286
|
}
|
package/brain-hash.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Web Crypto owns cryptography in browsers and Node. No hashing implementation is bundled.
|
|
2
|
+
export async function hashText(value, algorithm = 'SHA-256') {
|
|
3
|
+
if (!['SHA-256', 'SHA-384', 'SHA-512'].includes(algorithm)) throw RangeError('hashText: SHA-256, SHA-384 or SHA-512 required')
|
|
4
|
+
if (typeof value !== 'string') throw TypeError('hashText: text input required')
|
|
5
|
+
if (value.length > 8388608) throw RangeError('hashText: input exceeds 8 MiB')
|
|
6
|
+
const bytes = new TextEncoder().encode(value)
|
|
7
|
+
if (bytes.byteLength > 8388608) throw RangeError('hashText: UTF-8 input exceeds 8 MiB')
|
|
8
|
+
if (!globalThis.crypto?.subtle) throw Error('hashText: Web Crypto requires a secure browser context or Node 22+')
|
|
9
|
+
const digest = new Uint8Array(await globalThis.crypto.subtle.digest(algorithm, bytes))
|
|
10
|
+
let hex = ''
|
|
11
|
+
for (let i = 0; i < digest.length; i++) hex += digest[i].toString(16).padStart(2, '0')
|
|
12
|
+
return hex
|
|
13
|
+
}
|
|
14
|
+
export async function shortHash(value, length = 32) {
|
|
15
|
+
if (!Number.isInteger(length) || length < 8 || length > 64) throw RangeError('shortHash: length must be 8..64 hex characters')
|
|
16
|
+
return (await hashText(value)).slice(0, length)
|
|
17
|
+
}
|
package/brain-inspect.js
CHANGED
|
@@ -16,8 +16,8 @@ const now = () => Date.now()
|
|
|
16
16
|
const pnow = () => (G.performance ? performance.now() : now())
|
|
17
17
|
const syncHeap = () => (G.performance && performance.memory ? performance.memory.usedJSHeapSize : G.process && process.memoryUsage ? process.memoryUsage().heapUsed : null)
|
|
18
18
|
|
|
19
|
-
let out = [], seq = 0, opts = {}, started = false
|
|
20
|
-
const push = r => out.push(r)
|
|
19
|
+
let out = [], seq = 0, opts = {}, started = false, dropped = 0
|
|
20
|
+
const push = r => { if (out.length >= 2000) { out.shift(); dropped++ } out.push(r) }
|
|
21
21
|
|
|
22
22
|
// ---------- instance identity and current owner ----------
|
|
23
23
|
const ids = new WeakMap()
|
|
@@ -612,6 +612,8 @@ export function tick() {
|
|
|
612
612
|
export function drain() {
|
|
613
613
|
if (!out.length) return null
|
|
614
614
|
const m = { t: 'm', v: 2, seq: seq++, at: now(), d: out }
|
|
615
|
+
if (dropped) m.dropped = dropped
|
|
616
|
+
dropped = 0
|
|
615
617
|
out = []
|
|
616
618
|
if (opts.send) opts.send(JSON.stringify(m))
|
|
617
619
|
return m
|
|
@@ -629,7 +631,7 @@ export function inspect(o) {
|
|
|
629
631
|
else i = instOf(x)
|
|
630
632
|
if (!i) return null
|
|
631
633
|
const chain = []
|
|
632
|
-
for (let j = i; j; j = instOf(j.p)) chain.push(nameOf(j))
|
|
634
|
+
for (let j = i; j && chain.length < 33; j = instOf(j.p)) chain.push(nameOf(j))
|
|
633
635
|
return { id: idOf(i), h: i.h, name: nameOf(i), file: (i.C && (i.C.f || (i.C.C && i.C.C.f))) || null, chain }
|
|
634
636
|
},
|
|
635
637
|
sigName,
|
|
@@ -643,7 +645,7 @@ export function inspect(o) {
|
|
|
643
645
|
if (done) done.add(key)
|
|
644
646
|
push({ k: 'chan', kind, name, role, id: i ? idOf(i) : null, h: i ? i.h : null, comp: i ? nameOf(i) : null })
|
|
645
647
|
})
|
|
646
|
-
sub.add(r => push({ k: 'err', fp: r.fp, code: r.code, msg: r.msg, where: r.where, comp: r.comp, line: r.line, src: r.src || null, stack: r.brainStack || r.stack, crumbs: r.crumbs, n: r.n, at: r.last }))
|
|
648
|
+
sub.add(r => push({ k: 'err', fp: r.fp, code: r.code, msg: r.msg, where: r.where, comp: r.comp, line: r.line, src: r.src || null, stack: r.brainStack || r.stack, brainStack: r.brainStack || null, location: r.location, generatedLocation: r.generatedLocation, frames: r.frames, causes: r.causes, truncated: r.truncated, mapping: r.mapping, crumbs: r.crumbs, n: r.n, at: r.last }))
|
|
647
649
|
patchTimers()
|
|
648
650
|
patchGlobalListeners()
|
|
649
651
|
patchHandlers()
|
package/brain-interop.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Explicit foreign-DOM ownership. The adapter, SDK and any React runtime belong to the app.
|
|
2
|
+
import { _rt, fx, untrack } from './brain.js'
|
|
3
|
+
|
|
4
|
+
const I = _rt()
|
|
5
|
+
|
|
6
|
+
// Call during component setup. Resolve the bound element only after insertion.
|
|
7
|
+
// mount must synchronously return a cleanup function or { update?, destroy }.
|
|
8
|
+
export function interop(element, adapter, props = {}, options = {}) {
|
|
9
|
+
const owner = I.owner()
|
|
10
|
+
if (!owner) throw Error('interop: call during component setup')
|
|
11
|
+
let alive = true, instance = null, pending = props, ready = false
|
|
12
|
+
const report = (error, phase) => {
|
|
13
|
+
if (typeof options.onError === 'function') options.onError(error, phase)
|
|
14
|
+
else I.oops(error, owner, 'interop:' + phase)
|
|
15
|
+
}
|
|
16
|
+
const destroy = () => {
|
|
17
|
+
if (!alive) return
|
|
18
|
+
alive = false
|
|
19
|
+
const old = instance
|
|
20
|
+
instance = null
|
|
21
|
+
if (old) try { old.destroy() } catch (error) { report(error, 'destroy') }
|
|
22
|
+
}
|
|
23
|
+
const controller = {
|
|
24
|
+
update(next) {
|
|
25
|
+
if (!alive) throw Error('interop: boundary has been destroyed')
|
|
26
|
+
pending = next
|
|
27
|
+
if (!ready) return
|
|
28
|
+
if (!instance || typeof instance.update !== 'function') throw Error('interop: adapter does not support updates')
|
|
29
|
+
try { untrack(() => instance.update(next)) } catch (error) { report(error, 'update') }
|
|
30
|
+
},
|
|
31
|
+
destroy,
|
|
32
|
+
}
|
|
33
|
+
// No SDK execution during SSR or prerender. Disposal still invalidates this controller.
|
|
34
|
+
fx(() => destroy)
|
|
35
|
+
if (globalThis.__brainSSR || globalThis.__brainRoute) return controller
|
|
36
|
+
queueMicrotask(() => {
|
|
37
|
+
if (!alive) return
|
|
38
|
+
try {
|
|
39
|
+
const el = typeof element === 'function' ? untrack(element) : element
|
|
40
|
+
if (!el || el.nodeType !== 1) throw Error('interop: expected a bound DOM element')
|
|
41
|
+
if (!adapter || typeof adapter.mount !== 'function') throw Error('interop: adapter.mount is required')
|
|
42
|
+
const result = untrack(() => adapter.mount(el, pending))
|
|
43
|
+
if (typeof result === 'function') instance = { destroy: result }
|
|
44
|
+
else if (result && typeof result.destroy === 'function' && typeof result.then !== 'function') instance = result
|
|
45
|
+
else throw Error('interop: mount must return cleanup or { update, destroy }; asynchronous mounts are unsupported')
|
|
46
|
+
ready = true
|
|
47
|
+
} catch (error) { report(error, 'mount') }
|
|
48
|
+
})
|
|
49
|
+
return controller
|
|
50
|
+
}
|
package/brain-live.js
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
// Native, same-origin invalidation metadata. HTTP resource reads remain authoritative.
|
|
2
|
+
import { sig, fx, untrack, _rt } from './brain.js'
|
|
3
|
+
|
|
4
|
+
const G = globalThis, I = _rt()
|
|
5
|
+
const MAX_OWNERS = 128, MAX_ROOMS = 32, FRAME = 4096, BUFFER = 32768
|
|
6
|
+
const COALESCE = 100, READY_MS = 8000, REFRESH_MS = 10000, RETRY_MS = 60000
|
|
7
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
8
|
+
const own = (value, key) => Object.prototype.hasOwnProperty.call(value, key)
|
|
9
|
+
const RESERVED = new Set(['constructor', 'prototype', '__proto__', 'id', 'owner', 'created_at', 'updated_at', 'search'])
|
|
10
|
+
let broker
|
|
11
|
+
const fault = fix => Object.assign(Error('B0918: ' + fix), { code: 'B0918', fix })
|
|
12
|
+
const fail = fix => { throw fault(fix) }
|
|
13
|
+
const exact = (value, keys) => value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).every(key => keys.includes(key))
|
|
14
|
+
const resourceName = name => typeof name === 'string' && /^[a-z][a-z0-9_]{0,47}$/.test(name) && !RESERVED.has(name)
|
|
15
|
+
|
|
16
|
+
function roomsOf(value) {
|
|
17
|
+
if (!Array.isArray(value) || value.length > MAX_ROOMS) fail('Use at most 32 native resource rooms')
|
|
18
|
+
const rooms = new Map()
|
|
19
|
+
for (const room of value) {
|
|
20
|
+
let command, key
|
|
21
|
+
const id = room && typeof room === 'object' && own(room, 'id') ? room.id : undefined
|
|
22
|
+
if (exact(room, ['room']) && own(room, 'room') && room.room === 'user:self') {
|
|
23
|
+
command = { room: 'user:self' }; key = 'user:self'
|
|
24
|
+
} else if (exact(room, ['resource', 'id']) && own(room, 'resource') && resourceName(room.resource) && (id === undefined || (typeof id === 'string' && UUID.test(id)))) {
|
|
25
|
+
command = id === undefined ? { resource: room.resource } : { resource: room.resource, id: id.toLowerCase() }
|
|
26
|
+
key = command.resource + ':' + (command.id || '')
|
|
27
|
+
} else fail('Use a declared resource, an optional UUID, or the user:self room')
|
|
28
|
+
rooms.set(key, Object.freeze(command))
|
|
29
|
+
}
|
|
30
|
+
return rooms
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function endpoint() {
|
|
34
|
+
const location = G.location
|
|
35
|
+
if (!location || !/^https?:$/.test(location.protocol)) fail('Serve live resources from an HTTP(S) application origin')
|
|
36
|
+
const meta = G.document && document.querySelector('meta[name=brain-base]')
|
|
37
|
+
const base = meta && meta.getAttribute('content') || ''
|
|
38
|
+
if (typeof base !== 'string' || base.length > 512 || (base && (!base.startsWith('/') || base.startsWith('//') || /[\\?#\x00-\x20\x7f]/.test(base)))) fail('Use a normalized same-origin application base path')
|
|
39
|
+
const origin = new URL(location.origin)
|
|
40
|
+
if (origin.origin !== location.origin || origin.username || origin.password) fail('Use a canonical application origin')
|
|
41
|
+
const normalized = base.replace(/\/$/, '')
|
|
42
|
+
const url = new URL(normalized + '/_brain/socket', origin)
|
|
43
|
+
if (url.origin !== origin.origin || url.pathname !== normalized + '/_brain/socket') fail('Use a normalized same-origin application base path')
|
|
44
|
+
url.protocol = origin.protocol === 'https:' ? 'wss:' : 'ws:'
|
|
45
|
+
return url.href
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function metadata(text) {
|
|
49
|
+
if (typeof text !== 'string' || text.length > FRAME || /[^\x09\x0a\x0d\x20-\x7e]/.test(text)) return null
|
|
50
|
+
let value
|
|
51
|
+
try {
|
|
52
|
+
value = JSON.parse(text, (key, item) => {
|
|
53
|
+
if (key === '__proto__' || key === 'constructor' || key === 'prototype') throw Error('Unsafe metadata')
|
|
54
|
+
return item
|
|
55
|
+
})
|
|
56
|
+
} catch { return null }
|
|
57
|
+
if (exact(value, ['type', 'protocol', 'durable', 'resyncRequired']) && value.type === 'ready' && value.protocol === 1 && value.durable === false && value.resyncRequired === true) return Object.freeze(value)
|
|
58
|
+
if (exact(value, ['type', 'reason']) && value.type === 'resync' && value.reason === 'joined') return Object.freeze(value)
|
|
59
|
+
if (exact(value, ['type']) && value.type === 'pong') return Object.freeze(value)
|
|
60
|
+
if (exact(value, ['type', 'code']) && value.type === 'error' && value.code === 'ROOM_FORBIDDEN') return Object.freeze(value)
|
|
61
|
+
if (exact(value, ['type', 'resource', 'id', 'operation', 'eventId']) && value.type === 'invalidate' && resourceName(value.resource) && typeof value.id === 'string' && UUID.test(value.id) && typeof value.eventId === 'string' && UUID.test(value.eventId) && ['create', 'update', 'delete'].includes(value.operation)) return Object.freeze(value)
|
|
62
|
+
return null
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function createBroker() {
|
|
66
|
+
const members = new Set(), joined = new Map(), waiting = [], events = new Map()
|
|
67
|
+
let socket = null, epoch = 0, scope = null, terminal = '', ready = false
|
|
68
|
+
let reconcileQueued = false, retryTimer, readyTimer, heartbeat, pongTimer, flushTimer
|
|
69
|
+
let attempts = 0, retryStart = 0, lastConnect = 0
|
|
70
|
+
|
|
71
|
+
const update = () => {
|
|
72
|
+
for (const member of members) {
|
|
73
|
+
const active = [...member.rooms.keys()].some(key => joined.get(key) === 'ready')
|
|
74
|
+
const forbidden = member.rooms.size > 0 && [...member.rooms.keys()].every(key => joined.get(key) === 'forbidden')
|
|
75
|
+
member.up.v = !!(socket && ready && member.scope && member.scope === scope && active)
|
|
76
|
+
member.state.v = !member.scope || !member.rooms.size ? 'idle' : terminal || (member.refreshState || (forbidden ? 'forbidden' : member.up.v ? 'open' : retryTimer ? 'retrying' : 'connecting'))
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const clearMember = member => {
|
|
80
|
+
member.generation++
|
|
81
|
+
member.dirty = false
|
|
82
|
+
member.pending = member.resync = null
|
|
83
|
+
member.refreshState = member.flight ? 'refresh-pending' : ''
|
|
84
|
+
if (member.flight) {
|
|
85
|
+
const flight = member.flight
|
|
86
|
+
clearTimeout(flight.deadline); clearTimeout(flight.tick)
|
|
87
|
+
flight.deadline = flight.tick = null
|
|
88
|
+
if (flight.resolveTick) { flight.resolveTick(); flight.resolveTick = null }
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const stop = () => {
|
|
92
|
+
epoch++; ready = false
|
|
93
|
+
clearTimeout(retryTimer); retryTimer = null
|
|
94
|
+
clearTimeout(readyTimer); clearInterval(heartbeat); clearTimeout(pongTimer)
|
|
95
|
+
readyTimer = heartbeat = pongTimer = null
|
|
96
|
+
clearTimeout(flushTimer); flushTimer = null
|
|
97
|
+
joined.clear(); waiting.length = 0; events.clear(); currentRooms.clear()
|
|
98
|
+
const previous = socket; socket = null
|
|
99
|
+
if (previous) {
|
|
100
|
+
previous.onopen = previous.onmessage = previous.onclose = previous.onerror = null
|
|
101
|
+
try { previous.close(1000, 'Live resource lifecycle ended') } catch {}
|
|
102
|
+
}
|
|
103
|
+
for (const member of members) { clearMember(member); member.up.v = false }
|
|
104
|
+
}
|
|
105
|
+
const reject = state => { stop(); terminal = state; update() }
|
|
106
|
+
const send = value => {
|
|
107
|
+
if (!socket || socket.readyState !== 1 || socket.bufferedAmount > BUFFER) return false
|
|
108
|
+
const text = JSON.stringify(value)
|
|
109
|
+
if (text.length > FRAME) return false
|
|
110
|
+
try { socket.send(text); return true } catch { return false }
|
|
111
|
+
}
|
|
112
|
+
const refresh = member => {
|
|
113
|
+
if (!members.has(member) || !member.up.v || !member.dirty || member.flight) return
|
|
114
|
+
member.dirty = false; member.refreshState = ''; update()
|
|
115
|
+
const pending = member.pending, resync = member.resync
|
|
116
|
+
member.pending = member.resync = null
|
|
117
|
+
const flight = { generation: member.generation, epoch, deadline: null, tick: null, resolveTick: null }
|
|
118
|
+
member.flight = flight
|
|
119
|
+
flight.deadline = setTimeout(timeoutFlight.bind(null, member.cell, flight), REFRESH_MS)
|
|
120
|
+
try {
|
|
121
|
+
if (resync && own(member.options, 'onResync') && member.options.onResync) observeMetadata(member.options.onResync, resync)
|
|
122
|
+
if (pending && own(member.options, 'onInvalidate') && member.options.onInvalidate) observeMetadata(member.options.onInvalidate, pending)
|
|
123
|
+
untrack(() => member.node.refetch())
|
|
124
|
+
// Equivalent post-effect tick, with an owned cancellation handle. The
|
|
125
|
+
// standard tick() intentionally exposes no handle for owner cancellation.
|
|
126
|
+
const next = new Promise(resolve => {
|
|
127
|
+
flight.resolveTick = resolve
|
|
128
|
+
flight.tick = setTimeout(resolve, 0)
|
|
129
|
+
})
|
|
130
|
+
next.then(readResource.bind(null, member.cell, flight)).then(
|
|
131
|
+
settleFlight.bind(null, member.cell, flight, false),
|
|
132
|
+
settleFlight.bind(null, member.cell, flight, true),
|
|
133
|
+
)
|
|
134
|
+
} catch {
|
|
135
|
+
I.oops(fault('Keep live metadata observers synchronous and handle resource failures in the view'), member.owner, 'B0918')
|
|
136
|
+
dispose(member.cell)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const valid = (member, flight) => members.has(member) && member.generation === flight.generation && epoch === flight.epoch
|
|
140
|
+
function complete(member, flight, failed) {
|
|
141
|
+
if (member.flight !== flight) return
|
|
142
|
+
clearTimeout(flight.deadline); clearTimeout(flight.tick)
|
|
143
|
+
flight.deadline = flight.tick = flight.resolveTick = null
|
|
144
|
+
member.flight = null; member.refreshState = ''
|
|
145
|
+
if (valid(member, flight) && failed) member.refreshState = 'refresh-error'
|
|
146
|
+
if (members.has(member) && member.dirty) schedule()
|
|
147
|
+
if (members.has(member)) update()
|
|
148
|
+
}
|
|
149
|
+
function schedule() {
|
|
150
|
+
if (!flushTimer) flushTimer = setTimeout(() => {
|
|
151
|
+
flushTimer = null
|
|
152
|
+
for (const member of members) refresh(member)
|
|
153
|
+
}, COALESCE)
|
|
154
|
+
}
|
|
155
|
+
const invalidate = (value, roomKey) => {
|
|
156
|
+
for (const member of members) {
|
|
157
|
+
if (!member.scope || member.scope !== scope) continue
|
|
158
|
+
const interested = roomKey ? member.rooms.has(roomKey) : [...member.rooms].some(([key, room]) => joined.get(key) === 'ready' && (room.room === 'user:self' || (room.resource === value.resource && (!room.id || room.id === value.id))))
|
|
159
|
+
if (interested) { member.dirty = true; if (value.type === 'resync') member.resync = value; else member.pending = value; schedule() }
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const retry = () => {
|
|
163
|
+
stop()
|
|
164
|
+
if (++attempts > 5 || (retryStart && Date.now() - retryStart >= RETRY_MS)) { terminal = 'exhausted'; update(); return }
|
|
165
|
+
if (!retryStart) retryStart = Date.now()
|
|
166
|
+
const delay = Math.floor(Math.min(8000, 250 * 2 ** (attempts - 1)) * (0.75 + Math.random() * 0.25))
|
|
167
|
+
retryTimer = setTimeout(() => { retryTimer = null; reconcile() }, delay)
|
|
168
|
+
update()
|
|
169
|
+
}
|
|
170
|
+
const desired = () => {
|
|
171
|
+
const rooms = new Map(), scopes = new Set()
|
|
172
|
+
for (const member of members) if (member.scope && member.rooms.size) {
|
|
173
|
+
scopes.add(member.scope)
|
|
174
|
+
for (const [key, value] of member.rooms) rooms.set(key, value)
|
|
175
|
+
}
|
|
176
|
+
return { rooms, scopes }
|
|
177
|
+
}
|
|
178
|
+
const syncRooms = rooms => {
|
|
179
|
+
const changed = [...rooms.keys()].some(key => !joined.has(key)) || [...joined.keys()].some(key => !rooms.has(key))
|
|
180
|
+
if (waiting.length && changed) { retry(); return }
|
|
181
|
+
for (const [key] of joined) if (!rooms.has(key)) {
|
|
182
|
+
const room = currentRooms.get(key)
|
|
183
|
+
if (!room || !send({ type: 'leave', ...room })) { retry(); return }
|
|
184
|
+
joined.delete(key)
|
|
185
|
+
}
|
|
186
|
+
currentRooms = rooms
|
|
187
|
+
for (const [key, room] of rooms) if (!joined.has(key)) {
|
|
188
|
+
if (waiting.length >= MAX_ROOMS || !send({ type: 'join', ...room })) { retry(); return }
|
|
189
|
+
joined.set(key, 'pending'); waiting.push(key)
|
|
190
|
+
}
|
|
191
|
+
update()
|
|
192
|
+
}
|
|
193
|
+
let currentRooms = new Map()
|
|
194
|
+
const connect = () => {
|
|
195
|
+
const captured = ++epoch
|
|
196
|
+
lastConnect = Date.now()
|
|
197
|
+
let address
|
|
198
|
+
try { address = endpoint() } catch { reject('invalid-config'); return }
|
|
199
|
+
try { socket = new G.WebSocket(address, 'brain.v1') } catch { retry(); return }
|
|
200
|
+
readyTimer = setTimeout(() => { if (epoch === captured) retry() }, READY_MS)
|
|
201
|
+
socket.onopen = () => {
|
|
202
|
+
if (epoch !== captured) return
|
|
203
|
+
if (socket.protocol !== 'brain.v1') reject('protocol-error')
|
|
204
|
+
}
|
|
205
|
+
socket.onerror = () => { /* close or the ready deadline owns retries; arbitrary browser errors are not retained. */ }
|
|
206
|
+
socket.onclose = event => {
|
|
207
|
+
if (epoch !== captured) return
|
|
208
|
+
if (event.code === 1008) reject(event.reason === 'Authentication expired' ? 'auth-required' : 'policy-closed')
|
|
209
|
+
else retry()
|
|
210
|
+
}
|
|
211
|
+
socket.onmessage = event => {
|
|
212
|
+
if (epoch !== captured) return
|
|
213
|
+
const value = metadata(event.data)
|
|
214
|
+
if (!value) { reject('protocol-error'); return }
|
|
215
|
+
if (value.type === 'ready') {
|
|
216
|
+
if (ready) { reject('protocol-error'); return }
|
|
217
|
+
clearTimeout(readyTimer); readyTimer = null; ready = true
|
|
218
|
+
syncRooms(desired().rooms)
|
|
219
|
+
heartbeat = setInterval(() => {
|
|
220
|
+
if (!send({ type: 'ping' })) { retry(); return }
|
|
221
|
+
clearTimeout(pongTimer)
|
|
222
|
+
pongTimer = setTimeout(() => { if (epoch === captured) retry() }, 12000)
|
|
223
|
+
}, 25000)
|
|
224
|
+
} else if (!ready) reject('protocol-error')
|
|
225
|
+
else if (value.type === 'pong') { clearTimeout(pongTimer); pongTimer = null }
|
|
226
|
+
else if (value.type === 'resync' || value.type === 'error') {
|
|
227
|
+
const key = waiting.shift()
|
|
228
|
+
if (!key) { reject('protocol-error'); return }
|
|
229
|
+
joined.set(key, value.type === 'resync' ? 'ready' : 'forbidden')
|
|
230
|
+
if (value.type === 'resync') { attempts = 0; retryStart = 0 }
|
|
231
|
+
update()
|
|
232
|
+
if (value.type === 'resync') invalidate(value, key)
|
|
233
|
+
} else {
|
|
234
|
+
const now = Date.now()
|
|
235
|
+
for (const [id, time] of events) if (now - time > 60000) events.delete(id)
|
|
236
|
+
if (events.has(value.eventId)) return
|
|
237
|
+
events.set(value.eventId, now)
|
|
238
|
+
while (events.size > 128) events.delete(events.keys().next().value)
|
|
239
|
+
invalidate(value)
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
update()
|
|
243
|
+
}
|
|
244
|
+
function reconcile() {
|
|
245
|
+
reconcileQueued = false
|
|
246
|
+
const { rooms, scopes } = desired()
|
|
247
|
+
if (!rooms.size) { stop(); scope = null; terminal = ''; attempts = 0; retryStart = 0; update(); return }
|
|
248
|
+
if (scopes.size !== 1) { reject('scope-conflict'); return }
|
|
249
|
+
const next = scopes.values().next().value
|
|
250
|
+
if (next !== scope || terminal === 'scope-conflict') { stop(); scope = next; terminal = ''; attempts = 0; retryStart = 0 }
|
|
251
|
+
if (rooms.size > MAX_ROOMS) { reject('room-limit'); return }
|
|
252
|
+
if (terminal || retryTimer) { update(); return }
|
|
253
|
+
if (socket && ready) syncRooms(rooms)
|
|
254
|
+
else if (!socket) {
|
|
255
|
+
const elapsed = Date.now() - lastConnect
|
|
256
|
+
const delay = elapsed < 0 ? 0 : Math.max(0, 250 - elapsed)
|
|
257
|
+
if (delay) retryTimer = setTimeout(() => { retryTimer = null; reconcile() }, delay)
|
|
258
|
+
else connect()
|
|
259
|
+
}
|
|
260
|
+
update()
|
|
261
|
+
}
|
|
262
|
+
const queue = () => { if (!reconcileQueued) { reconcileQueued = true; queueMicrotask(reconcile) } }
|
|
263
|
+
return {
|
|
264
|
+
valid, complete,
|
|
265
|
+
stats() { return { owners: members.size, rooms: joined.size, pendingJoins: waiting.length, dedupe: events.size, connection: socket ? 1 : 0, timers: [retryTimer, readyTimer, heartbeat, pongTimer, flushTimer].filter(Boolean).length + [...members].filter(member => member.flight && member.flight.deadline).length, refreshes: [...members].filter(member => member.flight).length, dirty: [...members].filter(member => member.dirty).length } },
|
|
266
|
+
add(member) {
|
|
267
|
+
if (!members.has(member) && members.size >= MAX_OWNERS) fail('Close unused live resource owners before adding another')
|
|
268
|
+
members.add(member)
|
|
269
|
+
if (scope && member.scope && member.scope !== scope) stop()
|
|
270
|
+
queue()
|
|
271
|
+
},
|
|
272
|
+
remove(member) {
|
|
273
|
+
members.delete(member); clearMember(member); member.up.v = false
|
|
274
|
+
if (!members.size) { stop(); scope = null; terminal = ''; attempts = 0; retryStart = 0 }
|
|
275
|
+
else queue()
|
|
276
|
+
},
|
|
277
|
+
reconnect(member) {
|
|
278
|
+
if (!members.has(member)) return
|
|
279
|
+
stop(); terminal = ''; attempts = 0; retryStart = 0; queue()
|
|
280
|
+
},
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function observeMetadata(callback, value) {
|
|
285
|
+
const result = untrack(() => callback(value))
|
|
286
|
+
if (result && typeof result.then === 'function') {
|
|
287
|
+
// Observe rejection without retaining an owner or the user-provided error.
|
|
288
|
+
Promise.resolve(result).catch(() => {})
|
|
289
|
+
fail('Use synchronous metadata observers; resource owns asynchronous fetching')
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Promise reactions retain only a nullable cell and scalar flight metadata. An
|
|
294
|
+
// externally retained, never-settling Promise cannot keep a disposed resource alive.
|
|
295
|
+
function readResource(cell, flight) {
|
|
296
|
+
flight.tick = flight.resolveTick = null
|
|
297
|
+
const member = cell.record
|
|
298
|
+
if (member && broker.valid(member, flight)) return member.node.v
|
|
299
|
+
}
|
|
300
|
+
function settleFlight(cell, flight, failed) {
|
|
301
|
+
const member = cell.record
|
|
302
|
+
if (member) broker.complete(member, flight, failed)
|
|
303
|
+
}
|
|
304
|
+
function timeoutFlight(cell, flight) {
|
|
305
|
+
const member = cell.record
|
|
306
|
+
if (member && broker.valid(member, flight)) {
|
|
307
|
+
// Never release a pending fetch slot merely because its deadline elapsed.
|
|
308
|
+
member.refreshState = 'refresh-timeout'
|
|
309
|
+
member.state.v = 'refresh-timeout'
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
function detach(cell) {
|
|
313
|
+
const member = cell.record
|
|
314
|
+
if (member && broker) broker.remove(member)
|
|
315
|
+
}
|
|
316
|
+
function dispose(cell) {
|
|
317
|
+
const member = cell.record
|
|
318
|
+
if (!member) return
|
|
319
|
+
cell.record = null
|
|
320
|
+
if (member.stopWatch) member.stopWatch()
|
|
321
|
+
if (broker) broker.remove(member)
|
|
322
|
+
member.node = member.options = member.owner = member.stopWatch = null
|
|
323
|
+
member.scope = null; member.rooms.clear(); member.flight = null
|
|
324
|
+
member.state.v = 'closed'
|
|
325
|
+
}
|
|
326
|
+
function observe(cell) {
|
|
327
|
+
const member = cell.record
|
|
328
|
+
if (!member) return
|
|
329
|
+
const scope = member.options.scope()
|
|
330
|
+
if (scope !== null && (typeof scope !== 'string' || !scope.length || scope.length > 160 || /[\x00-\x20\x7f]/.test(scope))) fail('Use null or a bounded public user/revision string as scope; never credentials')
|
|
331
|
+
member.scope = scope
|
|
332
|
+
member.rooms = roomsOf(typeof member.options.rooms === 'function' ? member.options.rooms() : member.options.rooms)
|
|
333
|
+
broker = broker || createBroker()
|
|
334
|
+
broker.add(member)
|
|
335
|
+
return detach.bind(null, cell)
|
|
336
|
+
}
|
|
337
|
+
function ownerCleanup(cell) { return dispose.bind(null, cell) }
|
|
338
|
+
function reconnect(cell) { if (cell.record && broker) broker.reconnect(cell.record) }
|
|
339
|
+
|
|
340
|
+
// Internal, metadata-only capacity evidence; never scope identifiers, rooms or values.
|
|
341
|
+
export function _liveStats() {
|
|
342
|
+
return broker ? broker.stats() : { owners: 0, rooms: 0, pendingJoins: 0, dedupe: 0, connection: 0, timers: 0, refreshes: 0, dirty: 0 }
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export function liveResource(node, options = {}) {
|
|
346
|
+
if (!node || typeof node !== 'object' || typeof node.refetch !== 'function' || !('v' in node) || !exact(options, ['rooms', 'scope', 'onInvalidate', 'onResync']) || !own(options, 'rooms') || !own(options, 'scope') || typeof options.scope !== 'function') fail('Use liveResource(rows, {rooms, scope: () => publicUserRevision}) with a resource handle')
|
|
347
|
+
if (own(options, 'onInvalidate') && options.onInvalidate !== undefined && typeof options.onInvalidate !== 'function') fail('Use a metadata callback for onInvalidate')
|
|
348
|
+
if (own(options, 'onResync') && options.onResync !== undefined && typeof options.onResync !== 'function') fail('Use a metadata callback for onResync')
|
|
349
|
+
const state = sig('idle'), up = sig(false), cell = { record: null }
|
|
350
|
+
const out = {
|
|
351
|
+
get connected() { return up.v },
|
|
352
|
+
get state() { return state.v },
|
|
353
|
+
close() { dispose(cell); up.v = false; state.v = 'closed' },
|
|
354
|
+
reconnect: reconnect.bind(null, cell),
|
|
355
|
+
}
|
|
356
|
+
if (G.__brainSSR || G.__brainRoute) return out
|
|
357
|
+
if (typeof G.WebSocket !== 'function') { state.v = 'unsupported'; return out }
|
|
358
|
+
const owner = I.owner()
|
|
359
|
+
if (!owner) fail('Create liveResource inside a component so unmount closes its subscription')
|
|
360
|
+
cell.record = { cell, node, options, state, up, owner, scope: null, rooms: new Map(), generation: 0, flight: null, dirty: false, pending: null, resync: null, refreshState: '', stopWatch: null }
|
|
361
|
+
fx(ownerCleanup.bind(null, cell))
|
|
362
|
+
cell.record.stopWatch = fx(observe.bind(null, cell))
|
|
363
|
+
return out
|
|
364
|
+
}
|
package/brain-overlay.js
CHANGED
|
@@ -24,10 +24,21 @@ export function forLLM(d) {
|
|
|
24
24
|
})
|
|
25
25
|
if (d.fix) out.push(`fix: ${d.fix}`)
|
|
26
26
|
if (d.brainStack) out.push('stack:', ...[].concat(d.brainStack))
|
|
27
|
+
if (d.location || d.frames || d.causes || d.rid) out.push('structured trace (development only):', JSON.stringify(traceOf(d)))
|
|
28
|
+
if (d.mapping?.status && d.mapping.status !== 'mapped') out.push('Location mapping is incomplete; do not treat generated coordinates as original source coordinates.')
|
|
29
|
+
out.push('Agent next steps: inspect the exact source revision, apply a focused change, run brain check --json, then reproduce the failing action. Record evidence with brain history and refresh brain quality.')
|
|
27
30
|
if (d.file) out.push('brain check --json:', JSON.stringify((byFile.get(d.file) || [d]).map(({ snippet, ...x }) => x)))
|
|
28
31
|
return out.join('\n')
|
|
29
32
|
}
|
|
30
33
|
|
|
34
|
+
function traceOf(d) {
|
|
35
|
+
return { schemaVersion: 1, requestId: d.rid || d.requestId || null, component: d.comp || null,
|
|
36
|
+
location: d.location || null, generatedLocation: d.generatedLocation || null,
|
|
37
|
+
mapping: d.mapping || null, truncated: d.truncated || null,
|
|
38
|
+
frames: Array.isArray(d.frames) ? d.frames.slice(0, 24) : [],
|
|
39
|
+
causes: Array.isArray(d.causes) ? d.causes.slice(0, 6) : [] }
|
|
40
|
+
}
|
|
41
|
+
|
|
31
42
|
function item(d, i) {
|
|
32
43
|
const loc = d.file ? `${d.file}:${d.line || ''}${d.col ? ':' + d.col : ''}` : d.src || ''
|
|
33
44
|
const snip = d.snippet && d.snippet.text
|
|
@@ -35,6 +46,7 @@ function item(d, i) {
|
|
|
35
46
|
: ''
|
|
36
47
|
return `<li class="${d.level === 'warning' || d.level === 'info' ? d.level : 'error'}"><p class="t"><b>${esc(d.code || 'Error')}</b> <span>${esc(loc)}</span></p>` +
|
|
37
48
|
`<p>${esc(d.msg)}</p>${snip}${d.fix ? `<p class="fix">fix: ${esc(d.fix)}</p>` : ''}` +
|
|
49
|
+
`${d.location || d.frames || d.causes ? `<details><summary>Development trace and mapping</summary><pre>${esc(JSON.stringify(traceOf(d), null, 2))}</pre></details>` : ''}` +
|
|
38
50
|
`<button type="button" data-copy="${i}">Copy for the LLM</button></li>`
|
|
39
51
|
}
|
|
40
52
|
|