@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 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 generic = st === 401 ? 'Authentication failed. Sign in again.' : st === 403 ? 'You do not have permission to continue.' : st === 422 ? 'Check the highlighted fields.' : st === 429 ? 'Too many requests. Wait before trying again.' : `Request failed (${st}).`
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: native && st === 422 ? j.error.fields : j && j.errors, 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 } : {}) })
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): path without the base path
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
- S.forEach((s, i) => JSON.stringify(untrack(() => s.v)) !== JSON.stringify(v[i]) && (s.v = v[i])) // only what changed
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 && D.querySelector('meta[name=brain-base]'), b = (m && m.getAttribute('content')) || '/'
32
- G.location && G.location.assign(b !== '/' && !(u + '/').startsWith(b) ? b + u.slice(1) : u)
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
- if (r.t !== 'num') return v
50
- const numbers = new Array(v.length)
51
- for (let i = 0; i < v.length; i++) numbers[i] = Number(v[i])
52
- return numbers
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 && D.getElementById('brain-forms')
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 (SEEN && SEEN[id]) || null
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 && o.check[x.path], v = untrack(() => x.s.v)
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 && e.kind === 'aborted' ? '' : (e && e.message) || msg('form.check_failed', 'Could not check this value.')))
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, (init && init[k]) || {})
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 && D.activeElement)
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 && D.getElementById(fid(x.path))
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 && o.submit.id)
173
+ const st = posted(o.submit?.id)
175
174
  const { v, err } = build(schema, '', st ? st.values : o.init)
176
175
  if (st) {
177
- for (const p in st.errors || {}) if (byPath[p]) (byPath[p].e.v = String(st.errors[p])), (byPath[p].touched = true)
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] || (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
- if (e && e.preventDefault) e.preventDefault()
259
+ e?.preventDefault?.()
257
260
  if (untrack(() => busy.v)) return // no re-entry: a double click sends once
258
- const fm = e && e.target && e.target.elements ? e.target : null
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
- first = byPath[Object.keys(cross)[0]] || null
275
- if (!first) return
283
+ return void aim()
276
284
  }
277
285
  }
278
- if (first) return void (first.el && first.el.focus && first.el.focus())
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
- first = F.find(x => untrack(() => x.e.v))
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 && r.errors) mark(r.errors)
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 (r && typeof r.redirect === 'string' && r.redirect[0] === '/' && r.redirect[1] !== '/') go(r.redirect)
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 && x.errors) mark(x.errors)
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 (!(x && x.kind === 'aborted')) error.v = (x && x.message && !(x.status >= 500) && x.name !== 'TypeError' && x.message) || msg('form.error', GENERIC)
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
- 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' : 'look for external references to its nodes or signals (global listeners, caches, module closures)', fx: w.fx })
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
- return O.fetch.call(this, input, init).then(
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
- this.addEventListener('loadend', () => push({ k: 'net', id: r.id, st: this.status, ms: +(pnow() - t0).toFixed(1), b: typeof this.responseText === 'string' ? this.responseText.length : null }))
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
- x => I.oops(x, null, 'island') // reported once to the catcher, never an unhandled rejection
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.scope || !member.rooms.size ? 'idle' : terminal || (member.refreshState || (forbidden ? 'forbidden' : member.up.v ? 'open' : retryTimer ? 'retrying' : 'connecting'))
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
  }