@christianirack/brainllm-runtime 0.8.4 → 0.8.5

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-inspect.js CHANGED
@@ -214,13 +214,14 @@ function leak(r) {
214
214
  leaks.push(r)
215
215
  push(r)
216
216
  }
217
- function b602(n, what, line) {
217
+ function b602(n, what, line, foreign) {
218
218
  if (!n || flagged.has(n) || n.s >= 3) return
219
219
  flagged.add(n)
220
220
  const i = instOf(n), name = i ? nameOf(i) : '?'
221
221
  const fx = `fx in ${name} (${what})${line ? ' ' + line : ''}`
222
222
  if (i) (b602ByInst.get(i.r) || b602ByInst.set(i.r, []).get(i.r)).push(fx)
223
- leak({ code: 'B0602', h: i ? i.h : null, name, file: i && i.C.f, line, msg: `global ${what} inside an fx without cleanup`, fix: 'return a cleanup function from the fx: `fx(() => { …; return () => remove() })`', fx: [fx] })
223
+ // a listener another module registered while this fx ran is not the component's: name where it really comes from
224
+ leak({ code: 'B0602', h: i ? i.h : null, name, file: foreign && line ? line.replace(/:\d+$/, '') : i && i.C.f, line, msg: `global ${what} inside an fx${foreign && line ? ` of ${name}, registered by ${line},` : ''} without cleanup`, fix: 'return a cleanup function from the fx: `fx(() => { …; return () => remove() })`', fx: [fx] })
224
225
  }
225
226
  function patchGlobalListeners() {
226
227
  for (const t of [G.window, G.document].filter(Boolean)) {
@@ -229,8 +230,8 @@ function patchGlobalListeners() {
229
230
  t.addEventListener = function (type, ...a) {
230
231
  const c = ctx()
231
232
  if (c && c.o === 'fx') {
232
- const line = site().line
233
- O.st.call(G, () => !c.n.k && b602(c.n, `addEventListener('${type}')`, line), 0)
233
+ const { line, foreign } = site()
234
+ O.st.call(G, () => !c.n.k && b602(c.n, `addEventListener('${type}')`, line, foreign), 0)
234
235
  }
235
236
  return add.call(this, type, ...a)
236
237
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@christianirack/brainllm-runtime",
3
- "version": "0.8.4",
3
+ "version": "0.8.5",
4
4
  "description": "The BRAINLLM runtime for the Rust-built web compiler for LLMs: dependency-free ES modules (signals, DOM, router, data, forms, channels). The brain binary embeds this same version.",
5
5
  "type": "module",
6
6
  "exports": {
package/server/server.mjs CHANGED
@@ -546,17 +546,20 @@ export function nativeAuthBridge(raw, refused, serviceKey, report, ownSession) {
546
546
  try { bytes = Buffer.from(JSON.stringify(body)) } catch { throw new HttpError(400, 'Native resource values must be JSON serializable') }
547
547
  if (bytes.length > 65536) throw new HttpError(413, 'Native resource values exceed their byte budget')
548
548
  }
549
- const req = Readable.from(bytes.length ? [bytes] : [])
550
- req.method = method
551
- req.$brainForwardedFor = forwardedFor(incoming)
552
- // A service call acts as the key's owner, never as the visitor: it sends no session.
553
- req.headers = { ...(serviceKey ? { authorization: 'Bearer ' + serviceKey } : ownSession ? {} : { cookie: cookies(incoming, false) }), accept: 'application/json', 'content-type':'application/json', 'content-length':String(bytes.length) }
554
- if (method !== 'GET') {
555
- req.headers.origin = bridgeOrigin(incoming, rid, 'Native writes require a same-origin server request')
549
+ const outgoing = () => {
550
+ const req = Readable.from(bytes.length ? [bytes] : [])
551
+ req.method = method
552
+ req.$brainForwardedFor = forwardedFor(incoming)
553
+ // A service call acts as the key's owner, never as the visitor: it sends no session.
554
+ req.headers = { ...(serviceKey ? { authorization: 'Bearer ' + serviceKey } : ownSession ? {} : { cookie: cookies(incoming, false) }), accept: 'application/json', 'content-type':'application/json', 'content-length':String(bytes.length) }
555
+ if (method !== 'GET') {
556
+ req.headers.origin = bridgeOrigin(incoming, rid, 'Native writes require a same-origin server request')
557
+ }
558
+ return req
556
559
  }
560
+ let req = outgoing()
557
561
  let value
558
- try {
559
- await proxy(req, response, new URL(path, target), rid, (_res, status, data, headers) => {
562
+ const answered = (_res, status, data, headers) => {
560
563
  if (status === 422) {
561
564
  // the backend's own field messages (never driver text): a server function that rethrows answers 422 {errors}
562
565
  let fields
@@ -576,7 +579,20 @@ export function nativeAuthBridge(raw, refused, serviceKey, report, ownSession) {
576
579
  if (status === 204) { value = null; return }
577
580
  if (!String(headers['content-type'] || '').startsWith('application/json')) throw new HttpError(502, 'Native resource response must be JSON')
578
581
  try { value = JSON.parse(data.toString('utf8')) } catch { throw new HttpError(502, 'Native resource response is invalid') }
579
- }, 2 * 1024 * 1024)
582
+ }
583
+ try {
584
+ // A restarting backend refuses connections for a few seconds: a read waits for it instead of failing the page.
585
+ for (let wait = 250; ; wait *= 2) {
586
+ try {
587
+ await proxy(req, response, new URL(path, target), rid, answered, 2 * 1024 * 1024)
588
+ break
589
+ } catch (e) {
590
+ if (method !== 'GET' || wait > 2000 || stopped || response.destroyed || !['ECONNREFUSED', 'ECONNRESET'].includes(e?.transport)) throw e
591
+ req.destroy()
592
+ await new Promise(done => setTimeout(done, wait))
593
+ req = outgoing()
594
+ }
595
+ }
580
596
  } catch (e) {
581
597
  // A read that failed: `load` may catch it and render anyway, so the page answer says what happened (ssr below).
582
598
  if (method === 'GET' && !(e?.status >= 400 && e.status < 500)) {
@@ -1352,6 +1368,7 @@ export function createServer(o = {}) {
1352
1368
  }
1353
1369
  if (route.i18n) await load(route.i18n) // the route's dictionary chunk: add(locale, {…}) before anything renders
1354
1370
  globalThis.__brainForms = forms // form() reads it while the view is built (renders are serialized)
1371
+ globalThis.__brainIds = (globalThis.__brainIds || 0) + 1 // each page numbers its instance ids from the start, as the client will
1355
1372
  globalThis.__brainLocale = route.locale || null // t(), fmt and locale() of this page (§37)
1356
1373
  globalThis.__brainAlternates = route.alternates || null // <LangSwitch> links, in the HTML before any JS
1357
1374
  globalThis.__brainRoute = at // route(): app path, params, query of this request
@@ -1468,8 +1485,8 @@ export function createServer(o = {}) {
1468
1485
  // pages, plus an optional `lastmod`, at request time. /sitemap.xml is the build's file with those URLs added (a sitemap
1469
1486
  // index gets one more child, /sitemap-dynamic.xml). A file holds at most 50 000 URLs (sitemaps.org): when the build's
1470
1487
  // URLs plus the dynamic ones pass that, /sitemap.xml becomes an index of /sitemap-static.xml (the build's file) and
1471
- // /sitemap-dynamic.xml; dynamic URLs past 50 000 are dropped with a warning. Absolute URLs take the origin of the
1472
- // build's sitemap (brain.json `site`); a build without a sitemap serves none. The list is kept 5 min (one sitemap() run
1488
+ // /sitemap-dynamic.xml; dynamic URLs past 50 000 are dropped with a warning. Absolute URLs take the site origin from
1489
+ // the manifest (an older manifest: from the build's sitemap); a build without a sitemap serves none. The list is kept 5 min (one sitemap() run
1473
1490
  // at a time when it expires) and the response says max-age=300; a sitemap() that throws is logged and skipped.
1474
1491
  const DYN = routes.filter(r => r.sitemap && r.server)
1475
1492
  let dynList = null
@@ -1485,14 +1502,23 @@ export function createServer(o = {}) {
1485
1502
  const day = v => (v instanceof Date ? v.toISOString().slice(0, 10) : typeof v === 'string' && /^\d{4}-\d{2}-\d{2}/.test(v) ? v : null)
1486
1503
  const MAX_URLS = 50000
1487
1504
  let dynRun = null
1505
+ const dynGood = new Map()
1488
1506
  async function collect(origin) {
1489
1507
  const urls = []
1490
1508
  let dropped = 0
1491
- for (const r of DYN) {
1509
+ // Views answer at the same time, so one slow view costs one timeout; a view that fails keeps its last good list.
1510
+ const lists = await Promise.all(DYN.map(async r => {
1492
1511
  try {
1493
1512
  const sm = await load(r.server)
1494
1513
  const list = typeof sm.sitemap === 'function' ? await withTimeout(Promise.resolve().then(() => sm.sitemap({ env })), TIMEOUT) : []
1495
- for (const p of Array.isArray(list) ? list : []) {
1514
+ dynGood.set(r, Array.isArray(list) ? list : [])
1515
+ } catch (e) {
1516
+ log('error', 'sitemap() failed', { view: r.view, ...trace(e) })
1517
+ }
1518
+ return dynGood.get(r) || []
1519
+ }))
1520
+ DYN.forEach((r, n) => {
1521
+ for (const p of lists[n]) {
1496
1522
  const path = fill(r.path, p)
1497
1523
  if (!path) continue
1498
1524
  const loc = origin + withBase(path)
@@ -1500,19 +1526,17 @@ export function createServer(o = {}) {
1500
1526
  else if (urls.length < MAX_URLS) urls.push({ loc, lastmod: day(p.lastmod) })
1501
1527
  else dropped++
1502
1528
  }
1503
- } catch (e) {
1504
- log('error', 'sitemap() failed', { view: r.view, ...trace(e) })
1505
- }
1506
- }
1529
+ })
1507
1530
  if (dropped) log('warn', 'sitemap(): more than 50000 URLs, the rest are not listed', { dropped })
1531
+ if (!urls.length) log('warn', 'sitemap(): no view returned a URL, so /sitemap.xml lists only the pages of the build', { views: DYN.map(r => r.view) })
1508
1532
  dynList = { at: Date.now(), urls }
1509
1533
  return urls
1510
1534
  }
1511
1535
  async function dynamicUrls(origin) {
1512
1536
  if (dynList && Date.now() - dynList.at < 300000) return dynList.urls
1513
- // concurrent requests after expiry share one run of every sitemap()
1537
+ // concurrent requests after expiry share one run of every sitemap(); a crawler never waits while a list exists
1514
1538
  if (!dynRun) dynRun = collect(origin).finally(() => (dynRun = null))
1515
- return dynRun
1539
+ return dynList ? dynList.urls : dynRun
1516
1540
  }
1517
1541
  // §33 S01: every sitemap file holds at most 50 000 URLs and SITEMAP_BYTES bytes of UTF-8 XML; the dynamic URLs fill
1518
1542
  // /sitemap-dynamic.xml, /sitemap-dynamic-2.xml, … in sitemap() order, so the split is deterministic
@@ -1535,7 +1559,7 @@ export function createServer(o = {}) {
1535
1559
  const f = file('/sitemap.xml')
1536
1560
  if (!f) return false
1537
1561
  const text = readFileSync(f, 'utf8')
1538
- const origin = [...text.matchAll(/<loc>(https?:\/\/[^<]+)<\/loc>/g)].map(m => URL.canParse(m[1]) && new URL(m[1]).origin).find(Boolean)
1562
+ const origin = mf.site || [...text.matchAll(/<loc>(https?:\/\/[^<]+)<\/loc>/g)].map(m => URL.canParse(m[1]) && new URL(m[1]).origin).find(Boolean)
1539
1563
  if (!origin) return false
1540
1564
  const index = text.includes('<sitemapindex')
1541
1565
  const sets = pages(await dynamicUrls(origin))