@christianirack/brainllm-runtime 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/brain-ai.js +15 -7
- package/brain-analytics.js +401 -0
- package/brain-budget.js +46 -25
- package/brain-ch.js +35 -8
- package/brain-data.js +4 -1
- package/brain-env.js +5 -4
- package/brain-guard.js +6 -6
- package/brain-live.js +367 -0
- package/brain-mcp.js +87 -18
- package/brain-outbound.js +244 -0
- package/brain-router.js +8 -12
- package/brain-update.js +37 -0
- package/brain-url.js +7 -5
- package/brain.js +6 -3
- package/package.json +1 -1
- package/server/native-sockets.mjs +156 -0
- package/server/server.mjs +319 -85
- package/sw/brain-sw.js +51 -2
- package/ui/error-state.brain +26 -13
- package/ui/tabs.brain +4 -3
package/brain-ch.js
CHANGED
|
@@ -60,10 +60,10 @@ const safe = (k, v) => (k === '__proto__' || k === 'constructor' || k === 'proto
|
|
|
60
60
|
// global instance) and inside `provide(K, store(…))` (one instance per subtree, §41.2). sync: 'tab' (BroadcastChannel) | 'storage'
|
|
61
61
|
// (localStorage + storage event) | 'ws' (WebSocket at `url`, see ws() below). `id` is set by the codegen (channel
|
|
62
62
|
// path); if missing, it is derived from init.
|
|
63
|
-
|
|
64
|
-
|
|
63
|
+
// Shared signal/descriptor construction. Compiler-selected plain stores retain the
|
|
64
|
+
// same reset, serialization and inspector contracts without reaching transport code.
|
|
65
|
+
function storeState(init, name, changed) {
|
|
65
66
|
const S = {}, s = {}
|
|
66
|
-
let last = '' // JSON of the last state sent or received: cuts the echo between tabs
|
|
67
67
|
for (const k of Object.keys(init)) {
|
|
68
68
|
S[k] = sig(init[k], name + '.' + k) // named: dev tools (brain_why, HMR) see `cart.total`
|
|
69
69
|
Object.defineProperty(s, k, {
|
|
@@ -80,7 +80,8 @@ export function store(init, o = {}, id) {
|
|
|
80
80
|
const apply = d => {
|
|
81
81
|
const n = snap()
|
|
82
82
|
for (const k in d) if (k in S) n[k] = d[k]
|
|
83
|
-
|
|
83
|
+
const encoded = JSON.stringify(n) // Preserve serialization errors and toJSON effects, even without sync.
|
|
84
|
+
if (changed) changed(encoded) // Before the batch: a sync effect must see the new echo marker.
|
|
84
85
|
batch(() => {
|
|
85
86
|
for (const k in n) S[k].v = n[k]
|
|
86
87
|
})
|
|
@@ -90,6 +91,19 @@ export function store(init, o = {}, id) {
|
|
|
90
91
|
$snap: { value: snap },
|
|
91
92
|
$reset: { value: () => apply({ ...init }) }, // back to the initial state (persisted and synced like any write)
|
|
92
93
|
})
|
|
94
|
+
return { S, s, snap, apply }
|
|
95
|
+
}
|
|
96
|
+
// Compiler-only: public store() still accepts all options and retains its full fallback.
|
|
97
|
+
export function plainStore(init, id) {
|
|
98
|
+
const name = id || 'store:' + Object.keys(init).join(',')
|
|
99
|
+
const state = storeState(init, name, null)
|
|
100
|
+
JSON.stringify(state.snap()) // The public plain path performs this validation too.
|
|
101
|
+
return state.s
|
|
102
|
+
}
|
|
103
|
+
export function store(init, o = {}, id) {
|
|
104
|
+
const name = id || o.name || 'store:' + Object.keys(init).join(',')
|
|
105
|
+
let last = '' // JSON of the last state sent or received: cuts the echo between tabs
|
|
106
|
+
const { S, s, snap, apply } = storeState(init, name, value => { last = value })
|
|
93
107
|
const K = 'brain:' + name
|
|
94
108
|
if (o.persist) {
|
|
95
109
|
// Persistence (INTERNALS §35.4), versioned by the shape of init. Small states live in localStorage and are restored
|
|
@@ -105,12 +119,14 @@ export function store(init, o = {}, id) {
|
|
|
105
119
|
const base = JSON.stringify(snap()) // the state as created: changes made before the module loads are still saved
|
|
106
120
|
READY.push(import('./brain-persist.js').then(m => m.persist({ key: P, v, big, apply, snap, S, base })))
|
|
107
121
|
}
|
|
122
|
+
let onStorage = null
|
|
108
123
|
if (o.sync === 'storage' && G.localStorage) {
|
|
109
124
|
try {
|
|
110
125
|
const v = localStorage.getItem(K)
|
|
111
126
|
if (v) apply(JSON.parse(v, safe))
|
|
112
127
|
} catch {}
|
|
113
|
-
|
|
128
|
+
onStorage = e => e.key === K && e.newValue && apply(JSON.parse(e.newValue, safe))
|
|
129
|
+
G.addEventListener && G.addEventListener('storage', onStorage)
|
|
114
130
|
}
|
|
115
131
|
const bc = o.sync === 'tab' && G.BroadcastChannel ? new BroadcastChannel(K) : null
|
|
116
132
|
const w = o.sync === 'ws' && G.WebSocket ? ws(o.url || '/_ws', K, apply) : null
|
|
@@ -141,6 +157,14 @@ export function store(init, o = {}, id) {
|
|
|
141
157
|
}
|
|
142
158
|
}),
|
|
143
159
|
I.owner())
|
|
160
|
+
// a store owned by a subtree (provide inside a component) releases its channel, listener and socket with it;
|
|
161
|
+
// a global store (channels/, no owner) lives as long as the page
|
|
162
|
+
if (o.sync && I.owner())
|
|
163
|
+
I.root(() => fx(() => () => {
|
|
164
|
+
if (bc && bc.close) bc.close()
|
|
165
|
+
if (onStorage && G.removeEventListener) G.removeEventListener('storage', onStorage)
|
|
166
|
+
if (w) w.close()
|
|
167
|
+
}), I.owner())
|
|
144
168
|
return s
|
|
145
169
|
}
|
|
146
170
|
|
|
@@ -185,8 +209,9 @@ function ws(url, s, apply) {
|
|
|
185
209
|
}
|
|
186
210
|
}
|
|
187
211
|
const now = () => ((n = 0), open())
|
|
212
|
+
const shown = () => document.visibilityState === 'visible' && open()
|
|
188
213
|
G.addEventListener && G.addEventListener('online', now)
|
|
189
|
-
G.document && document.addEventListener && document.addEventListener('visibilitychange',
|
|
214
|
+
G.document && document.addEventListener && document.addEventListener('visibilitychange', shown)
|
|
190
215
|
c = {
|
|
191
216
|
subs,
|
|
192
217
|
sub(k, f) {
|
|
@@ -198,14 +223,16 @@ function ws(url, s, apply) {
|
|
|
198
223
|
off = true
|
|
199
224
|
clearTimeout(t)
|
|
200
225
|
sock && sock.close()
|
|
201
|
-
WS.delete(url)
|
|
226
|
+
if (WS.get(url) === c) WS.delete(url)
|
|
202
227
|
G.removeEventListener && G.removeEventListener('online', now)
|
|
228
|
+
G.document && document.removeEventListener && document.removeEventListener('visibilitychange', shown)
|
|
203
229
|
},
|
|
204
230
|
}
|
|
205
231
|
WS.set(url, c)
|
|
206
232
|
}
|
|
207
233
|
c.sub(s, apply)
|
|
208
|
-
|
|
234
|
+
let gone = false // $close() and the owner's cleanup may both run: release once
|
|
235
|
+
return { send: d => c.send(s, d), close: () => gone || ((gone = true), c.subs.delete(s), c.subs.size || c.close()) }
|
|
209
236
|
}
|
|
210
237
|
|
|
211
238
|
// Shape of a store: field names + kind of each initial value. A persisted state with another shape is discarded.
|
package/brain-data.js
CHANGED
|
@@ -186,8 +186,9 @@ function request(path, M, def, arg, o) {
|
|
|
186
186
|
// optimistic: () => (list = [...list, item]) — applied now, undone if the request fails
|
|
187
187
|
const undo = o.optimistic ? rec(o.optimistic) : null
|
|
188
188
|
// a call made from an event handler is cancelled when its component unmounts (brain-guard keeps `$c` on its nodes)
|
|
189
|
+
let reg = null
|
|
189
190
|
if (!CUR && G.event && G.event.target) for (let el = G.event.target; el; el = el.parentNode) if (el.$c) {
|
|
190
|
-
const c = el.$c
|
|
191
|
+
const c = (reg = el.$c)
|
|
191
192
|
c.add(ctl)
|
|
192
193
|
ctl.signal.addEventListener('abort', () => c.delete(ctl))
|
|
193
194
|
break
|
|
@@ -238,6 +239,8 @@ function request(path, M, def, arg, o) {
|
|
|
238
239
|
batch(() => { for (let i = undo.length - 2; i >= 0; i -= 2) undo[i].v = undo[i + 1] })
|
|
239
240
|
throw e
|
|
240
241
|
})
|
|
242
|
+
// a settled request leaves its component's registry: a long-lived view must not keep every finished call
|
|
243
|
+
if (reg) p.then(() => reg.delete(ctl), () => reg.delete(ctl))
|
|
241
244
|
p.cancel = () => ctl.abort()
|
|
242
245
|
if (file) Object.defineProperty(p, 'progress', { get: () => up.v }) // 0–1, a signal read
|
|
243
246
|
return p
|
package/brain-env.js
CHANGED
|
@@ -24,10 +24,11 @@ export function mounted(f) {
|
|
|
24
24
|
onDispose(() => ((alive = false), typeof c === 'function' && c()))
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
// watch(() => src, (v, old) => …): runs the callback when the source changes, not on the first read
|
|
28
|
-
//
|
|
29
|
-
|
|
30
|
-
|
|
27
|
+
// watch(() => src, (v, old) => …): runs the callback when the source changes, not on the first read (with
|
|
28
|
+
// `{ immediate: true }` also once with the initial value, `old` undefined); the callback is not tracked. A returned
|
|
29
|
+
// function is its cleanup (before the next run and on unmount).
|
|
30
|
+
export function watch(src, cb, options) {
|
|
31
|
+
let first = !options?.immediate, old
|
|
31
32
|
fx(() => {
|
|
32
33
|
const v = src()
|
|
33
34
|
if (first) return void ((first = false), (old = v))
|
package/brain-guard.js
CHANGED
|
@@ -24,22 +24,22 @@ const TEXT = 'This section could not load.'
|
|
|
24
24
|
const BUDGET = { nodes: 200000, rows: 100000, runs: 1000, ms: 200 }
|
|
25
25
|
const STOPPED = 'This part stopped to protect the page.'
|
|
26
26
|
const OWN = new WeakMap() // root owner → instance
|
|
27
|
-
const now = () =>
|
|
27
|
+
const now = () => performance.now()
|
|
28
28
|
// work per flush: the time between two effect runs belongs to the earlier one (the hook fires as a run starts); the
|
|
29
29
|
// last run of a flush is settled by a microtask queued at the flush's first run
|
|
30
30
|
let LX = null, LT = 0, inFlush = false
|
|
31
31
|
const touched = new Set()
|
|
32
32
|
// elements under a root, and the most element children any one of them has (the rows of a list)
|
|
33
33
|
const count = (e, c) => {
|
|
34
|
-
if (e.nodeType
|
|
34
|
+
if (e.nodeType != 1) return
|
|
35
35
|
c.nodes++
|
|
36
36
|
let k = 0
|
|
37
|
-
for (const n of e.childNodes) if (n.nodeType
|
|
37
|
+
for (const n of e.childNodes) if (n.nodeType == 1) k++, count(n, c)
|
|
38
38
|
if (k > c.rows) c.rows = k
|
|
39
39
|
}
|
|
40
40
|
// elements under a root: native when the DOM has it (the shim walks)
|
|
41
41
|
const elems = r => {
|
|
42
|
-
if (r.nodeType
|
|
42
|
+
if (r.nodeType != 1) return 0
|
|
43
43
|
if (r.getElementsByTagName) return 1 + r.getElementsByTagName('*').length
|
|
44
44
|
const c = { nodes: 0, rows: 0 }
|
|
45
45
|
count(r, c)
|
|
@@ -219,7 +219,7 @@ export function guard(C, fb, budget) {
|
|
|
219
219
|
report(pending[0], pending[1])
|
|
220
220
|
pending = null
|
|
221
221
|
}
|
|
222
|
-
for (const n of nodes) if (n.nodeType
|
|
222
|
+
for (const n of nodes) if (n.nodeType == 1) (n.$g = handler), (n.$c = reqs)
|
|
223
223
|
}, own)
|
|
224
224
|
function again() {
|
|
225
225
|
const p = end.parentNode
|
|
@@ -232,7 +232,7 @@ export function guard(C, fb, budget) {
|
|
|
232
232
|
if (own)
|
|
233
233
|
I.root(() => fx(() => () => {
|
|
234
234
|
if (dispose) dispose()
|
|
235
|
-
for (const n of nodes) if (n.nodeType
|
|
235
|
+
for (const n of nodes) if (n.nodeType == 1) (n.$g = null), (n.$c = null) // see brain-dev: no node → instance links
|
|
236
236
|
I.rm(nodes)
|
|
237
237
|
}), own)
|
|
238
238
|
return [...nodes, end]
|
package/brain-live.js
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
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
|
+
// A joined room proves health only once the connection stays up this long; shorter sessions keep spending the budget.
|
|
8
|
+
const STABLE_MS = 30000
|
|
9
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
10
|
+
const own = (value, key) => Object.prototype.hasOwnProperty.call(value, key)
|
|
11
|
+
const RESERVED = new Set(['constructor', 'prototype', '__proto__', 'id', 'owner', 'created_at', 'updated_at', 'search'])
|
|
12
|
+
let broker
|
|
13
|
+
const fault = fix => Object.assign(Error('B0918: ' + fix), { code: 'B0918', fix })
|
|
14
|
+
const fail = fix => { throw fault(fix) }
|
|
15
|
+
const exact = (value, keys) => value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).every(key => keys.includes(key))
|
|
16
|
+
const resourceName = name => typeof name === 'string' && /^[a-z][a-z0-9_]{0,47}$/.test(name) && !RESERVED.has(name)
|
|
17
|
+
|
|
18
|
+
function roomsOf(value) {
|
|
19
|
+
if (!Array.isArray(value) || value.length > MAX_ROOMS) fail('Use at most 32 native resource rooms')
|
|
20
|
+
const rooms = new Map()
|
|
21
|
+
for (const room of value) {
|
|
22
|
+
let command, key
|
|
23
|
+
const id = room && typeof room === 'object' && own(room, 'id') ? room.id : undefined
|
|
24
|
+
if (exact(room, ['room']) && own(room, 'room') && room.room === 'user:self') {
|
|
25
|
+
command = { room: 'user:self' }; key = 'user:self'
|
|
26
|
+
} else if (exact(room, ['resource', 'id']) && own(room, 'resource') && resourceName(room.resource) && (id === undefined || (typeof id === 'string' && UUID.test(id)))) {
|
|
27
|
+
command = id === undefined ? { resource: room.resource } : { resource: room.resource, id: id.toLowerCase() }
|
|
28
|
+
key = command.resource + ':' + (command.id || '')
|
|
29
|
+
} else fail('Use a declared resource, an optional UUID, or the user:self room')
|
|
30
|
+
rooms.set(key, Object.freeze(command))
|
|
31
|
+
}
|
|
32
|
+
return rooms
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function endpoint() {
|
|
36
|
+
const location = G.location
|
|
37
|
+
if (!location || !/^https?:$/.test(location.protocol)) fail('Serve live resources from an HTTP(S) application origin')
|
|
38
|
+
const meta = G.document && document.querySelector('meta[name=brain-base]')
|
|
39
|
+
const base = meta && meta.getAttribute('content') || ''
|
|
40
|
+
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')
|
|
41
|
+
const origin = new URL(location.origin)
|
|
42
|
+
if (origin.origin !== location.origin || origin.username || origin.password) fail('Use a canonical application origin')
|
|
43
|
+
const normalized = base.replace(/\/$/, '')
|
|
44
|
+
const url = new URL(normalized + '/_brain/socket', origin)
|
|
45
|
+
if (url.origin !== origin.origin || url.pathname !== normalized + '/_brain/socket') fail('Use a normalized same-origin application base path')
|
|
46
|
+
url.protocol = origin.protocol === 'https:' ? 'wss:' : 'ws:'
|
|
47
|
+
return url.href
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function metadata(text) {
|
|
51
|
+
if (typeof text !== 'string' || text.length > FRAME || /[^\x09\x0a\x0d\x20-\x7e]/.test(text)) return null
|
|
52
|
+
let value
|
|
53
|
+
try {
|
|
54
|
+
value = JSON.parse(text, (key, item) => {
|
|
55
|
+
if (key === '__proto__' || key === 'constructor' || key === 'prototype') throw Error('Unsafe metadata')
|
|
56
|
+
return item
|
|
57
|
+
})
|
|
58
|
+
} catch { return null }
|
|
59
|
+
if (exact(value, ['type', 'protocol', 'durable', 'resyncRequired']) && value.type === 'ready' && value.protocol === 1 && value.durable === false && value.resyncRequired === true) return Object.freeze(value)
|
|
60
|
+
if (exact(value, ['type', 'reason']) && value.type === 'resync' && value.reason === 'joined') return Object.freeze(value)
|
|
61
|
+
if (exact(value, ['type']) && value.type === 'pong') return Object.freeze(value)
|
|
62
|
+
if (exact(value, ['type', 'code']) && value.type === 'error' && value.code === 'ROOM_FORBIDDEN') return Object.freeze(value)
|
|
63
|
+
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)
|
|
64
|
+
return null
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function createBroker() {
|
|
68
|
+
const members = new Set(), joined = new Map(), waiting = [], events = new Map()
|
|
69
|
+
let socket = null, epoch = 0, scope = null, terminal = '', ready = false
|
|
70
|
+
let reconcileQueued = false, retryTimer, readyTimer, heartbeat, pongTimer, flushTimer
|
|
71
|
+
let attempts = 0, retryStart = 0, lastConnect = 0, healthySince = 0
|
|
72
|
+
|
|
73
|
+
const update = () => {
|
|
74
|
+
for (const member of members) {
|
|
75
|
+
const active = [...member.rooms.keys()].some(key => joined.get(key) === 'ready')
|
|
76
|
+
const forbidden = member.rooms.size > 0 && [...member.rooms.keys()].every(key => joined.get(key) === 'forbidden')
|
|
77
|
+
member.up.v = !!(socket && ready && member.scope && member.scope === scope && active)
|
|
78
|
+
member.state.v = !member.scope || !member.rooms.size ? 'idle' : terminal || (member.refreshState || (forbidden ? 'forbidden' : member.up.v ? 'open' : retryTimer ? 'retrying' : 'connecting'))
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const clearMember = member => {
|
|
82
|
+
member.generation++
|
|
83
|
+
member.dirty = false
|
|
84
|
+
member.pending = member.resync = null
|
|
85
|
+
member.refreshState = member.flight ? 'refresh-pending' : ''
|
|
86
|
+
if (member.flight) {
|
|
87
|
+
const flight = member.flight
|
|
88
|
+
clearTimeout(flight.deadline); clearTimeout(flight.tick)
|
|
89
|
+
flight.deadline = flight.tick = null
|
|
90
|
+
if (flight.resolveTick) { flight.resolveTick(); flight.resolveTick = null }
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const stop = () => {
|
|
94
|
+
epoch++; ready = false; healthySince = 0
|
|
95
|
+
clearTimeout(retryTimer); retryTimer = null
|
|
96
|
+
clearTimeout(readyTimer); clearInterval(heartbeat); clearTimeout(pongTimer)
|
|
97
|
+
readyTimer = heartbeat = pongTimer = null
|
|
98
|
+
clearTimeout(flushTimer); flushTimer = null
|
|
99
|
+
joined.clear(); waiting.length = 0; events.clear(); currentRooms.clear()
|
|
100
|
+
const previous = socket; socket = null
|
|
101
|
+
if (previous) {
|
|
102
|
+
previous.onopen = previous.onmessage = previous.onclose = previous.onerror = null
|
|
103
|
+
try { previous.close(1000, 'Live resource lifecycle ended') } catch {}
|
|
104
|
+
}
|
|
105
|
+
for (const member of members) { clearMember(member); member.up.v = false }
|
|
106
|
+
}
|
|
107
|
+
const reject = state => { stop(); terminal = state; update() }
|
|
108
|
+
const send = value => {
|
|
109
|
+
if (!socket || socket.readyState !== 1 || socket.bufferedAmount > BUFFER) return false
|
|
110
|
+
const text = JSON.stringify(value)
|
|
111
|
+
if (text.length > FRAME) return false
|
|
112
|
+
try { socket.send(text); return true } catch { return false }
|
|
113
|
+
}
|
|
114
|
+
const refresh = member => {
|
|
115
|
+
if (!members.has(member) || !member.up.v || !member.dirty || member.flight) return
|
|
116
|
+
member.dirty = false; member.refreshState = ''; update()
|
|
117
|
+
const pending = member.pending, resync = member.resync
|
|
118
|
+
member.pending = member.resync = null
|
|
119
|
+
const flight = { generation: member.generation, epoch, deadline: null, tick: null, resolveTick: null }
|
|
120
|
+
member.flight = flight
|
|
121
|
+
flight.deadline = setTimeout(timeoutFlight.bind(null, member.cell, flight), REFRESH_MS)
|
|
122
|
+
try {
|
|
123
|
+
if (resync && own(member.options, 'onResync') && member.options.onResync) observeMetadata(member.options.onResync, resync)
|
|
124
|
+
if (pending && own(member.options, 'onInvalidate') && member.options.onInvalidate) observeMetadata(member.options.onInvalidate, pending)
|
|
125
|
+
untrack(() => member.node.refetch())
|
|
126
|
+
// Equivalent post-effect tick, with an owned cancellation handle. The
|
|
127
|
+
// standard tick() intentionally exposes no handle for owner cancellation.
|
|
128
|
+
const next = new Promise(resolve => {
|
|
129
|
+
flight.resolveTick = resolve
|
|
130
|
+
flight.tick = setTimeout(resolve, 0)
|
|
131
|
+
})
|
|
132
|
+
next.then(readResource.bind(null, member.cell, flight)).then(
|
|
133
|
+
settleFlight.bind(null, member.cell, flight, false),
|
|
134
|
+
settleFlight.bind(null, member.cell, flight, true),
|
|
135
|
+
)
|
|
136
|
+
} catch {
|
|
137
|
+
I.oops(fault('Keep live metadata observers synchronous and handle resource failures in the view'), member.owner, 'B0918')
|
|
138
|
+
dispose(member.cell)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
const valid = (member, flight) => members.has(member) && member.generation === flight.generation && epoch === flight.epoch
|
|
142
|
+
function complete(member, flight, failed) {
|
|
143
|
+
if (member.flight !== flight) return
|
|
144
|
+
clearTimeout(flight.deadline); clearTimeout(flight.tick)
|
|
145
|
+
flight.deadline = flight.tick = flight.resolveTick = null
|
|
146
|
+
member.flight = null; member.refreshState = ''
|
|
147
|
+
if (valid(member, flight) && failed) member.refreshState = 'refresh-error'
|
|
148
|
+
if (members.has(member) && member.dirty) schedule()
|
|
149
|
+
if (members.has(member)) update()
|
|
150
|
+
}
|
|
151
|
+
function schedule() {
|
|
152
|
+
if (!flushTimer) flushTimer = setTimeout(() => {
|
|
153
|
+
flushTimer = null
|
|
154
|
+
for (const member of members) refresh(member)
|
|
155
|
+
}, COALESCE)
|
|
156
|
+
}
|
|
157
|
+
const invalidate = (value, roomKey) => {
|
|
158
|
+
for (const member of members) {
|
|
159
|
+
if (!member.scope || member.scope !== scope) continue
|
|
160
|
+
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))))
|
|
161
|
+
if (interested) { member.dirty = true; if (value.type === 'resync') member.resync = value; else member.pending = value; schedule() }
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
const retry = () => {
|
|
165
|
+
if (healthySince && Date.now() - healthySince >= STABLE_MS) { attempts = 0; retryStart = 0 }
|
|
166
|
+
stop()
|
|
167
|
+
if (++attempts > 5 || (retryStart && Date.now() - retryStart >= RETRY_MS)) { terminal = 'exhausted'; update(); return }
|
|
168
|
+
if (!retryStart) retryStart = Date.now()
|
|
169
|
+
const delay = Math.floor(Math.min(8000, 250 * 2 ** (attempts - 1)) * (0.75 + Math.random() * 0.25))
|
|
170
|
+
retryTimer = setTimeout(() => { retryTimer = null; reconcile() }, delay)
|
|
171
|
+
update()
|
|
172
|
+
}
|
|
173
|
+
const desired = () => {
|
|
174
|
+
const rooms = new Map(), scopes = new Set()
|
|
175
|
+
for (const member of members) if (member.scope && member.rooms.size) {
|
|
176
|
+
scopes.add(member.scope)
|
|
177
|
+
for (const [key, value] of member.rooms) rooms.set(key, value)
|
|
178
|
+
}
|
|
179
|
+
return { rooms, scopes }
|
|
180
|
+
}
|
|
181
|
+
const syncRooms = rooms => {
|
|
182
|
+
const changed = [...rooms.keys()].some(key => !joined.has(key)) || [...joined.keys()].some(key => !rooms.has(key))
|
|
183
|
+
if (waiting.length && changed) { retry(); return }
|
|
184
|
+
for (const [key] of joined) if (!rooms.has(key)) {
|
|
185
|
+
const room = currentRooms.get(key)
|
|
186
|
+
if (!room || !send({ type: 'leave', ...room })) { retry(); return }
|
|
187
|
+
joined.delete(key)
|
|
188
|
+
}
|
|
189
|
+
currentRooms = rooms
|
|
190
|
+
for (const [key, room] of rooms) if (!joined.has(key)) {
|
|
191
|
+
if (waiting.length >= MAX_ROOMS || !send({ type: 'join', ...room })) { retry(); return }
|
|
192
|
+
joined.set(key, 'pending'); waiting.push(key)
|
|
193
|
+
}
|
|
194
|
+
update()
|
|
195
|
+
}
|
|
196
|
+
let currentRooms = new Map()
|
|
197
|
+
const connect = () => {
|
|
198
|
+
const captured = ++epoch
|
|
199
|
+
lastConnect = Date.now()
|
|
200
|
+
let address
|
|
201
|
+
try { address = endpoint() } catch { reject('invalid-config'); return }
|
|
202
|
+
try { socket = new G.WebSocket(address, 'brain.v1') } catch { retry(); return }
|
|
203
|
+
readyTimer = setTimeout(() => { if (epoch === captured) retry() }, READY_MS)
|
|
204
|
+
socket.onopen = () => {
|
|
205
|
+
if (epoch !== captured) return
|
|
206
|
+
if (socket.protocol !== 'brain.v1') reject('protocol-error')
|
|
207
|
+
}
|
|
208
|
+
socket.onerror = () => { /* close or the ready deadline owns retries; arbitrary browser errors are not retained. */ }
|
|
209
|
+
socket.onclose = event => {
|
|
210
|
+
if (epoch !== captured) return
|
|
211
|
+
if (event.code === 1008) reject(event.reason === 'Authentication expired' ? 'auth-required' : 'policy-closed')
|
|
212
|
+
else retry()
|
|
213
|
+
}
|
|
214
|
+
socket.onmessage = event => {
|
|
215
|
+
if (epoch !== captured) return
|
|
216
|
+
const value = metadata(event.data)
|
|
217
|
+
if (!value) { reject('protocol-error'); return }
|
|
218
|
+
if (value.type === 'ready') {
|
|
219
|
+
if (ready) { reject('protocol-error'); return }
|
|
220
|
+
clearTimeout(readyTimer); readyTimer = null; ready = true
|
|
221
|
+
syncRooms(desired().rooms)
|
|
222
|
+
heartbeat = setInterval(() => {
|
|
223
|
+
if (!send({ type: 'ping' })) { retry(); return }
|
|
224
|
+
clearTimeout(pongTimer)
|
|
225
|
+
pongTimer = setTimeout(() => { if (epoch === captured) retry() }, 12000)
|
|
226
|
+
}, 25000)
|
|
227
|
+
} else if (!ready) reject('protocol-error')
|
|
228
|
+
else if (value.type === 'pong') { clearTimeout(pongTimer); pongTimer = null }
|
|
229
|
+
else if (value.type === 'resync' || value.type === 'error') {
|
|
230
|
+
const key = waiting.shift()
|
|
231
|
+
if (!key) { reject('protocol-error'); return }
|
|
232
|
+
joined.set(key, value.type === 'resync' ? 'ready' : 'forbidden')
|
|
233
|
+
if (value.type === 'resync' && !healthySince) healthySince = Date.now()
|
|
234
|
+
update()
|
|
235
|
+
if (value.type === 'resync') invalidate(value, key)
|
|
236
|
+
} else {
|
|
237
|
+
const now = Date.now()
|
|
238
|
+
for (const [id, time] of events) if (now - time > 60000) events.delete(id)
|
|
239
|
+
if (events.has(value.eventId)) return
|
|
240
|
+
events.set(value.eventId, now)
|
|
241
|
+
while (events.size > 128) events.delete(events.keys().next().value)
|
|
242
|
+
invalidate(value)
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
update()
|
|
246
|
+
}
|
|
247
|
+
function reconcile() {
|
|
248
|
+
reconcileQueued = false
|
|
249
|
+
const { rooms, scopes } = desired()
|
|
250
|
+
if (!rooms.size) { stop(); scope = null; terminal = ''; attempts = 0; retryStart = 0; update(); return }
|
|
251
|
+
if (scopes.size !== 1) { reject('scope-conflict'); return }
|
|
252
|
+
const next = scopes.values().next().value
|
|
253
|
+
if (next !== scope || terminal === 'scope-conflict') { stop(); scope = next; terminal = ''; attempts = 0; retryStart = 0 }
|
|
254
|
+
if (rooms.size > MAX_ROOMS) { reject('room-limit'); return }
|
|
255
|
+
if (terminal || retryTimer) { update(); return }
|
|
256
|
+
if (socket && ready) syncRooms(rooms)
|
|
257
|
+
else if (!socket) {
|
|
258
|
+
const elapsed = Date.now() - lastConnect
|
|
259
|
+
const delay = elapsed < 0 ? 0 : Math.max(0, 250 - elapsed)
|
|
260
|
+
if (delay) retryTimer = setTimeout(() => { retryTimer = null; reconcile() }, delay)
|
|
261
|
+
else connect()
|
|
262
|
+
}
|
|
263
|
+
update()
|
|
264
|
+
}
|
|
265
|
+
const queue = () => { if (!reconcileQueued) { reconcileQueued = true; queueMicrotask(reconcile) } }
|
|
266
|
+
return {
|
|
267
|
+
valid, complete,
|
|
268
|
+
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 } },
|
|
269
|
+
add(member) {
|
|
270
|
+
if (!members.has(member) && members.size >= MAX_OWNERS) fail('Close unused live resource owners before adding another')
|
|
271
|
+
members.add(member)
|
|
272
|
+
if (scope && member.scope && member.scope !== scope) stop()
|
|
273
|
+
queue()
|
|
274
|
+
},
|
|
275
|
+
remove(member) {
|
|
276
|
+
members.delete(member); clearMember(member); member.up.v = false
|
|
277
|
+
if (!members.size) { stop(); scope = null; terminal = ''; attempts = 0; retryStart = 0 }
|
|
278
|
+
else queue()
|
|
279
|
+
},
|
|
280
|
+
reconnect(member) {
|
|
281
|
+
if (!members.has(member)) return
|
|
282
|
+
stop(); terminal = ''; attempts = 0; retryStart = 0; queue()
|
|
283
|
+
},
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function observeMetadata(callback, value) {
|
|
288
|
+
const result = untrack(() => callback(value))
|
|
289
|
+
if (result && typeof result.then === 'function') {
|
|
290
|
+
// Observe rejection without retaining an owner or the user-provided error.
|
|
291
|
+
Promise.resolve(result).catch(() => {})
|
|
292
|
+
fail('Use synchronous metadata observers; resource owns asynchronous fetching')
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Promise reactions retain only a nullable cell and scalar flight metadata. An
|
|
297
|
+
// externally retained, never-settling Promise cannot keep a disposed resource alive.
|
|
298
|
+
function readResource(cell, flight) {
|
|
299
|
+
flight.tick = flight.resolveTick = null
|
|
300
|
+
const member = cell.record
|
|
301
|
+
if (member && broker.valid(member, flight)) return member.node.v
|
|
302
|
+
}
|
|
303
|
+
function settleFlight(cell, flight, failed) {
|
|
304
|
+
const member = cell.record
|
|
305
|
+
if (member) broker.complete(member, flight, failed)
|
|
306
|
+
}
|
|
307
|
+
function timeoutFlight(cell, flight) {
|
|
308
|
+
const member = cell.record
|
|
309
|
+
if (member && broker.valid(member, flight)) {
|
|
310
|
+
// Never release a pending fetch slot merely because its deadline elapsed.
|
|
311
|
+
member.refreshState = 'refresh-timeout'
|
|
312
|
+
member.state.v = 'refresh-timeout'
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
function detach(cell) {
|
|
316
|
+
const member = cell.record
|
|
317
|
+
if (member && broker) broker.remove(member)
|
|
318
|
+
}
|
|
319
|
+
function dispose(cell) {
|
|
320
|
+
const member = cell.record
|
|
321
|
+
if (!member) return
|
|
322
|
+
cell.record = null
|
|
323
|
+
if (member.stopWatch) member.stopWatch()
|
|
324
|
+
if (broker) broker.remove(member)
|
|
325
|
+
member.node = member.options = member.owner = member.stopWatch = null
|
|
326
|
+
member.scope = null; member.rooms.clear(); member.flight = null
|
|
327
|
+
member.state.v = 'closed'
|
|
328
|
+
}
|
|
329
|
+
function observe(cell) {
|
|
330
|
+
const member = cell.record
|
|
331
|
+
if (!member) return
|
|
332
|
+
const scope = member.options.scope()
|
|
333
|
+
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')
|
|
334
|
+
member.scope = scope
|
|
335
|
+
member.rooms = roomsOf(typeof member.options.rooms === 'function' ? member.options.rooms() : member.options.rooms)
|
|
336
|
+
broker = broker || createBroker()
|
|
337
|
+
broker.add(member)
|
|
338
|
+
return detach.bind(null, cell)
|
|
339
|
+
}
|
|
340
|
+
function ownerCleanup(cell) { return dispose.bind(null, cell) }
|
|
341
|
+
function reconnect(cell) { if (cell.record && broker) broker.reconnect(cell.record) }
|
|
342
|
+
|
|
343
|
+
// Internal, metadata-only capacity evidence; never scope identifiers, rooms or values.
|
|
344
|
+
export function _liveStats() {
|
|
345
|
+
return broker ? broker.stats() : { owners: 0, rooms: 0, pendingJoins: 0, dedupe: 0, connection: 0, timers: 0, refreshes: 0, dirty: 0 }
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export function liveResource(node, options = {}) {
|
|
349
|
+
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')
|
|
350
|
+
if (own(options, 'onInvalidate') && options.onInvalidate !== undefined && typeof options.onInvalidate !== 'function') fail('Use a metadata callback for onInvalidate')
|
|
351
|
+
if (own(options, 'onResync') && options.onResync !== undefined && typeof options.onResync !== 'function') fail('Use a metadata callback for onResync')
|
|
352
|
+
const state = sig('idle'), up = sig(false), cell = { record: null }
|
|
353
|
+
const out = {
|
|
354
|
+
get connected() { return up.v },
|
|
355
|
+
get state() { return state.v },
|
|
356
|
+
close() { dispose(cell); up.v = false; state.v = 'closed' },
|
|
357
|
+
reconnect: reconnect.bind(null, cell),
|
|
358
|
+
}
|
|
359
|
+
if (G.__brainSSR || G.__brainRoute) return out
|
|
360
|
+
if (typeof G.WebSocket !== 'function') { state.v = 'unsupported'; return out }
|
|
361
|
+
const owner = I.owner()
|
|
362
|
+
if (!owner) fail('Create liveResource inside a component so unmount closes its subscription')
|
|
363
|
+
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 }
|
|
364
|
+
fx(ownerCleanup.bind(null, cell))
|
|
365
|
+
cell.record.stopWatch = fx(observe.bind(null, cell))
|
|
366
|
+
return out
|
|
367
|
+
}
|