@christianirack/brainllm-runtime 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +4 -2
- package/brain-anim.js +21 -0
- package/brain-ch.js +254 -0
- package/brain-consent.js +38 -0
- package/brain-data.js +547 -0
- package/brain-dev.js +100 -0
- package/brain-env.js +160 -0
- package/brain-err.js +242 -0
- package/brain-form.js +260 -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 +58 -0
- package/brain-mock.js +80 -0
- package/brain-overlay.js +123 -0
- package/brain-persist.js +65 -0
- package/brain-router.js +353 -0
- package/brain-rpc.js +17 -0
- package/brain-schema.js +69 -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 +882 -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/shim/dom.js
ADDED
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
// Minimal DOM to test the runtime in Node with no dependencies. Covers only what brain.js uses.
|
|
2
|
+
// Installs `document`, `Event` and `MouseEvent` on globalThis. `stats.moves` counts insertions of already-connected nodes.
|
|
3
|
+
|
|
4
|
+
export const stats = { moves: 0 }
|
|
5
|
+
const VOID = new Set(['area', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'source', 'track', 'wbr'])
|
|
6
|
+
const esc = s => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
7
|
+
const unesc = s => s.replace(/&(amp|lt|gt|quot|#39);/g, (_, e) => ({ amp: '&', lt: '<', gt: '>', quot: '"', '#39': "'" })[e])
|
|
8
|
+
|
|
9
|
+
export class Event {
|
|
10
|
+
constructor(type, init = {}) {
|
|
11
|
+
this.type = type
|
|
12
|
+
this.bubbles = !!init.bubbles
|
|
13
|
+
this.cancelable = !!init.cancelable
|
|
14
|
+
this.target = null
|
|
15
|
+
this.currentTarget = null
|
|
16
|
+
this.cancelBubble = false
|
|
17
|
+
this.defaultPrevented = false
|
|
18
|
+
this._p = []
|
|
19
|
+
}
|
|
20
|
+
composedPath() {
|
|
21
|
+
return this._p
|
|
22
|
+
}
|
|
23
|
+
stopImmediatePropagation() {
|
|
24
|
+
this.cancelBubble = this._stop = true
|
|
25
|
+
}
|
|
26
|
+
stopPropagation() {
|
|
27
|
+
this.cancelBubble = true
|
|
28
|
+
}
|
|
29
|
+
preventDefault() {
|
|
30
|
+
this.defaultPrevented = true
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export class MouseEvent extends Event {}
|
|
34
|
+
|
|
35
|
+
// Minimal MutationObserver: only records for nodes connected to the document, plus takeRecords() (used by brain-inspect).
|
|
36
|
+
const MOS = new Set()
|
|
37
|
+
const rec = (type, target) => {
|
|
38
|
+
if (MOS.size && target.isConnected) for (const o of MOS) o._q.push({ type, target })
|
|
39
|
+
}
|
|
40
|
+
export class MutationObserver {
|
|
41
|
+
constructor(f) {
|
|
42
|
+
this._f = f
|
|
43
|
+
this._q = []
|
|
44
|
+
}
|
|
45
|
+
observe() {
|
|
46
|
+
MOS.add(this)
|
|
47
|
+
}
|
|
48
|
+
disconnect() {
|
|
49
|
+
MOS.delete(this)
|
|
50
|
+
this._q = []
|
|
51
|
+
}
|
|
52
|
+
takeRecords() {
|
|
53
|
+
const q = this._q
|
|
54
|
+
this._q = []
|
|
55
|
+
return q
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
class Node {
|
|
60
|
+
constructor(type, doc) {
|
|
61
|
+
this.nodeType = type
|
|
62
|
+
this.ownerDocument = doc
|
|
63
|
+
this.parentNode = null
|
|
64
|
+
this.childNodes = []
|
|
65
|
+
this._l = { cap: {}, bub: {} }
|
|
66
|
+
}
|
|
67
|
+
get firstChild() {
|
|
68
|
+
return this.childNodes[0] || null
|
|
69
|
+
}
|
|
70
|
+
get lastChild() {
|
|
71
|
+
return this.childNodes[this.childNodes.length - 1] || null
|
|
72
|
+
}
|
|
73
|
+
get nextSibling() {
|
|
74
|
+
const p = this.parentNode
|
|
75
|
+
return p ? p.childNodes[p.childNodes.indexOf(this) + 1] || null : null
|
|
76
|
+
}
|
|
77
|
+
get previousSibling() {
|
|
78
|
+
const p = this.parentNode
|
|
79
|
+
return p ? p.childNodes[p.childNodes.indexOf(this) - 1] || null : null
|
|
80
|
+
}
|
|
81
|
+
get isConnected() {
|
|
82
|
+
let n = this
|
|
83
|
+
while (n.parentNode) n = n.parentNode
|
|
84
|
+
return n.nodeType === 9
|
|
85
|
+
}
|
|
86
|
+
insertBefore(n, ref) {
|
|
87
|
+
if (n.nodeType === 11) {
|
|
88
|
+
for (const c of [...n.childNodes]) this.insertBefore(c, ref)
|
|
89
|
+
return n
|
|
90
|
+
}
|
|
91
|
+
if (n.parentNode) {
|
|
92
|
+
if (n.isConnected) stats.moves++
|
|
93
|
+
n.parentNode.removeChild(n)
|
|
94
|
+
}
|
|
95
|
+
const i = ref ? this.childNodes.indexOf(ref) : -1
|
|
96
|
+
if (ref && i < 0) throw Error('ref is not a child')
|
|
97
|
+
i < 0 ? this.childNodes.push(n) : this.childNodes.splice(i, 0, n)
|
|
98
|
+
n.parentNode = this
|
|
99
|
+
rec('childList', this)
|
|
100
|
+
return n
|
|
101
|
+
}
|
|
102
|
+
appendChild(n) {
|
|
103
|
+
return this.insertBefore(n, null)
|
|
104
|
+
}
|
|
105
|
+
removeChild(n) {
|
|
106
|
+
const i = this.childNodes.indexOf(n)
|
|
107
|
+
if (i < 0) throw Error('not a child')
|
|
108
|
+
this.childNodes.splice(i, 1)
|
|
109
|
+
n.parentNode = null
|
|
110
|
+
rec('childList', this)
|
|
111
|
+
return n
|
|
112
|
+
}
|
|
113
|
+
remove() {
|
|
114
|
+
if (this.parentNode) this.parentNode.removeChild(this)
|
|
115
|
+
}
|
|
116
|
+
replaceWith(...ns) {
|
|
117
|
+
const p = this.parentNode
|
|
118
|
+
for (const n of ns) p.insertBefore(n, this)
|
|
119
|
+
this.remove()
|
|
120
|
+
}
|
|
121
|
+
get textContent() {
|
|
122
|
+
return this.nodeType === 3 ? this.data : this.childNodes.filter(c => c.nodeType !== 8).map(c => c.textContent).join('')
|
|
123
|
+
}
|
|
124
|
+
set textContent(v) {
|
|
125
|
+
for (const c of this.childNodes) c.parentNode = null
|
|
126
|
+
this.childNodes = []
|
|
127
|
+
if (v !== '') this.appendChild(this.ownerDocument.createTextNode(v))
|
|
128
|
+
}
|
|
129
|
+
addEventListener(t, f, cap) {
|
|
130
|
+
const a = (this._l[cap ? 'cap' : 'bub'][t] ||= [])
|
|
131
|
+
if (!a.includes(f)) a.push(f) // the same listener twice is one listener (DOM spec)
|
|
132
|
+
}
|
|
133
|
+
removeEventListener(t, f, cap) {
|
|
134
|
+
const a = this._l[cap ? 'cap' : 'bub'][t] || []
|
|
135
|
+
const i = a.indexOf(f)
|
|
136
|
+
if (i >= 0) a.splice(i, 1)
|
|
137
|
+
}
|
|
138
|
+
dispatchEvent(e) {
|
|
139
|
+
e.target = this
|
|
140
|
+
const path = []
|
|
141
|
+
for (let n = this; n; n = n.parentNode || n.host) path.push(n)
|
|
142
|
+
if (path[path.length - 1].nodeType === 9 && path[path.length - 1].defaultView) path.push(path[path.length - 1].defaultView)
|
|
143
|
+
e._p = path
|
|
144
|
+
const fire = (n, k) => {
|
|
145
|
+
for (const f of [...(n._l[k][e.type] || [])]) {
|
|
146
|
+
if (e._stop) return
|
|
147
|
+
Object.defineProperty(e, 'currentTarget', { configurable: true, writable: true, value: n })
|
|
148
|
+
f.call(n, e)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
for (let i = path.length - 1; i >= 0 && !e.cancelBubble; i--) fire(path[i], 'cap')
|
|
152
|
+
for (let i = 0; i < path.length && !e.cancelBubble; i++) {
|
|
153
|
+
fire(path[i], 'bub')
|
|
154
|
+
if (!e.bubbles) break
|
|
155
|
+
}
|
|
156
|
+
if (!e.defaultPrevented && e.type === 'click' && this.tagName === 'INPUT' && this.type === 'checkbox') this.checked = !this.checked
|
|
157
|
+
return !e.defaultPrevented
|
|
158
|
+
}
|
|
159
|
+
contains(n) {
|
|
160
|
+
for (; n; n = n.parentNode) if (n === this) return true
|
|
161
|
+
return false
|
|
162
|
+
}
|
|
163
|
+
// Supported selectors: lists (`a, b`), descendant (`a b`), compounds of `tag`, `.class`, `#id`, `[attr]`,
|
|
164
|
+
// `[attr=v]` (quoted or not) and `:not(<compound>)`. Enough for the runtime and the ui kit.
|
|
165
|
+
querySelectorAll(sel) {
|
|
166
|
+
const alts = sel.split(',').map(a => a.trim().split(/\s+/).map(compound))
|
|
167
|
+
const out = []
|
|
168
|
+
const walk = n => {
|
|
169
|
+
for (const c of n.childNodes) {
|
|
170
|
+
if (c.nodeType === 1 && alts.some(chain => matchChain(c, chain, this))) out.push(c)
|
|
171
|
+
walk(c)
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
walk(this)
|
|
175
|
+
return out
|
|
176
|
+
}
|
|
177
|
+
querySelector(sel) {
|
|
178
|
+
return this.querySelectorAll(sel)[0] || null
|
|
179
|
+
}
|
|
180
|
+
get innerHTML() {
|
|
181
|
+
return this.childNodes.map(ser).join('')
|
|
182
|
+
}
|
|
183
|
+
set innerHTML(h) {
|
|
184
|
+
this.textContent = ''
|
|
185
|
+
parse(h, this.nodeName === 'TEMPLATE' ? this.content : this, this.ownerDocument)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
class Text extends Node {
|
|
190
|
+
constructor(d, doc) {
|
|
191
|
+
super(3, doc)
|
|
192
|
+
this._d = String(d)
|
|
193
|
+
}
|
|
194
|
+
get data() {
|
|
195
|
+
return this._d
|
|
196
|
+
}
|
|
197
|
+
set data(v) {
|
|
198
|
+
this._d = String(v)
|
|
199
|
+
rec('characterData', this)
|
|
200
|
+
}
|
|
201
|
+
get nodeValue() {
|
|
202
|
+
return this._d
|
|
203
|
+
}
|
|
204
|
+
set nodeValue(v) {
|
|
205
|
+
this.data = v
|
|
206
|
+
}
|
|
207
|
+
cloneNode() {
|
|
208
|
+
return new Text(this.data, this.ownerDocument)
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
class Comment extends Node {
|
|
212
|
+
constructor(d, doc) {
|
|
213
|
+
super(8, doc)
|
|
214
|
+
this._d = String(d)
|
|
215
|
+
}
|
|
216
|
+
get data() {
|
|
217
|
+
return this._d
|
|
218
|
+
}
|
|
219
|
+
set data(v) {
|
|
220
|
+
this._d = String(v)
|
|
221
|
+
rec('characterData', this)
|
|
222
|
+
}
|
|
223
|
+
get nodeValue() {
|
|
224
|
+
return this._d
|
|
225
|
+
}
|
|
226
|
+
set nodeValue(v) {
|
|
227
|
+
this.data = v
|
|
228
|
+
}
|
|
229
|
+
cloneNode() {
|
|
230
|
+
return new Comment(this.data, this.ownerDocument)
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
class Fragment extends Node {
|
|
234
|
+
constructor(doc) {
|
|
235
|
+
super(11, doc)
|
|
236
|
+
}
|
|
237
|
+
cloneNode(deep) {
|
|
238
|
+
const f = new Fragment(this.ownerDocument)
|
|
239
|
+
if (deep) for (const c of this.childNodes) f.appendChild(c.cloneNode(true))
|
|
240
|
+
return f
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
class Element extends Node {
|
|
245
|
+
constructor(tag, doc) {
|
|
246
|
+
super(1, doc)
|
|
247
|
+
this.tagName = this.nodeName = tag.toUpperCase()
|
|
248
|
+
this.attrs = new Map()
|
|
249
|
+
this.value = ''
|
|
250
|
+
this.checked = false
|
|
251
|
+
this.selected = false
|
|
252
|
+
const el = this
|
|
253
|
+
this.style = {
|
|
254
|
+
_p: new Map(),
|
|
255
|
+
setProperty(k, v) {
|
|
256
|
+
this._p.set(k, String(v))
|
|
257
|
+
},
|
|
258
|
+
removeProperty(k) {
|
|
259
|
+
this._p.delete(k)
|
|
260
|
+
},
|
|
261
|
+
getPropertyValue(k) {
|
|
262
|
+
return this._p.get(k) || ''
|
|
263
|
+
},
|
|
264
|
+
}
|
|
265
|
+
this.classList = {
|
|
266
|
+
_l: () => (el.getAttribute('class') || '').split(/\s+/).filter(Boolean),
|
|
267
|
+
contains: c => el.classList._l().includes(c),
|
|
268
|
+
toggle(c, on) {
|
|
269
|
+
const l = this._l().filter(x => x !== c)
|
|
270
|
+
if (on) l.push(c)
|
|
271
|
+
l.length ? el.setAttribute('class', l.join(' ')) : el.removeAttribute('class')
|
|
272
|
+
return on
|
|
273
|
+
},
|
|
274
|
+
}
|
|
275
|
+
this.dataset = new Proxy({}, { get: (_, k) => el.getAttribute('data-' + String(k).replace(/[A-Z]/g, c => '-' + c.toLowerCase())) ?? undefined })
|
|
276
|
+
if (this.tagName === 'TEMPLATE') this.content = new Fragment(doc)
|
|
277
|
+
}
|
|
278
|
+
get type() {
|
|
279
|
+
return this.getAttribute('type') || ''
|
|
280
|
+
}
|
|
281
|
+
setAttribute(k, v) {
|
|
282
|
+
this.attrs.set(k, String(v))
|
|
283
|
+
rec('attributes', this)
|
|
284
|
+
if (k === 'value') this.value = String(v)
|
|
285
|
+
if (k === 'checked') this.checked = true
|
|
286
|
+
}
|
|
287
|
+
get attributes() {
|
|
288
|
+
return [...this.attrs].map(([name, value]) => ({ name, value }))
|
|
289
|
+
}
|
|
290
|
+
replaceChildren(...ns) {
|
|
291
|
+
this.textContent = ''
|
|
292
|
+
for (const n of ns) this.appendChild(n)
|
|
293
|
+
}
|
|
294
|
+
getAttribute(k) {
|
|
295
|
+
return this.attrs.has(k) ? this.attrs.get(k) : null
|
|
296
|
+
}
|
|
297
|
+
hasAttribute(k) {
|
|
298
|
+
return this.attrs.has(k)
|
|
299
|
+
}
|
|
300
|
+
removeAttribute(k) {
|
|
301
|
+
if (this.attrs.delete(k)) rec('attributes', this)
|
|
302
|
+
}
|
|
303
|
+
// reflected attributes the runtime and the ui kit read
|
|
304
|
+
get id() {
|
|
305
|
+
return this.getAttribute('id') || ''
|
|
306
|
+
}
|
|
307
|
+
set id(v) {
|
|
308
|
+
this.setAttribute('id', v)
|
|
309
|
+
}
|
|
310
|
+
// HTMLFormElement.elements (namedItem only)
|
|
311
|
+
get elements() {
|
|
312
|
+
if (this.tagName !== 'FORM') return undefined
|
|
313
|
+
const all = this.querySelectorAll('[name]')
|
|
314
|
+
return { namedItem: n => all.find(e => e.getAttribute('name') === n) || null }
|
|
315
|
+
}
|
|
316
|
+
focus() {
|
|
317
|
+
this.ownerDocument.activeElement = this
|
|
318
|
+
}
|
|
319
|
+
cloneNode(deep) {
|
|
320
|
+
const e = new Element(this.tagName.toLowerCase(), this.ownerDocument)
|
|
321
|
+
for (const [k, v] of this.attrs) e.setAttribute(k, v)
|
|
322
|
+
if (deep) {
|
|
323
|
+
for (const c of this.childNodes) e.appendChild(c.cloneNode(true))
|
|
324
|
+
if (this.content) for (const c of this.content.childNodes) e.content.appendChild(c.cloneNode(true))
|
|
325
|
+
}
|
|
326
|
+
return e
|
|
327
|
+
}
|
|
328
|
+
get outerHTML() {
|
|
329
|
+
return ser(this)
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function ser(n) {
|
|
334
|
+
if (n.nodeType === 3) return esc(n.data)
|
|
335
|
+
if (n.nodeType === 8) return `<!--${n.data}-->`
|
|
336
|
+
if (n.nodeType === 11) return n.childNodes.map(ser).join('')
|
|
337
|
+
const t = n.tagName.toLowerCase()
|
|
338
|
+
const a = [...n.attrs].map(([k, v]) => (v === '' ? ` ${k}` : ` ${k}="${esc(v).replace(/"/g, '"')}"`)).join('')
|
|
339
|
+
return VOID.has(t) ? `<${t}${a}>` : `<${t}${a}>${n.childNodes.map(ser).join('')}</${t}>`
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Minimal HTML parser: tags, attributes, text, comments (including `<!>`), void elements and script raw text.
|
|
343
|
+
function parse(h, root, doc) {
|
|
344
|
+
const stack = [root]
|
|
345
|
+
const top = () => stack[stack.length - 1]
|
|
346
|
+
const re = /<!--([\s\S]*?)-->|<!>|<\/([\w-]+)\s*>|<([\w-]+)((?:\s+[^\s=\/>]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+))?)*)\s*(\/?)>|([^<]+|<)/g
|
|
347
|
+
let m
|
|
348
|
+
while ((m = re.exec(h))) {
|
|
349
|
+
if (m[0] === '<!>') top().appendChild(new Comment('', doc))
|
|
350
|
+
else if (m[1] != null) top().appendChild(new Comment(m[1], doc))
|
|
351
|
+
else if (m[2]) {
|
|
352
|
+
const t = m[2].toUpperCase()
|
|
353
|
+
for (let i = stack.length - 1; i > 0; i--)
|
|
354
|
+
if (stack[i].tagName === t) {
|
|
355
|
+
stack.length = i
|
|
356
|
+
break
|
|
357
|
+
}
|
|
358
|
+
} else if (m[3]) {
|
|
359
|
+
const el = new Element(m[3], doc)
|
|
360
|
+
for (const a of m[4].matchAll(/([^\s=\/>]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g)) el.setAttribute(a[1], unesc(a[2] ?? a[3] ?? a[4] ?? ''))
|
|
361
|
+
top().appendChild(el)
|
|
362
|
+
const t = m[3].toLowerCase()
|
|
363
|
+
if (t === 'script') {
|
|
364
|
+
const end = h.indexOf('</script>', re.lastIndex)
|
|
365
|
+
el.appendChild(new Text(h.slice(re.lastIndex, end), doc))
|
|
366
|
+
re.lastIndex = end + 9
|
|
367
|
+
} else if (!VOID.has(t) && !m[5]) stack.push(t === 'template' ? Object.assign(el.content, { tagName: 'TEMPLATE' }) : el)
|
|
368
|
+
} else top().appendChild(new Text(unesc(m[6]), doc))
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
class Document extends Node {
|
|
373
|
+
constructor() {
|
|
374
|
+
super(9, null)
|
|
375
|
+
this.ownerDocument = this
|
|
376
|
+
this.baseURI = 'http://localhost/'
|
|
377
|
+
this.activeElement = null
|
|
378
|
+
// <html><head></head><body></body></html>, as in a browser
|
|
379
|
+
this.documentElement = this.appendChild(new Element('html', this))
|
|
380
|
+
this.head = this.documentElement.appendChild(new Element('head', this))
|
|
381
|
+
this.body = this.documentElement.appendChild(new Element('body', this))
|
|
382
|
+
}
|
|
383
|
+
createElement(t) {
|
|
384
|
+
return new Element(t, this)
|
|
385
|
+
}
|
|
386
|
+
createTextNode(d) {
|
|
387
|
+
return new Text(d, this)
|
|
388
|
+
}
|
|
389
|
+
createComment(d) {
|
|
390
|
+
return new Comment(d, this)
|
|
391
|
+
}
|
|
392
|
+
createDocumentFragment() {
|
|
393
|
+
return new Fragment(this)
|
|
394
|
+
}
|
|
395
|
+
getElementById(id) {
|
|
396
|
+
const walk = n => {
|
|
397
|
+
for (const c of n.childNodes) {
|
|
398
|
+
if (c.nodeType !== 1) continue
|
|
399
|
+
if (c.getAttribute('id') === id) return c
|
|
400
|
+
const r = walk(c)
|
|
401
|
+
if (r) return r
|
|
402
|
+
}
|
|
403
|
+
return null
|
|
404
|
+
}
|
|
405
|
+
return walk(this)
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Selector helpers for querySelectorAll: a compound is a list of tests on one element.
|
|
410
|
+
function compound(str) {
|
|
411
|
+
const tests = []
|
|
412
|
+
const rx = /^(?:([\w-]+)|\.([\w-]+)|#([\w-]+)|\[([\w-]+)(?:=["']?([^"'\]]*)["']?)?\]|:not\(([^)]*)\))/
|
|
413
|
+
let rest = str
|
|
414
|
+
while (rest) {
|
|
415
|
+
const m = rx.exec(rest)
|
|
416
|
+
if (!m) throw Error('unsupported selector: ' + str)
|
|
417
|
+
rest = rest.slice(m[0].length)
|
|
418
|
+
if (m[1]) tests.push(e => e.tagName === m[1].toUpperCase())
|
|
419
|
+
else if (m[2]) tests.push(e => (e.getAttribute('class') || '').split(/\s+/).includes(m[2]))
|
|
420
|
+
else if (m[3]) tests.push(e => e.getAttribute('id') === m[3])
|
|
421
|
+
else if (m[4]) tests.push(e => e.hasAttribute(m[4]) && (m[5] == null || e.getAttribute(m[4]) === m[5]))
|
|
422
|
+
else {
|
|
423
|
+
const inner = compound(m[6])
|
|
424
|
+
tests.push(e => !inner(e))
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
return e => tests.every(t => t(e))
|
|
428
|
+
}
|
|
429
|
+
// chain = [c0, c1, …, cn]: the element matches cn and has ancestors (below root) matching the rest, in order.
|
|
430
|
+
function matchChain(el, chain, root) {
|
|
431
|
+
if (!chain[chain.length - 1](el)) return false
|
|
432
|
+
let i = chain.length - 2
|
|
433
|
+
for (let a = el.parentNode; i >= 0 && a && a !== root; a = a.parentNode) if (a.nodeType === 1 && chain[i](a)) i--
|
|
434
|
+
return i < 0
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
export function install() {
|
|
438
|
+
const doc = new Document()
|
|
439
|
+
// real window at the end of the event path (after document), as in the browser
|
|
440
|
+
const win = new Node(0, doc)
|
|
441
|
+
doc.defaultView = win
|
|
442
|
+
globalThis.window = win
|
|
443
|
+
globalThis.document = doc
|
|
444
|
+
globalThis.Event = Event
|
|
445
|
+
globalThis.MouseEvent = MouseEvent
|
|
446
|
+
globalThis.MutationObserver = MutationObserver
|
|
447
|
+
return doc
|
|
448
|
+
}
|
package/shim/probe.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// BRAINLLM runaway probe (INTERNALS §30.2, #91). Runs after the prerender, in Node on the DOM shim: each view or island
|
|
2
|
+
// is mounted with fake timers and stepped for N seconds of virtual time (default 30 s, ~200 ms real) with no user
|
|
3
|
+
// input. It samples, per component (`data-b` hash): DOM nodes; per signal: the length of the lists it holds; and the
|
|
4
|
+
// animation frames requested. Sustained growth with no input, or a requestAnimationFrame loop re-armed in every frame
|
|
5
|
+
// for the whole run, is a runaway: B0907 findings with what grew and how fast. Repeated bounded work (a clock or a
|
|
6
|
+
// stopwatch updating its text) is not: it does not grow and it is what such a component is for. brain-pack (BUILD) calls it and fails the
|
|
7
|
+
// build, doctor and test on any finding.
|
|
8
|
+
// const findings = await probe({ core, mount: root => core.mount(View, root, props), seconds: 30 })
|
|
9
|
+
// → [{ code: 'B0907', comp: '<hash>' | null, what: 'nodes' | 'list' | 'raf', growth: '+40 nodes/s', msg, fix }]
|
|
10
|
+
const TICK = 100 // ms of virtual time per step
|
|
11
|
+
const FIX = 'bound the growth (slice the list, stop the interval, clean up the timer in the fx) or move the work behind a user action'
|
|
12
|
+
|
|
13
|
+
export async function probe({ core, mount, seconds = 30, document: doc = globalThis.document }) {
|
|
14
|
+
const G = globalThis, N = core.sig(0).constructor, P = N.prototype
|
|
15
|
+
// ---- fake clock: timers, animation frames and Date.now move only when the probe steps ----
|
|
16
|
+
let now = 0, seq = 0
|
|
17
|
+
const timers = new Map() // id → { at, f, every }
|
|
18
|
+
const saved = { st: G.setTimeout, ct: G.clearTimeout, si: G.setInterval, ci: G.clearInterval, raf: G.requestAnimationFrame, caf: G.cancelAnimationFrame, dn: Date.now }
|
|
19
|
+
const add = (f, ms, every) => (timers.set(++seq, { at: now + Math.max(0, +ms || 0), f, every: every ? Math.max(1, +ms || 1) : 0 }), seq)
|
|
20
|
+
G.setTimeout = (f, ms, ...a) => add(() => f(...a), ms, false)
|
|
21
|
+
G.setInterval = (f, ms, ...a) => add(() => f(...a), ms, true)
|
|
22
|
+
G.clearTimeout = G.clearInterval = id => timers.delete(id)
|
|
23
|
+
G.requestAnimationFrame = f => add(() => (rafTicks.add(tickNo), f(now)), 16, false)
|
|
24
|
+
G.cancelAnimationFrame = id => timers.delete(id)
|
|
25
|
+
const t0 = saved.dn.call(Date)
|
|
26
|
+
Date.now = () => t0 + now
|
|
27
|
+
// ---- instrumentation: list lengths per signal, ticks with an animation frame ----
|
|
28
|
+
const lists = new Map() // signal → { last: length }
|
|
29
|
+
const rafTicks = new Set()
|
|
30
|
+
let tickNo = 0
|
|
31
|
+
const vd = Object.getOwnPropertyDescriptor(P, 'v')
|
|
32
|
+
Object.defineProperty(P, 'v', {
|
|
33
|
+
configurable: true,
|
|
34
|
+
get: vd.get,
|
|
35
|
+
set(x) {
|
|
36
|
+
vd.set.call(this, x)
|
|
37
|
+
if (Array.isArray(x)) (lists.get(this) || lists.set(this, []).get(this)).last = x.length
|
|
38
|
+
},
|
|
39
|
+
})
|
|
40
|
+
const flush = async () => {
|
|
41
|
+
for (let i = 0; i < 3; i++) await new Promise(r => queueMicrotask(r))
|
|
42
|
+
}
|
|
43
|
+
// nodes per component: each `data-b` root counts its elements, nested components count apart
|
|
44
|
+
const perComp = () => {
|
|
45
|
+
const out = {}
|
|
46
|
+
const walk = (el, h) => {
|
|
47
|
+
const own = el.getAttribute && el.getAttribute('data-b')
|
|
48
|
+
const at = own || h
|
|
49
|
+
if (at) out[at] = (out[at] || 0) + 1
|
|
50
|
+
for (const c of el.childNodes || []) if (c.nodeType === 1) walk(c, at)
|
|
51
|
+
}
|
|
52
|
+
walk(root, null)
|
|
53
|
+
return out
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const root = doc.createElement('div')
|
|
57
|
+
doc.body.appendChild(root)
|
|
58
|
+
const findings = []
|
|
59
|
+
let stop = null
|
|
60
|
+
try {
|
|
61
|
+
stop = mount(root)
|
|
62
|
+
await flush()
|
|
63
|
+
const samples = [] // per virtual second: { nodes: {hash: n}, lists: Map(signal → length) }
|
|
64
|
+
const steps = Math.round((seconds * 1000) / TICK)
|
|
65
|
+
for (let s = 0; s <= steps; s++) {
|
|
66
|
+
tickNo = s
|
|
67
|
+
now = s * TICK
|
|
68
|
+
// run every timer due by now, in time order (an interval re-arms itself)
|
|
69
|
+
for (;;) {
|
|
70
|
+
let next = null
|
|
71
|
+
for (const [id, t] of timers) if (t.at <= now && (!next || t.at < next[1].at)) next = [id, t]
|
|
72
|
+
if (!next) break
|
|
73
|
+
const [id, t] = next
|
|
74
|
+
if (t.every) t.at += t.every
|
|
75
|
+
else timers.delete(id)
|
|
76
|
+
try {
|
|
77
|
+
t.f()
|
|
78
|
+
} catch {} // a timer that throws is the error capture's business, not the probe's
|
|
79
|
+
}
|
|
80
|
+
await flush()
|
|
81
|
+
if (s % Math.round(1000 / TICK) === 0) samples.push({ nodes: perComp(), lists: new Map([...lists].map(([k, v]) => [k, v.last])) })
|
|
82
|
+
}
|
|
83
|
+
// ---- verdicts: growth in every second of the second half, and well above the start ----
|
|
84
|
+
const half = samples.slice(Math.floor(samples.length / 2))
|
|
85
|
+
const rising = vals => vals.length > 3 && vals.every((v, i) => i === 0 || v > vals[i - 1])
|
|
86
|
+
const hashes = new Set(samples.flatMap(x => Object.keys(x.nodes)))
|
|
87
|
+
for (const h of hashes) {
|
|
88
|
+
const v = half.map(x => x.nodes[h] || 0)
|
|
89
|
+
const all = samples.map(x => x.nodes[h] || 0)
|
|
90
|
+
if (rising(v) && all.at(-1) - all[0] >= 20) {
|
|
91
|
+
const rate = Math.round((all.at(-1) - all[0]) / seconds)
|
|
92
|
+
findings.push({ code: 'B0907', comp: h, what: 'nodes', growth: `+${rate} nodes/s`, msg: `component ${h} grows with no input: +${rate} nodes/s over ${seconds} s of virtual time`, fix: FIX })
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
for (const sig of lists.keys()) {
|
|
96
|
+
const v = half.map(x => x.lists.get(sig) ?? 0), all = samples.map(x => x.lists.get(sig) ?? 0)
|
|
97
|
+
if (rising(v) && all.at(-1) - all[0] >= 10) {
|
|
98
|
+
const rate = +((all.at(-1) - all[0]) / steps).toFixed(2)
|
|
99
|
+
findings.push({ code: 'B0907', comp: null, what: 'list', growth: `+${rate} item/tick`, msg: `a list grows with no input: +${rate} item/tick (${all[0]} → ${all.at(-1)} items) over ${seconds} s of virtual time`, fix: FIX })
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (rafTicks.size >= 0.95 * steps) findings.push({ code: 'B0907', comp: null, what: 'raf', growth: 'a frame every tick', msg: `a requestAnimationFrame loop re-arms itself in every frame with no input for ${seconds} s of virtual time`, fix: 'stop the loop when there is nothing to animate (or use a CSS / Web Animations transition)' })
|
|
103
|
+
} finally {
|
|
104
|
+
if (stop) stop()
|
|
105
|
+
root.remove()
|
|
106
|
+
Object.defineProperty(P, 'v', vd)
|
|
107
|
+
Object.assign(G, { setTimeout: saved.st, clearTimeout: saved.ct, setInterval: saved.si, clearInterval: saved.ci, requestAnimationFrame: saved.raf, cancelAnimationFrame: saved.caf })
|
|
108
|
+
Date.now = saved.dn
|
|
109
|
+
}
|
|
110
|
+
return findings
|
|
111
|
+
}
|
package/sw/brain-sw.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// BRAINLLM service worker (INTERNALS §35.2). `brain build` emits dist/sw.js as one line
|
|
2
|
+
// `self.BRAIN_BUILD = { hash, precache: [...], offline: '/_offline.html' | null }` followed by this file unchanged.
|
|
3
|
+
// Strategies: /_b/* cache-first (hashed, immutable) · navigations network-first with a 3 s timeout, then the cached
|
|
4
|
+
// page, then the offline page · other same-origin GETs (unhashed public/ files) stale-while-revalidate · /_r/, non-GET
|
|
5
|
+
// and other origins are never touched. Every build has one cache, `brain-<sig>`, holding its chunks, pages and public
|
|
6
|
+
// files (§35.7). A new worker takes over at once and keeps only the previous build's cache, and only for the chunks an
|
|
7
|
+
// open tab still asks for: navigations are answered from the current build alone, so nothing old is shown by mistake.
|
|
8
|
+
// After taking over it tells every tab `brain:build <sig>` (the router then does full loads). Caches that are not per
|
|
9
|
+
// build are named `brain:*`. (`sw: false` is a separate kill worker emitted by the build.)
|
|
10
|
+
const B = self.BRAIN_BUILD || { hash: 'dev', precache: [], offline: null }
|
|
11
|
+
const BASE = B.base || '/' // app served under a base path (§23 #148); precache/offline URLs already carry it
|
|
12
|
+
const BUILD = 'brain-' + B.hash, META = 'brain:meta'
|
|
13
|
+
const NAV_TIMEOUT = 3000
|
|
14
|
+
// responses marked personal or uncacheable are never stored
|
|
15
|
+
const storable = r => r.ok && !/no-store|private/i.test(r.headers.get('cache-control') || '')
|
|
16
|
+
const OFFLINE =
|
|
17
|
+
'<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">' +
|
|
18
|
+
'<title>Offline</title><main style="font:16px/1.5 system-ui;max-width:32rem;margin:15vh auto;padding:0 16px">' +
|
|
19
|
+
'<h1>You are offline</h1><p>This page has not been visited yet on this device. Reconnect and try again.</p></main></html>'
|
|
20
|
+
|
|
21
|
+
self.addEventListener('install', e => {
|
|
22
|
+
e.waitUntil(
|
|
23
|
+
(async () => {
|
|
24
|
+
const c = await caches.open(BUILD)
|
|
25
|
+
// one failing URL must not abort the install: cache what answers, the rest is cached on first use
|
|
26
|
+
// default cache mode: the page just filled the HTTP cache with these immutable files, so this costs no bytes
|
|
27
|
+
await Promise.allSettled(B.precache.map(u => fetch(u).then(r => r.ok && c.put(u, r))))
|
|
28
|
+
await self.skipWaiting()
|
|
29
|
+
})()
|
|
30
|
+
)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
self.addEventListener('activate', e => {
|
|
34
|
+
e.waitUntil(
|
|
35
|
+
(async () => {
|
|
36
|
+
if (self.registration.navigationPreload) await self.registration.navigationPreload.enable()
|
|
37
|
+
// generations: [previous, current]; every other build cache is deleted
|
|
38
|
+
const meta = await caches.open(META)
|
|
39
|
+
const r = await meta.match('/__brain_generations')
|
|
40
|
+
const gens = r ? await r.json() : []
|
|
41
|
+
const keep = [...gens.filter(g => g !== BUILD).slice(-1), BUILD]
|
|
42
|
+
await meta.put('/__brain_generations', new Response(JSON.stringify(keep)))
|
|
43
|
+
for (const k of await caches.keys()) if (k.startsWith('brain-') && !keep.includes(k)) await caches.delete(k)
|
|
44
|
+
await self.clients.claim()
|
|
45
|
+
for (const c of await self.clients.matchAll({ type: 'window', includeUncontrolled: true })) c.postMessage('brain:build ' + B.hash)
|
|
46
|
+
})()
|
|
47
|
+
)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
self.addEventListener('fetch', e => {
|
|
51
|
+
const req = e.request
|
|
52
|
+
if (req.method !== 'GET') return
|
|
53
|
+
const url = new URL(req.url)
|
|
54
|
+
if (url.origin !== self.location.origin || url.pathname.startsWith(BASE + '_r/') || url.pathname === BASE + 'sw.js') return
|
|
55
|
+
// offline: false (B.offline null) → navigations go straight to the network: no page cache, no offline page
|
|
56
|
+
if (req.mode === 'navigate') return B.offline ? e.respondWith(navigate(e)) : undefined
|
|
57
|
+
if (url.pathname.startsWith(BASE + '_b/')) return e.respondWith(hashed(req))
|
|
58
|
+
// only files the page itself loads (img, style, script, font, manifest…); fetch()/XHR data is never touched (§35:
|
|
59
|
+
// data is not cached by default)
|
|
60
|
+
if (req.destination) e.respondWith(revalidate(e))
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
// cache-first: any generation may hold it (an old tab asks for old chunks); misses are stored in the current build
|
|
64
|
+
async function hashed(req) {
|
|
65
|
+
const hit = await caches.match(req)
|
|
66
|
+
if (hit) return hit
|
|
67
|
+
const r = await fetch(req)
|
|
68
|
+
if (storable(r)) (await caches.open(BUILD)).put(req, r.clone())
|
|
69
|
+
return r
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function navigate(e) {
|
|
73
|
+
const net = (async () => {
|
|
74
|
+
const r = (await e.preloadResponse) || (await fetch(e.request))
|
|
75
|
+
if (storable(r) && (r.headers.get('content-type') || '').includes('text/html')) {
|
|
76
|
+
const copy = r.clone()
|
|
77
|
+
e.waitUntil(caches.open(BUILD).then(c => c.put(e.request, copy)))
|
|
78
|
+
}
|
|
79
|
+
return r
|
|
80
|
+
})()
|
|
81
|
+
e.waitUntil(net.catch(() => {}))
|
|
82
|
+
const timeout = new Promise(ok => setTimeout(() => ok(null), NAV_TIMEOUT))
|
|
83
|
+
const first = await Promise.race([net, timeout]).catch(() => null)
|
|
84
|
+
if (first) return first
|
|
85
|
+
const cached = await caches.match(e.request, { cacheName: BUILD }) // never a page of the previous build
|
|
86
|
+
if (cached) return cached
|
|
87
|
+
// nothing cached: a slow network is still better than the offline page
|
|
88
|
+
try {
|
|
89
|
+
return await net
|
|
90
|
+
} catch {
|
|
91
|
+
const off = B.offline && (await caches.match(B.offline, { cacheName: BUILD }))
|
|
92
|
+
return off || new Response(OFFLINE, { status: 503, headers: { 'content-type': 'text/html; charset=utf-8' } })
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// stale-while-revalidate for unhashed same-origin files
|
|
97
|
+
async function revalidate(e) {
|
|
98
|
+
const c = await caches.open(BUILD)
|
|
99
|
+
const hit = await c.match(e.request)
|
|
100
|
+
const net = fetch(e.request).then(r => (storable(r) && c.put(e.request, r.clone()), r))
|
|
101
|
+
e.waitUntil(net.catch(() => {}))
|
|
102
|
+
return hit || net
|
|
103
|
+
}
|