@christianirack/brainllm-runtime 0.6.1 → 0.8.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-data.js +5 -2
- package/brain-env.js +8 -6
- package/brain-form.js +46 -36
- package/brain-inspect.js +19 -6
- package/brain-island.js +2 -1
- package/brain-lazy.js +2 -0
- package/brain-links.js +37 -0
- package/brain-live.js +21 -5
- package/brain-observe.js +120 -0
- package/brain-router.js +224 -178
- package/brain-schema.js +41 -9
- package/brain-trace.js +269 -0
- package/brain-vitals.js +60 -0
- package/package.json +1 -1
- package/server/llm.mjs +199 -0
- package/server/native-sockets.mjs +13 -10
- package/server/server.mjs +663 -105
- package/shim/dom.js +42 -7
- package/sw/brain-sw.js +17 -9
- package/ui/error-state.brain +1 -1
package/brain-data.js
CHANGED
|
@@ -379,9 +379,12 @@ async function once(u, M, body, sg, pri, up) {
|
|
|
379
379
|
const ra = h('retry-after'), retryAfter = ra == null ? undefined : isNaN(ra) ? Math.max(0, (Date.parse(ra) - Date.now()) / 1e3) : +ra
|
|
380
380
|
if (st === 401 && P.login && G.location && !location.pathname.endsWith(P.login)) location.assign(appBase() + P.login + '?next=' + encodeURIComponent(location.pathname + location.search))
|
|
381
381
|
const native = j && j.schemaVersion === 1 && j.error && typeof j.error === 'object' && typeof j.error.code === 'string' && /^[A-Z][A-Z0-9_]{0,63}$/.test(j.error.code)
|
|
382
|
-
const
|
|
382
|
+
const fields = native && st === 422 ? j.error.fields : j && j.errors
|
|
383
|
+
// "highlighted fields" only when there are fields to highlight. The backend's `fix` is for the developer (error.fix),
|
|
384
|
+
// never visitor text: it is English and names what the app should change.
|
|
385
|
+
const generic = st === 401 ? 'Authentication failed. Sign in again.' : st === 403 ? 'You do not have permission to continue.' : st === 422 ? (fields && Object.keys(fields).length ? 'Check the highlighted fields.' : 'The server rejected this request. Check it and try again.') : st === 429 ? 'Too many requests. Wait before trying again.' : `Request failed (${st}).`
|
|
383
386
|
const message = !native && st < 500 && typeof j?.error === 'string' && j.error.length <= 512 && !/[\r\n]|(?:TypeError|ReferenceError|SyntaxError|Error):/.test(j.error) ? j.error : generic
|
|
384
|
-
throw new ApiError('http', message, { status: st, errors:
|
|
387
|
+
throw new ApiError('http', message, { status: st, errors: fields, retryAfter, id: h('x-request-id') || undefined, plain: message === generic, ...(native ? { backendCode: j.error.code, fix: typeof j.error.fix === 'string' ? j.error.fix.slice(0,1024) : undefined } : {}) })
|
|
385
388
|
}
|
|
386
389
|
|
|
387
390
|
// multipart upload with progress (fetch has no upload progress)
|
package/brain-env.js
CHANGED
|
@@ -125,7 +125,8 @@ export function portal(target, f) {
|
|
|
125
125
|
return []
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
-
// route() → { path, params, query }, read-only and reactive (each a signal read)
|
|
128
|
+
// route() → { path, params, query, navigating }, read-only and reactive (each a signal read); navigating is true while
|
|
129
|
+
// a client navigation is on its way (brain-router passes it as __brainRouted(true|false)): path without the base path
|
|
129
130
|
// (<meta name="brain-base">), query from the URL, params from the page's <meta name="brain-params" content='{"id":"7"}'>
|
|
130
131
|
// (the server knows the route's pattern; the client does not). brain-router calls globalThis.__brainRouted after every
|
|
131
132
|
// client navigation. While the server renders, the values come from globalThis.__brainRoute (server.mjs, prerender).
|
|
@@ -148,13 +149,14 @@ const read = () => {
|
|
|
148
149
|
}
|
|
149
150
|
export function route() {
|
|
150
151
|
const s = G.__brainRoute
|
|
151
|
-
if (s) return { path: s.path, params: s.params || {}, query: s.query || {} }
|
|
152
|
+
if (s) return { path: s.path, params: s.params || {}, query: s.query || {}, navigating: false }
|
|
152
153
|
if (!cur) {
|
|
153
|
-
const S = read().map(v => sig(v))
|
|
154
|
-
cur = { get path() { return S[0].v }, get params() { return S[1].v }, get query() { return S[2].v } }
|
|
155
|
-
G.__brainRouted =
|
|
154
|
+
const S = [...read(), false].map(v => sig(v))
|
|
155
|
+
cur = { get path() { return S[0].v }, get params() { return S[1].v }, get query() { return S[2].v }, get navigating() { return S[3].v } }
|
|
156
|
+
G.__brainRouted = on => {
|
|
157
|
+
if (on !== undefined) return void (S[3].v = !!on)
|
|
156
158
|
const v = read()
|
|
157
|
-
|
|
159
|
+
v.forEach((x, i) => JSON.stringify(untrack(() => S[i].v)) !== JSON.stringify(x) && (S[i].v = x)) // only what changed
|
|
158
160
|
}
|
|
159
161
|
}
|
|
160
162
|
return cur
|
package/brain-form.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// BRAINLLM forms (INTERNALS §14). `let f = form(schema, { submit: async (v) => … })` →
|
|
2
2
|
// bind:value={f.v.email} · {f.err.email} · f.busy · f.error (form-level) · f.ok · on:submit={f.submit}
|
|
3
|
-
// Schema mini-language per field: `str num bool email url date` + `!` required + `<=N >=N` (length for str and
|
|
4
|
-
// value for num) + `[T]` list of T, or a nested schema object (a type from types.jsbrain).
|
|
3
|
+
// Schema mini-language per field: `str num bool email url date tel file` + `!` required + `<=N >=N` (length for str and
|
|
4
|
+
// lists, value for num, bytes for file) + `[T]` list of T, or a nested schema object (a type from types.jsbrain).
|
|
5
5
|
// Validation: a field is validated when it loses focus and every field on submit; errors show only after that.
|
|
6
6
|
// The element of each field is found without ids: the control that has focus while its value is written (the
|
|
7
7
|
// `bind:` input handler), or on submit `form.elements[name]`. On it the runtime keeps `aria-invalid` and, when the
|
|
@@ -28,8 +28,8 @@ const GENERIC = 'Something went wrong. Try again.' // the text callForm (server.
|
|
|
28
28
|
const go = u => {
|
|
29
29
|
const G = globalThis
|
|
30
30
|
if (G.__brainNavigate) return G.__brainNavigate(u)
|
|
31
|
-
const m = D
|
|
32
|
-
G.location
|
|
31
|
+
const m = D?.querySelector('meta[name=brain-base]'), b = m?.getAttribute('content') || '/'
|
|
32
|
+
G.location?.assign(b !== '/' && !(u + '/').startsWith(b) ? b + u.slice(1) : u)
|
|
33
33
|
}
|
|
34
34
|
// forms with unsaved changes: brain-router asks before a client navigation, the browser before unload. A form being
|
|
35
35
|
// submitted does not ask (#299): the user chose to save, and a submit() that navigates once the server accepted the
|
|
@@ -42,14 +42,11 @@ if (globalThis.addEventListener && !globalThis.__brainDirtyHook) {
|
|
|
42
42
|
})
|
|
43
43
|
}
|
|
44
44
|
const empty = v => v == null || typeof v === 'string' && v.trim() === '' || Array.isArray(v) && !v.length
|
|
45
|
-
const init0 = r => (r.list ? [] : r.t === 'bool' ? false : '')
|
|
45
|
+
const init0 = r => (r.list ? [] : r.t === 'bool' ? false : r.t === 'file' ? null : '')
|
|
46
46
|
const out = (r, v) => {
|
|
47
47
|
if (r.list) {
|
|
48
48
|
if (!Array.isArray(v)) return []
|
|
49
|
-
|
|
50
|
-
const numbers = new Array(v.length)
|
|
51
|
-
for (let i = 0; i < v.length; i++) numbers[i] = Number(v[i])
|
|
52
|
-
return numbers
|
|
49
|
+
return r.t === 'num' ? Array.from(v, Number) : v
|
|
53
50
|
}
|
|
54
51
|
return r.t === 'num' ? (empty(v) ? null : Number(v)) : r.t === 'bool' ? !!v : v
|
|
55
52
|
}
|
|
@@ -62,12 +59,12 @@ function posted(id) {
|
|
|
62
59
|
if (g) return g[id] || null
|
|
63
60
|
if (SEEN === undefined)
|
|
64
61
|
try {
|
|
65
|
-
const el = D
|
|
62
|
+
const el = D?.getElementById('brain-forms')
|
|
66
63
|
SEEN = el ? JSON.parse(el.textContent) : null
|
|
67
64
|
} catch {
|
|
68
65
|
SEEN = null
|
|
69
66
|
}
|
|
70
|
-
return
|
|
67
|
+
return SEEN?.[id] || null
|
|
71
68
|
}
|
|
72
69
|
|
|
73
70
|
export function form(schema, o = {}) {
|
|
@@ -98,12 +95,12 @@ function compiledForm(schema, o, types, validate, remote) {
|
|
|
98
95
|
}
|
|
99
96
|
// Only public/dynamic or check-configured forms retain this optional capability.
|
|
100
97
|
function remoteCheck(o, x, checking) {
|
|
101
|
-
const c = o.check
|
|
98
|
+
const c = o.check?.[x.path], v = untrack(() => x.s.v)
|
|
102
99
|
if (!c || untrack(() => x.e.v)) return Promise.resolve(untrack(() => x.e.v))
|
|
103
100
|
checking.v++
|
|
104
101
|
return Promise.resolve()
|
|
105
102
|
.then(() => c(v))
|
|
106
|
-
.then(m => m || '', e => (e
|
|
103
|
+
.then(m => m || '', e => (e?.kind === 'aborted' ? '' : e?.message || msg('form.check_failed', 'Could not check this value.')))
|
|
107
104
|
.then(m => {
|
|
108
105
|
if (untrack(() => x.s.v) === v) x.e.v = m
|
|
109
106
|
return untrack(() => x.e.v)
|
|
@@ -123,7 +120,7 @@ function createForm(schema, o, readRule, validateRule, remote) {
|
|
|
123
120
|
for (const k of Object.keys(sch)) {
|
|
124
121
|
const r = readRule(sch[k], k), path = base ? base + '.' + k : k
|
|
125
122
|
if (r.t === 'obj') {
|
|
126
|
-
const sub = build(r.s, path,
|
|
123
|
+
const sub = build(r.s, path, init?.[k] || {})
|
|
127
124
|
Object.defineProperty(v, k, { enumerable: true, value: sub.v })
|
|
128
125
|
Object.defineProperty(err, k, { enumerable: true, value: sub.err })
|
|
129
126
|
continue
|
|
@@ -136,7 +133,7 @@ function createForm(schema, o, readRule, validateRule, remote) {
|
|
|
136
133
|
get: () => x.s.v,
|
|
137
134
|
set: val => {
|
|
138
135
|
x.s.v = val
|
|
139
|
-
track(x, D
|
|
136
|
+
track(x, D?.activeElement)
|
|
140
137
|
if (x.touched) validate(x) // already shown: keep the message live while fixing it
|
|
141
138
|
},
|
|
142
139
|
})
|
|
@@ -151,12 +148,14 @@ function createForm(schema, o, readRule, validateRule, remote) {
|
|
|
151
148
|
if (!el || x.el || el.$ff || !el.isConnected || !/^(INPUT|SELECT|TEXTAREA)$/.test(el.tagName || '')) return
|
|
152
149
|
x.el = el
|
|
153
150
|
el.$ff = x
|
|
151
|
+
// a form holding a password (sign in, sign up) is not work to lose: leaving it never asks
|
|
152
|
+
if (el.form?.querySelector('[type=password]')) DIRTY.delete(f)
|
|
154
153
|
el.addEventListener('focusout', () => touch(x))
|
|
155
154
|
I.root(() => fx(() => {
|
|
156
155
|
const e = shown.v >= 0 && x.touched && x.e.v // reads shown first: re-runs when the field gets touched
|
|
157
156
|
if (e) el.setAttribute('aria-invalid', 'true')
|
|
158
157
|
else el.removeAttribute('aria-invalid')
|
|
159
|
-
const d = D
|
|
158
|
+
const d = D?.getElementById(fid(x.path))
|
|
160
159
|
if (e && d) el.setAttribute('aria-describedby', fid(x.path))
|
|
161
160
|
else if (el.getAttribute('aria-describedby') === fid(x.path)) el.removeAttribute('aria-describedby')
|
|
162
161
|
}), owner)
|
|
@@ -171,11 +170,11 @@ function createForm(schema, o, readRule, validateRule, remote) {
|
|
|
171
170
|
|
|
172
171
|
// A form posted without JS (§38) comes back from the server with its values and messages: start from them, already
|
|
173
172
|
// shown, both while the server renders the page and when the page hydrates
|
|
174
|
-
const st = posted(o.submit
|
|
173
|
+
const st = posted(o.submit?.id)
|
|
175
174
|
const { v, err } = build(schema, '', st ? st.values : o.init)
|
|
176
175
|
if (st) {
|
|
177
|
-
|
|
178
|
-
error.v = st.error
|
|
176
|
+
mark(st.errors || {})
|
|
177
|
+
if (st.error) error.v = st.error
|
|
179
178
|
}
|
|
180
179
|
const now = () => JSON.stringify(F.map(x => x.s.v))
|
|
181
180
|
saved.v = untrack(now)
|
|
@@ -185,17 +184,21 @@ function createForm(schema, o, readRule, validateRule, remote) {
|
|
|
185
184
|
for (const x of F) {
|
|
186
185
|
const ks = x.path.split('.')
|
|
187
186
|
let t = r
|
|
188
|
-
for (const k of ks.slice(0, -1)) t = t[k]
|
|
187
|
+
for (const k of ks.slice(0, -1)) t = t[k] ||= {}
|
|
189
188
|
t[ks.at(-1)] = out(x.r, untrack(() => x.s.v))
|
|
190
189
|
}
|
|
191
190
|
return r
|
|
192
191
|
}
|
|
192
|
+
// errors with no field on this form never vanish: the first one (or a plain sentence) becomes f.error
|
|
193
193
|
function mark(errors) {
|
|
194
194
|
batch(() => {
|
|
195
|
+
let hit, left
|
|
195
196
|
for (const p in errors) {
|
|
196
197
|
const x = byPath[p]
|
|
197
|
-
if (x) (x.e.v = String(errors[p])), (x.touched = true)
|
|
198
|
+
if (x) (x.e.v = String(errors[p])), (x.touched = hit = true)
|
|
199
|
+
else left ??= errors[p]
|
|
198
200
|
}
|
|
201
|
+
if (!hit) error.v = String(left ?? msg('form.rejected', 'This could not be saved. Check it and try again.'))
|
|
199
202
|
shown.v++
|
|
200
203
|
})
|
|
201
204
|
}
|
|
@@ -253,9 +256,15 @@ function createForm(schema, o, readRule, validateRule, remote) {
|
|
|
253
256
|
})
|
|
254
257
|
},
|
|
255
258
|
async submit(e) {
|
|
256
|
-
|
|
259
|
+
e?.preventDefault?.()
|
|
257
260
|
if (untrack(() => busy.v)) return // no re-entry: a double click sends once
|
|
258
|
-
const fm = e
|
|
261
|
+
const fm = e?.target?.elements ? e.target : null, was = D?.activeElement
|
|
262
|
+
// the first rejected field, or back to the control the browser took focus from (it fell to <body>, the keyboard too)
|
|
263
|
+
const aim = () => {
|
|
264
|
+
const x = F.find(x => x.el && x.touched && untrack(() => x.e.v))
|
|
265
|
+
if (x) x.el.focus()
|
|
266
|
+
else if (D?.activeElement == D?.body) was?.focus()
|
|
267
|
+
}
|
|
259
268
|
let first = null
|
|
260
269
|
batch(() => {
|
|
261
270
|
for (const x of F) {
|
|
@@ -268,39 +277,40 @@ function createForm(schema, o, readRule, validateRule, remote) {
|
|
|
268
277
|
shown.v++
|
|
269
278
|
})
|
|
270
279
|
if (o.validate && !first) {
|
|
271
|
-
const cross = o.validate(values()) // cross-field rules: { field: msg }
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
280
|
+
const cross = o.validate(values()) // cross-field rules: { field: msg }; an empty message means valid
|
|
281
|
+
const failed = cross && Object.fromEntries(Object.entries(cross).filter(([, message]) => message))
|
|
282
|
+
if (failed && Object.keys(failed).length) {
|
|
283
|
+
mark(failed)
|
|
284
|
+
return void aim()
|
|
276
285
|
}
|
|
277
286
|
}
|
|
278
|
-
if (first) return void (
|
|
287
|
+
if (first) return void aim()
|
|
279
288
|
busy.v = true
|
|
280
289
|
if (fm) fm.setAttribute('aria-busy', 'true') // assistive tech hears the form is working
|
|
281
290
|
try {
|
|
282
291
|
if (remote && o.check) {
|
|
283
292
|
await Promise.all(F.map(x => remote(o, x, checking)))
|
|
284
|
-
|
|
285
|
-
if (first) return void (shown.v++, first.el && first.el.focus && first.el.focus())
|
|
293
|
+
if (F.some(x => untrack(() => x.e.v))) return void shown.v++
|
|
286
294
|
}
|
|
287
295
|
if (!o.submit) return
|
|
288
296
|
const r = await o.submit(values())
|
|
289
|
-
if (r
|
|
297
|
+
if (r?.errors) mark(r.errors)
|
|
290
298
|
else {
|
|
291
299
|
saved.v = untrack(now) // saved: no longer dirty
|
|
292
300
|
// `return { redirect: '/x' }` from the server function: the same page the 303 reaches without JS (§38)
|
|
293
|
-
if (
|
|
301
|
+
if (typeof r?.redirect === 'string' && r.redirect[0] === '/' && r.redirect[1] !== '/') go(r.redirect)
|
|
294
302
|
}
|
|
295
303
|
return r
|
|
296
304
|
} catch (x) {
|
|
297
|
-
if (x
|
|
305
|
+
if (x?.errors) mark(x.errors)
|
|
298
306
|
// a server failure reads the same as without JS: 4xx and the author's own errors keep their message; 5xx and a
|
|
299
|
-
// TypeError (the network dropped, or a bug) get the generic one
|
|
300
|
-
|
|
307
|
+
// TypeError (the network dropped, or a bug) get the generic one; the runtime's own English sentence (`plain`) gives
|
|
308
|
+
// way to the app's translated form.rejected
|
|
309
|
+
else if (x?.kind !== 'aborted') error.v = (!(x?.status >= 500) && x?.name !== 'TypeError' && (x?.plain ? msg('form.rejected', x.message) : x?.message)) || msg('form.error', GENERIC)
|
|
301
310
|
} finally {
|
|
302
311
|
busy.v = false
|
|
303
312
|
if (fm) fm.removeAttribute('aria-busy')
|
|
313
|
+
queueMicrotask(aim) // after the flush re-enables what `disabled={f.busy}` disabled
|
|
304
314
|
}
|
|
305
315
|
},
|
|
306
316
|
}
|
package/brain-inspect.js
CHANGED
|
@@ -261,6 +261,9 @@ const watched = []
|
|
|
261
261
|
// No deref(): reading a WeakRef keeps the target alive for the job (KeepDuringJob) and, checking every
|
|
262
262
|
// second, the inspector would cause the leak it measures. The FinalizationRegistry marks the record on collection.
|
|
263
263
|
const fin = G.FinalizationRegistry ? new FinalizationRegistry(w => (w.dead = 1)) : null
|
|
264
|
+
// Where a pending request went, without origin or query: evidence for a fix, never a value.
|
|
265
|
+
const pendingUrls = () => [...inflight.values()].slice(0, 3).map(r => `${r.m} ${r.url.replace(/^[a-z]+:\/\/[^/]+/i, '').split(/[?#]/)[0]}`)
|
|
266
|
+
const SETTLE_MS = 60000
|
|
264
267
|
function watch(d) {
|
|
265
268
|
if (!fin) return
|
|
266
269
|
// its first DOM node: an app that keeps a detached node (a module array, a cache) is the leak to catch. The browser
|
|
@@ -268,7 +271,7 @@ function watch(d) {
|
|
|
268
271
|
// collecting. Unmounted nodes no longer point at their instance (brain-dev, brain-guard), so a kept node is small.
|
|
269
272
|
const target = (d.n && d.n.find(x => x.nodeType === 1)) || d.r
|
|
270
273
|
if (!target) return
|
|
271
|
-
const w = { dead: 0, h: d.h, name: nameOf(d), file: (d.C && d.C.f) || null, at: gcs, fx: (d.r && b602ByInst.get(d.r)) || [] }
|
|
274
|
+
const w = { dead: 0, h: d.h, name: nameOf(d), file: (d.C && d.C.f) || null, at: gcs, fx: (d.r && b602ByInst.get(d.r)) || [], since: now(), net: [...inflight.keys()], urls: pendingUrls() }
|
|
272
275
|
fin.register(target, w)
|
|
273
276
|
watched.push(w)
|
|
274
277
|
}
|
|
@@ -276,28 +279,37 @@ function checkLeaks() {
|
|
|
276
279
|
for (let j = watched.length - 1; j >= 0; j--) {
|
|
277
280
|
const w = watched[j]
|
|
278
281
|
if (w.dead) watched.splice(j, 1)
|
|
282
|
+
// A request from before the unmount may be what holds it: judge after it settles (bounded, for a long poll).
|
|
283
|
+
else if (w.net.some(id => inflight.has(id)) && now() - w.since < SETTLE_MS) w.at = gcs
|
|
279
284
|
else if (gcs - w.at >= 2) {
|
|
280
285
|
watched.splice(j, 1)
|
|
281
|
-
|
|
286
|
+
const waited = w.urls.length ? `${w.urls.length > 2 ? 'several requests' : w.urls.join(', ')} ${w.urls.length > 1 ? 'were' : 'was'} in flight when it unmounted; ` : ''
|
|
287
|
+
leak({ code: 'B0601', h: w.h, name: w.name, file: w.file, msg: `unmounted ${w.name} instance still alive after 2 GC cycles`, fix: w.fx.length ? 'add cleanup to the listed fx: they retain the instance' : `${waited}look for what still references its nodes or signals: an in-flight promise (a pending server call, \`.then\` or \`await\`) or timer that writes to the component, global listeners, caches, module closures`, fx: w.fx })
|
|
282
288
|
}
|
|
283
289
|
}
|
|
284
290
|
}
|
|
285
291
|
|
|
286
292
|
// ---------- network: fetch and XHR ----------
|
|
287
293
|
let rid = 1
|
|
294
|
+
// Requests without an answer yet, id → { m, url, stop }: a `.then` or `await` waiting on one keeps what it closes over
|
|
295
|
+
// alive, so an instance unmounted meanwhile is not a verdict yet (B0601). `stop` is absent when the caller holds its own signal.
|
|
296
|
+
const inflight = new Map()
|
|
288
297
|
function patchNet() {
|
|
289
298
|
if (O.fetch && !G.fetch.$bi) {
|
|
290
299
|
G.fetch = function (input, init) {
|
|
291
300
|
const c = ctx(), s = site(), t0 = pnow()
|
|
292
301
|
const r = { k: 'net', id: rid++, kind: 'fetch', m: (init && init.method) || (input && input.method) || 'GET', url: String((input && input.url) || input), own: own(c), line: s.line, foreign: s.foreign, at: now(), st: null, ms: null, b: null }
|
|
293
302
|
push(r)
|
|
294
|
-
|
|
303
|
+
const ac = G.AbortController && !(init && init.signal) && !(input && input.signal) ? new AbortController() : null
|
|
304
|
+
inflight.set(r.id, { m: r.m, url: r.url, stop: ac && (() => ac.abort()) })
|
|
305
|
+
return O.fetch.call(this, input, ac ? { ...init, signal: ac.signal } : init).then(
|
|
295
306
|
res => {
|
|
307
|
+
inflight.delete(r.id)
|
|
296
308
|
const len = res.headers && res.headers.get && res.headers.get('content-length')
|
|
297
309
|
push({ k: 'net', id: r.id, st: res.status, ms: +(pnow() - t0).toFixed(1), b: len == null ? null : +len })
|
|
298
310
|
return res
|
|
299
311
|
},
|
|
300
|
-
err => (push({ k: 'net', id: r.id, st: 0, ms: +(pnow() - t0).toFixed(1), err: String(err) }), Promise.reject(err))
|
|
312
|
+
err => (inflight.delete(r.id), push({ k: 'net', id: r.id, st: 0, ms: +(pnow() - t0).toFixed(1), err: String(err) }), Promise.reject(err))
|
|
301
313
|
)
|
|
302
314
|
}
|
|
303
315
|
G.fetch.$bi = 1
|
|
@@ -313,7 +325,8 @@ function patchNet() {
|
|
|
313
325
|
X.send = function (body) {
|
|
314
326
|
const c = ctx(), s = site(), t0 = pnow(), r = { k: 'net', id: rid++, kind: 'xhr', ...(this.$r || {}), own: own(c), line: s.line, foreign: s.foreign, at: now(), st: null, ms: null, b: null }
|
|
315
327
|
push(r)
|
|
316
|
-
|
|
328
|
+
inflight.set(r.id, { m: r.m, url: r.url, stop: () => this.abort() })
|
|
329
|
+
this.addEventListener('loadend', () => (inflight.delete(r.id), push({ k: 'net', id: r.id, st: this.status, ms: +(pnow() - t0).toFixed(1), b: typeof this.responseText === 'string' ? this.responseText.length : null })))
|
|
317
330
|
return send.call(this, body)
|
|
318
331
|
}
|
|
319
332
|
}
|
|
@@ -695,7 +708,7 @@ export function inspect(o) {
|
|
|
695
708
|
G.document.addEventListener.call(G.document, 'keydown', e => e.altKey && e.shiftKey && e.code === 'KeyB' && toggle())
|
|
696
709
|
return api
|
|
697
710
|
}
|
|
698
|
-
const api = { tick, drain, comps, onLoaf, onEvents, leaks: () => leaks.slice(), timers: () => [...timers.values()], toggle: () => toggle() }
|
|
711
|
+
const api = { tick, drain, comps, onLoaf, onEvents, leaks: () => leaks.slice(), timers: () => [...timers.values()], toggle: () => toggle(), inflight: () => [...inflight.values()].map(r => `${r.m} ${r.url}`), abortInflight: () => { for (const r of inflight.values()) if (r.stop) r.stop() } }
|
|
699
712
|
|
|
700
713
|
// ---------- mini panel (Alt+Shift+B): heap, leaks and link to the full debugger (§9.1) ----------
|
|
701
714
|
let panel = null
|
package/brain-island.js
CHANGED
|
@@ -98,7 +98,8 @@ export function hydrate(r = document, rt) {
|
|
|
98
98
|
e.stopPropagation()
|
|
99
99
|
;(el.$b || (el.$b = resume(el, s))).then(
|
|
100
100
|
n => (n = at(n, path)) && n.dispatchEvent(new e.constructor(t, e)),
|
|
101
|
-
|
|
101
|
+
// a real link still goes where it points (last navigation wins over _cf's reload); the rest is reported once
|
|
102
|
+
x => { const a = e.target.closest('a[href]'); a && (a.getAttribute('href') || '#')[0] != '#' ? location.assign(a.href) : I.oops(x, null, 'island') }
|
|
102
103
|
)
|
|
103
104
|
},
|
|
104
105
|
true
|
package/brain-lazy.js
CHANGED
|
@@ -44,6 +44,7 @@ export function lazy(el, imp, mode, props = {}) {
|
|
|
44
44
|
})
|
|
45
45
|
.catch(e => {
|
|
46
46
|
el.$l = null
|
|
47
|
+
clicked = null // a retry must not replay a click from before the failure
|
|
47
48
|
if (dead) return
|
|
48
49
|
el.setAttribute('aria-busy', 'false')
|
|
49
50
|
retry()
|
|
@@ -64,6 +65,7 @@ export function lazy(el, imp, mode, props = {}) {
|
|
|
64
65
|
if (e.type === 'keydown') {
|
|
65
66
|
if (e.key !== 'Enter' && e.key !== ' ') return
|
|
66
67
|
e.preventDefault()
|
|
68
|
+
clicked = new Event('click', { bubbles: true, cancelable: true }) // the key press is what the user asked for
|
|
67
69
|
}
|
|
68
70
|
if (e.type === 'click') (clicked = e), e.preventDefault(), e.stopPropagation()
|
|
69
71
|
go()
|
package/brain-links.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Internal links keep the page's language (INTERNALS §37). The views compile once for every language, so their
|
|
2
|
+
// `href="/about"` reaches a page in `es` unprefixed: `brain build` rewrites the HTML it writes, and this module, loaded
|
|
3
|
+
// only by pages outside the default language, rewrites what the views mount afterwards. A link with `hreflang` points
|
|
4
|
+
// at a language on purpose and is left alone. go()/navigate() read the same table through __brainHref.
|
|
5
|
+
const G = globalThis
|
|
6
|
+
const SEL = 'a[href],form[action]'
|
|
7
|
+
let tables
|
|
8
|
+
|
|
9
|
+
// b: base path without its trailing slash; p: language prefix; re: paths with a page; s: translated routes.
|
|
10
|
+
export default function links(lang, b, p, re, s) {
|
|
11
|
+
if (tables) return void (tables[lang] = { b, p, re: new RegExp(re), s })
|
|
12
|
+
tables = { [lang]: { b, p, re: new RegExp(re), s } }
|
|
13
|
+
const to = h => {
|
|
14
|
+
const t = tables[document.documentElement.getAttribute('lang')]
|
|
15
|
+
if (!t || !h || h[0] !== '/' || h[1] === '/') return h
|
|
16
|
+
const a = t.b && (h + '/').startsWith(t.b + '/') ? h.slice(t.b.length) || '/' : h
|
|
17
|
+
const m = /^[^?#]*/.exec(a)[0]
|
|
18
|
+
const n = t.s[m] || (t.re.test(m) && (m === '/' ? t.p : t.p + m))
|
|
19
|
+
return n ? t.b + n + a.slice(m.length) : h
|
|
20
|
+
}
|
|
21
|
+
G.__brainHref = to
|
|
22
|
+
const fix = e => {
|
|
23
|
+
const n = e.localName === 'form' ? 'action' : 'href', h = e.getAttribute(n), v = e.hasAttribute('hreflang') ? h : to(h)
|
|
24
|
+
if (v === h) return
|
|
25
|
+
e.setAttribute(n, v)
|
|
26
|
+
if (n === 'href' && v.split(/[?#]/)[0] === location.pathname) e.setAttribute('aria-current', 'page')
|
|
27
|
+
}
|
|
28
|
+
const all = r => r.querySelectorAll?.(SEL).forEach(fix)
|
|
29
|
+
new MutationObserver(l => l.forEach(m => {
|
|
30
|
+
if (m.type === 'attributes') return fix(m.target)
|
|
31
|
+
m.addedNodes.forEach(n => {
|
|
32
|
+
if (n.matches?.(SEL)) fix(n)
|
|
33
|
+
all(n)
|
|
34
|
+
})
|
|
35
|
+
})).observe(document, { subtree: true, childList: true, attributes: true, attributeFilter: ['href', 'action'] })
|
|
36
|
+
all(document)
|
|
37
|
+
}
|
package/brain-live.js
CHANGED
|
@@ -68,14 +68,14 @@ function createBroker() {
|
|
|
68
68
|
const members = new Set(), joined = new Map(), waiting = [], events = new Map()
|
|
69
69
|
let socket = null, epoch = 0, scope = null, terminal = '', ready = false
|
|
70
70
|
let reconcileQueued = false, retryTimer, readyTimer, heartbeat, pongTimer, flushTimer
|
|
71
|
-
let attempts = 0, retryStart = 0, lastConnect = 0, healthySince = 0
|
|
71
|
+
let attempts = 0, retryStart = 0, lastConnect = 0, healthySince = 0, probed = false
|
|
72
72
|
|
|
73
73
|
const update = () => {
|
|
74
74
|
for (const member of members) {
|
|
75
75
|
const active = [...member.rooms.keys()].some(key => joined.get(key) === 'ready')
|
|
76
76
|
const forbidden = member.rooms.size > 0 && [...member.rooms.keys()].every(key => joined.get(key) === 'forbidden')
|
|
77
77
|
member.up.v = !!(socket && ready && member.scope && member.scope === scope && active)
|
|
78
|
-
member.state.v = !member.
|
|
78
|
+
member.state.v = !member.rooms.size ? 'idle' : !member.scope ? 'anonymous' : terminal || (member.refreshState || (forbidden ? 'forbidden' : member.up.v ? 'open' : retryTimer ? 'retrying' : 'connecting'))
|
|
79
79
|
}
|
|
80
80
|
}
|
|
81
81
|
const clearMember = member => {
|
|
@@ -105,6 +105,20 @@ function createBroker() {
|
|
|
105
105
|
for (const member of members) { clearMember(member); member.up.v = false }
|
|
106
106
|
}
|
|
107
107
|
const reject = state => { stop(); terminal = state; update() }
|
|
108
|
+
// The browser hides the status of a refused upgrade, so a closed session looks like a network failure. Once per
|
|
109
|
+
// outage, the resource's own read says which it is: a 401 is final, anything else keeps the retries.
|
|
110
|
+
const refused = () => {
|
|
111
|
+
if (probed) return
|
|
112
|
+
probed = true
|
|
113
|
+
for (const member of members) if (member.scope) {
|
|
114
|
+
try { untrack(() => member.node.refetch()) } catch { continue }
|
|
115
|
+
setTimeout(() => {
|
|
116
|
+
if (!members.has(member) || terminal || ready) return
|
|
117
|
+
Promise.resolve(untrack(() => member.node.v)).catch(e => { if (e && e.status === 401 && members.has(member) && !ready) reject('auth-required') })
|
|
118
|
+
}, 0)
|
|
119
|
+
return
|
|
120
|
+
}
|
|
121
|
+
}
|
|
108
122
|
const send = value => {
|
|
109
123
|
if (!socket || socket.readyState !== 1 || socket.bufferedAmount > BUFFER) return false
|
|
110
124
|
const text = JSON.stringify(value)
|
|
@@ -201,15 +215,17 @@ function createBroker() {
|
|
|
201
215
|
try { address = endpoint() } catch { reject('invalid-config'); return }
|
|
202
216
|
try { socket = new G.WebSocket(address, 'brain.v1') } catch { retry(); return }
|
|
203
217
|
readyTimer = setTimeout(() => { if (epoch === captured) retry() }, READY_MS)
|
|
218
|
+
let opened = false
|
|
204
219
|
socket.onopen = () => {
|
|
205
220
|
if (epoch !== captured) return
|
|
221
|
+
opened = true
|
|
206
222
|
if (socket.protocol !== 'brain.v1') reject('protocol-error')
|
|
207
223
|
}
|
|
208
224
|
socket.onerror = () => { /* close or the ready deadline owns retries; arbitrary browser errors are not retained. */ }
|
|
209
225
|
socket.onclose = event => {
|
|
210
226
|
if (epoch !== captured) return
|
|
211
227
|
if (event.code === 1008) reject(event.reason === 'Authentication expired' ? 'auth-required' : 'policy-closed')
|
|
212
|
-
else retry()
|
|
228
|
+
else { if (!opened) refused(); retry() }
|
|
213
229
|
}
|
|
214
230
|
socket.onmessage = event => {
|
|
215
231
|
if (epoch !== captured) return
|
|
@@ -217,7 +233,7 @@ function createBroker() {
|
|
|
217
233
|
if (!value) { reject('protocol-error'); return }
|
|
218
234
|
if (value.type === 'ready') {
|
|
219
235
|
if (ready) { reject('protocol-error'); return }
|
|
220
|
-
clearTimeout(readyTimer); readyTimer = null; ready = true
|
|
236
|
+
clearTimeout(readyTimer); readyTimer = null; ready = true; probed = false
|
|
221
237
|
syncRooms(desired().rooms)
|
|
222
238
|
heartbeat = setInterval(() => {
|
|
223
239
|
if (!send({ type: 'ping' })) { retry(); return }
|
|
@@ -279,7 +295,7 @@ function createBroker() {
|
|
|
279
295
|
},
|
|
280
296
|
reconnect(member) {
|
|
281
297
|
if (!members.has(member)) return
|
|
282
|
-
stop(); terminal = ''; attempts = 0; retryStart = 0; queue()
|
|
298
|
+
stop(); terminal = ''; attempts = 0; retryStart = 0; probed = false; queue()
|
|
283
299
|
},
|
|
284
300
|
}
|
|
285
301
|
}
|
package/brain-observe.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// Dev observability panel. `brain run` loads this file; builds never contain it. Alt+Shift+O or `?brain-observe` opens it.
|
|
2
|
+
// It shows what the running page does: Web Vitals, requests, long frames, errors and live components, and "Pick" names
|
|
3
|
+
// the component and source file of an element. `__brainObserve.snapshot()` returns the same data as JSON for an agent.
|
|
4
|
+
import { live } from './brain-dev.js'
|
|
5
|
+
import { sub } from './brain-err.js'
|
|
6
|
+
import { vitals } from './brain-vitals.js'
|
|
7
|
+
|
|
8
|
+
const LIMIT = 40
|
|
9
|
+
const data = { vitals: {}, requests: [], frames: [], errors: 0, updates: 0 }
|
|
10
|
+
const keep = (list, item) => { list.unshift(item); if (list.length > LIMIT) list.pop() }
|
|
11
|
+
let host, box, on = false, timer, picking = null
|
|
12
|
+
|
|
13
|
+
const CSS = `:host{all:initial;position:fixed;left:16px;bottom:16px;z-index:2147483646;font:12px/1.45 system-ui,-apple-system,sans-serif;color:#1a1a1a}
|
|
14
|
+
.p{width:min(420px,calc(100vw - 32px));max-height:min(70vh,560px);overflow:auto;box-sizing:border-box;border-radius:16px;padding:12px 14px;background:rgba(255,255,255,.76);
|
|
15
|
+
-webkit-backdrop-filter:blur(20px) saturate(1.6);backdrop-filter:blur(20px) saturate(1.6);border:1px solid rgba(0,0,0,.08);
|
|
16
|
+
box-shadow:0 1px 3px rgba(16,24,40,.08),0 12px 32px rgba(16,24,40,.14)}@supports not (backdrop-filter:blur(1px)){.p{background:#fff}}
|
|
17
|
+
@media (prefers-color-scheme:dark){:host{color:#f2f2f2}.p{background:rgba(28,28,30,.84);border-color:rgba(255,255,255,.1)}}
|
|
18
|
+
.h{display:flex;justify-content:space-between;align-items:center;gap:8px;font-weight:600}h3{margin:10px 0 4px;font-size:11px;font-weight:600;opacity:.65}
|
|
19
|
+
.v{display:flex;flex-wrap:wrap;gap:6px;font-variant-numeric:tabular-nums}.v span{padding:2px 7px;border-radius:8px;background:rgba(127,127,127,.14)}
|
|
20
|
+
.good{color:#0a7d32}.needs-improvement{color:#a85b00}.poor,.bad{color:#d70015}
|
|
21
|
+
.r{display:flex;gap:8px;font-variant-numeric:tabular-nums}.r b{font-weight:500;min-width:30px}.r i{flex:1;font-style:normal;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}.e{opacity:.6}
|
|
22
|
+
button{font:inherit;padding:3px 10px;border-radius:8px;border:1px solid rgba(127,127,127,.35);background:transparent;color:inherit;cursor:pointer}`
|
|
23
|
+
|
|
24
|
+
const el = (tag, cls, text) => Object.assign(document.createElement(tag), cls ? { className: cls } : {}, text === undefined ? {} : { textContent: text })
|
|
25
|
+
const fileOf = i => i.C?.f ?? null
|
|
26
|
+
const nameOf = i => i.C?.C?.name || i.C?.name || i.h
|
|
27
|
+
|
|
28
|
+
export function components() {
|
|
29
|
+
const rows = []
|
|
30
|
+
for (const [hash, set] of live) if (set.size) { const i = set.values().next().value; rows.push({ name: nameOf(i), file: fileOf(i), hash, instances: set.size }) }
|
|
31
|
+
return rows.sort((a, b) => b.instances - a.instances)
|
|
32
|
+
}
|
|
33
|
+
// The innermost live component: brain-dev marks each root element of an instance with `$i`.
|
|
34
|
+
export function componentOf(node) {
|
|
35
|
+
for (let n = node; n; n = n.parentNode ?? n.host) {
|
|
36
|
+
const i = n.$i
|
|
37
|
+
if (i) return { name: nameOf(i), file: fileOf(i), hash: i.h }
|
|
38
|
+
}
|
|
39
|
+
return null
|
|
40
|
+
}
|
|
41
|
+
export const snapshot = () => ({ path: location.pathname, vitals: { ...data.vitals }, requests: data.requests.slice(), longFrames: data.frames.slice(), errors: data.errors, hotUpdates: data.updates, components: components() })
|
|
42
|
+
|
|
43
|
+
function section(title, rows, empty) {
|
|
44
|
+
const out = [el('h3', '', title)]
|
|
45
|
+
if (!rows.length) out.push(el('div', 'e', empty))
|
|
46
|
+
return out.concat(rows)
|
|
47
|
+
}
|
|
48
|
+
function draw() {
|
|
49
|
+
if (!box) return
|
|
50
|
+
const pick = el('button', '', picking ? 'Picking… Esc cancels' : 'Pick')
|
|
51
|
+
pick.onclick = () => (picking ? stopPick() : startPick())
|
|
52
|
+
const head = el('div', 'h', 'brain observe')
|
|
53
|
+
head.append(pick)
|
|
54
|
+
const metrics = el('div', 'v')
|
|
55
|
+
for (const [name, m] of Object.entries(data.vitals)) metrics.append(el('span', m.rating, `${name} ${m.value}${name === 'CLS' ? '' : ' ms'}`))
|
|
56
|
+
const row = (a, b, c, cls) => { const r = el('div', 'r' + (cls ? ' ' + cls : '')); r.append(el('b', '', a), el('i', '', b), el('span', '', c)); return r }
|
|
57
|
+
box.replaceChildren(
|
|
58
|
+
head,
|
|
59
|
+
...section('Web Vitals', metrics.childElementCount ? [metrics] : [], 'Waiting for the browser to report.'),
|
|
60
|
+
...section(`Requests (${data.requests.length})`, data.requests.slice(0, 8).map(r => row(String(r.status ?? '…'), `${r.method ?? ''} ${r.url}`, `${r.ms ?? '?'} ms`, r.status >= 400 ? 'bad' : '')), 'No api() request yet.'),
|
|
61
|
+
...section(`Long frames (${data.frames.length}) · errors ${data.errors} · hot updates ${data.updates}`, data.frames.slice(0, 4).map(f => row(`${f.ms}`, f.source || 'unattributed', 'ms')), 'No frame over 50 ms.'),
|
|
62
|
+
...section(`Components (${live.size})`, components().slice(0, 8).map(c => row(`×${c.instances}`, c.file ?? c.name, '')), 'None mounted.'),
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
function toggle(force) {
|
|
66
|
+
on = force ?? !on
|
|
67
|
+
try { on ? sessionStorage.setItem('brain:observe', '1') : sessionStorage.removeItem('brain:observe') } catch {}
|
|
68
|
+
if (!on) { stopPick(); clearInterval(timer); host?.remove(); return on }
|
|
69
|
+
if (!host) {
|
|
70
|
+
host = document.createElement('brain-observe')
|
|
71
|
+
const root = host.attachShadow({ mode: 'open' })
|
|
72
|
+
root.append(el('style', '', CSS))
|
|
73
|
+
box = root.appendChild(el('div', 'p'))
|
|
74
|
+
}
|
|
75
|
+
document.body.append(host)
|
|
76
|
+
draw()
|
|
77
|
+
timer = setInterval(draw, 1000)
|
|
78
|
+
return on
|
|
79
|
+
}
|
|
80
|
+
function startPick() {
|
|
81
|
+
const mark = el('div')
|
|
82
|
+
mark.style.cssText = 'position:fixed;z-index:2147483645;pointer-events:none;outline:2px solid #0071e3;background:rgba(0,113,227,.08);border-radius:3px'
|
|
83
|
+
const tip = el('div')
|
|
84
|
+
tip.style.cssText = 'position:fixed;z-index:2147483645;pointer-events:none;padding:2px 8px;border-radius:6px;background:#111;color:#fff;font:12px ui-monospace,monospace'
|
|
85
|
+
document.body.append(mark, tip)
|
|
86
|
+
const move = ev => {
|
|
87
|
+
if (ev.target === host) return
|
|
88
|
+
const r = ev.target.getBoundingClientRect(), c = componentOf(ev.target)
|
|
89
|
+
Object.assign(mark.style, { left: r.left + 'px', top: r.top + 'px', width: r.width + 'px', height: r.height + 'px' })
|
|
90
|
+
Object.assign(tip.style, { left: r.left + 'px', top: Math.max(0, r.top - 24) + 'px' })
|
|
91
|
+
tip.textContent = c ? c.file ?? c.name : 'outside any component'
|
|
92
|
+
}
|
|
93
|
+
const click = ev => {
|
|
94
|
+
if (ev.target === host) return
|
|
95
|
+
ev.preventDefault(); ev.stopPropagation()
|
|
96
|
+
const c = componentOf(ev.target)
|
|
97
|
+
if (c) console.info('brain:observe', c)
|
|
98
|
+
dispatchEvent(new CustomEvent('brain:observe-pick', { detail: c }))
|
|
99
|
+
stopPick()
|
|
100
|
+
}
|
|
101
|
+
const key = ev => ev.key === 'Escape' && stopPick()
|
|
102
|
+
addEventListener('mousemove', move, true); addEventListener('click', click, true); addEventListener('keydown', key, true)
|
|
103
|
+
picking = () => { removeEventListener('mousemove', move, true); removeEventListener('click', click, true); removeEventListener('keydown', key, true); mark.remove(); tip.remove() }
|
|
104
|
+
draw()
|
|
105
|
+
}
|
|
106
|
+
function stopPick() { picking?.(); picking = null; draw() }
|
|
107
|
+
|
|
108
|
+
export function start() {
|
|
109
|
+
vitals(m => { data.vitals[m.name] = m })
|
|
110
|
+
addEventListener('brain:net', ev => keep(data.requests, ev.detail))
|
|
111
|
+
addEventListener('brain:hmr', () => data.updates++)
|
|
112
|
+
sub.add(() => data.errors++)
|
|
113
|
+
if (PerformanceObserver.supportedEntryTypes?.includes('long-animation-frame'))
|
|
114
|
+
new PerformanceObserver(list => { for (const e of list.getEntries()) keep(data.frames, { ms: Math.round(e.duration), source: e.scripts?.[0]?.sourceURL ? new URL(e.scripts[0].sourceURL, location.href).pathname : '' }) }).observe({ type: 'long-animation-frame', buffered: true })
|
|
115
|
+
addEventListener('keydown', ev => { if (ev.altKey && ev.shiftKey && ev.code === 'KeyO') { ev.preventDefault(); toggle() } })
|
|
116
|
+
globalThis.__brainObserve = { snapshot, toggle, componentOf }
|
|
117
|
+
let saved = false
|
|
118
|
+
try { saved = sessionStorage.getItem('brain:observe') === '1' } catch {}
|
|
119
|
+
if (saved || new URLSearchParams(location.search).has('brain-observe')) toggle(true)
|
|
120
|
+
}
|