@christianirack/brainllm-runtime 0.6.0 → 0.7.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 +4 -2
- package/brain-env.js +8 -6
- package/brain-form.js +37 -29
- 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-router.js +223 -178
- package/brain-trace.js +269 -0
- package/package.json +1 -1
- package/server/native-sockets.mjs +13 -10
- package/server/server.mjs +593 -86
- package/shim/dom.js +11 -2
- package/sw/brain-sw.js +17 -9
- package/ui/error-state.brain +1 -1
package/brain-data.js
CHANGED
|
@@ -379,9 +379,11 @@ 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: else the backend's own fix, or a plain sentence
|
|
384
|
+
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.' : (native && typeof j.error.fix === 'string' && j.error.fix.slice(0, 512)) || 'The server rejected this request. Check it and try again.') : st === 429 ? 'Too many requests. Wait before trying again.' : `Request failed (${st}).`
|
|
383
385
|
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:
|
|
386
|
+
throw new ApiError('http', message, { status: st, errors: fields, retryAfter, id: h('x-request-id') || undefined, ...(native ? { backendCode: j.error.code, fix: typeof j.error.fix === 'string' ? j.error.fix.slice(0,1024) : undefined } : {}) })
|
|
385
387
|
}
|
|
386
388
|
|
|
387
389
|
// 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
|
@@ -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
|
|
@@ -46,10 +46,7 @@ const init0 = r => (r.list ? [] : r.t === 'bool' ? false : '')
|
|
|
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) {
|
|
@@ -271,36 +280,35 @@ function createForm(schema, o, readRule, validateRule, remote) {
|
|
|
271
280
|
const cross = o.validate(values()) // cross-field rules: { field: msg }
|
|
272
281
|
if (cross && Object.keys(cross).length) {
|
|
273
282
|
mark(cross)
|
|
274
|
-
|
|
275
|
-
if (!first) return
|
|
283
|
+
return void aim()
|
|
276
284
|
}
|
|
277
285
|
}
|
|
278
|
-
if (first) return void (
|
|
286
|
+
if (first) return void aim()
|
|
279
287
|
busy.v = true
|
|
280
288
|
if (fm) fm.setAttribute('aria-busy', 'true') // assistive tech hears the form is working
|
|
281
289
|
try {
|
|
282
290
|
if (remote && o.check) {
|
|
283
291
|
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())
|
|
292
|
+
if (F.some(x => untrack(() => x.e.v))) return void shown.v++
|
|
286
293
|
}
|
|
287
294
|
if (!o.submit) return
|
|
288
295
|
const r = await o.submit(values())
|
|
289
|
-
if (r
|
|
296
|
+
if (r?.errors) mark(r.errors)
|
|
290
297
|
else {
|
|
291
298
|
saved.v = untrack(now) // saved: no longer dirty
|
|
292
299
|
// `return { redirect: '/x' }` from the server function: the same page the 303 reaches without JS (§38)
|
|
293
|
-
if (
|
|
300
|
+
if (typeof r?.redirect === 'string' && r.redirect[0] === '/' && r.redirect[1] !== '/') go(r.redirect)
|
|
294
301
|
}
|
|
295
302
|
return r
|
|
296
303
|
} catch (x) {
|
|
297
|
-
if (x
|
|
304
|
+
if (x?.errors) mark(x.errors)
|
|
298
305
|
// a server failure reads the same as without JS: 4xx and the author's own errors keep their message; 5xx and a
|
|
299
306
|
// TypeError (the network dropped, or a bug) get the generic one
|
|
300
|
-
else if (
|
|
307
|
+
else if (x?.kind !== 'aborted') error.v = (!(x?.status >= 500) && x?.name !== 'TypeError' && x?.message) || msg('form.error', GENERIC)
|
|
301
308
|
} finally {
|
|
302
309
|
busy.v = false
|
|
303
310
|
if (fm) fm.removeAttribute('aria-busy')
|
|
311
|
+
queueMicrotask(aim) // after the flush re-enables what `disabled={f.busy}` disabled
|
|
304
312
|
}
|
|
305
313
|
},
|
|
306
314
|
}
|
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
|
}
|