@decentnetwork/beagle 0.1.117 → 0.1.118

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.
@@ -1,4 +1,4 @@
1
- window.__DK_UI_VERSION="0.1.117";
1
+ window.__DK_UI_VERSION="0.1.118";
2
2
  "use strict";
3
3
  (() => {
4
4
  // packages/beagle-ui/dist/beagle-ui.js
@@ -1340,7 +1340,9 @@ window.__DK_UI_VERSION="0.1.117";
1340
1340
  userId: ASSISTANT_ID,
1341
1341
  alias: "Beagle Assistant",
1342
1342
  address: "",
1343
- online: true,
1343
+ // Not a peer, so not "online": there is no session, nothing to be present
1344
+ // on, and no dot is drawn for an `app` row anywhere in the UI.
1345
+ online: false,
1344
1346
  app: true,
1345
1347
  agent: false,
1346
1348
  pending: false,
@@ -1782,7 +1784,7 @@ ${lines}`, { assistant: { kind: "adopt", rows: fresh } }));
1782
1784
  textOverflow: "ellipsis",
1783
1785
  flex: 1,
1784
1786
  minWidth: 0
1785
- } }, dkRowPreview(row.preview)))), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", flexDirection: "column", alignItems: "flex-end", gap: 4, flexShrink: 0 } }, /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 10.5, color: "var(--faint)" } }, row.lastTime), row.unread ? /* @__PURE__ */ React.createElement(Unread, { n: row.unread }) : peer.pending ? /* @__PURE__ */ React.createElement(Icon, { name: "clock", size: 11, stroke: 2.2, color: "var(--warn, #d29922)", title: "friend request pending" }) : /* @__PURE__ */ React.createElement(StatusDot, { online: peer.online })));
1787
+ } }, dkRowPreview(row.preview)))), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", flexDirection: "column", alignItems: "flex-end", gap: 4, flexShrink: 0 } }, /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 10.5, color: "var(--faint)" } }, row.lastTime), peer.app ? null : row.unread ? /* @__PURE__ */ React.createElement(Unread, { n: row.unread }) : peer.pending ? /* @__PURE__ */ React.createElement(Icon, { name: "clock", size: 11, stroke: 2.2, color: "var(--warn, #d29922)", title: "friend request pending" }) : /* @__PURE__ */ React.createElement(StatusDot, { online: peer.online })));
1786
1788
  }
1787
1789
  function dkActivityTs(p, isProtocol, fix) {
1788
1790
  const row = rosterRow(p, isProtocol || (() => false), fix);
@@ -4387,6 +4389,8 @@ ${peer.address}`
4387
4389
  }
4388
4390
  function DkAvatar({ peer, size = 30, radius = 7, dot = true }) {
4389
4391
  const d = Math.max(8, size * 0.3);
4392
+ if (peer && peer.app)
4393
+ dot = false;
4390
4394
  return /* @__PURE__ */ React.createElement("div", { style: { position: "relative", width: size, height: size, flexShrink: 0 } }, peer.agent ? /* @__PURE__ */ React.createElement(DkBot, { seed: peer.id, size, radius }) : peer.avatarUrl ? /* @__PURE__ */ React.createElement(DkImgAvatar, { url: peer.avatarUrl, seed: peer.userId || peer.id, size, radius }) : peer.punkId != null ? /* @__PURE__ */ React.createElement(DkPunkAvatar, { id: peer.punkId, size, radius, fallbackSeed: peer.userId || peer.id }) : /* @__PURE__ */ React.createElement(DkIdenticon, { seed: peer.userId || peer.id, size, radius }), dot && /* @__PURE__ */ React.createElement("span", { style: {
4391
4395
  position: "absolute",
4392
4396
  right: -2,
@@ -6132,6 +6136,8 @@ ${peer.address}`
6132
6136
  return;
6133
6137
  const byId = /* @__PURE__ */ new Map();
6134
6138
  for (const p of peers || []) {
6139
+ if (p && p.app)
6140
+ continue;
6135
6141
  const dir = p.lastDir || (String(p.lastMsg || "").startsWith("you: ") ? "out" : "in");
6136
6142
  const quiet = typeof isProtocol === "function" && lastIsProtocol(p, isProtocol);
6137
6143
  byId.set(p.id, { ts: p.lastTs || 0, dir, quiet });
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Cached fetches for the discovery directories (the leaderboard and the
3
+ * beagles.eth /names listing).
4
+ *
5
+ * This used to cache successes only. When the gateway was down every UI poll —
6
+ * one every 2.5 s, per open tab, per machine — started a fresh request, so the
7
+ * upstream got hammered exactly when it was least able to answer. On
8
+ * 2026-09-28 the beagle servers on one home network were about 82% of the
9
+ * gateway's traffic and the day's total reached 139k requests against a 100k
10
+ * free limit. A failing upstream must cost LESS traffic than a working one,
11
+ * not more.
12
+ *
13
+ * Three rules, and they are the whole module:
14
+ * • a success is cached for `ttlMs` (5 minutes),
15
+ * • a failure is remembered for `failTtlMs` (60 s) and nothing is refetched
16
+ * inside that window — a stale body is served if we have one, because a
17
+ * five-minute-old directory listing is worth more than an error,
18
+ * • one in-flight request per URL, shared by every caller, so the poll that
19
+ * arrives while a fetch is running joins it instead of starting another.
20
+ */
21
+ export interface DiscoverFetchOptions {
22
+ /** How long a good answer stays good. */
23
+ ttlMs?: number;
24
+ /** How long a failure suppresses refetching. */
25
+ failTtlMs?: number;
26
+ /** Abort a request that takes longer than this. */
27
+ timeoutMs?: number;
28
+ /** Injected for tests. */
29
+ fetchImpl?: typeof fetch;
30
+ now?: () => number;
31
+ }
32
+ export declare const DISCOVER_TTL_MS: number;
33
+ export declare const DISCOVER_FAIL_TTL_MS = 60000;
34
+ export interface DiscoverFetch {
35
+ (url: string): Promise<unknown>;
36
+ /** Forget everything about this URL: the next call refetches. Used when we
37
+ * KNOW the upstream changed (we just wrote a record), so the Names tab
38
+ * reflects it now instead of in five minutes. A request already in flight
39
+ * when this is called carries pre-change data, so its result is discarded
40
+ * rather than cached. */
41
+ invalidate(url: string): void;
42
+ }
43
+ export declare function createDiscoverFetch(opts?: DiscoverFetchOptions): DiscoverFetch;
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Cached fetches for the discovery directories (the leaderboard and the
3
+ * beagles.eth /names listing).
4
+ *
5
+ * This used to cache successes only. When the gateway was down every UI poll —
6
+ * one every 2.5 s, per open tab, per machine — started a fresh request, so the
7
+ * upstream got hammered exactly when it was least able to answer. On
8
+ * 2026-09-28 the beagle servers on one home network were about 82% of the
9
+ * gateway's traffic and the day's total reached 139k requests against a 100k
10
+ * free limit. A failing upstream must cost LESS traffic than a working one,
11
+ * not more.
12
+ *
13
+ * Three rules, and they are the whole module:
14
+ * • a success is cached for `ttlMs` (5 minutes),
15
+ * • a failure is remembered for `failTtlMs` (60 s) and nothing is refetched
16
+ * inside that window — a stale body is served if we have one, because a
17
+ * five-minute-old directory listing is worth more than an error,
18
+ * • one in-flight request per URL, shared by every caller, so the poll that
19
+ * arrives while a fetch is running joins it instead of starting another.
20
+ */
21
+ export const DISCOVER_TTL_MS = 5 * 60_000;
22
+ export const DISCOVER_FAIL_TTL_MS = 60_000;
23
+ export function createDiscoverFetch(opts = {}) {
24
+ const ttlMs = opts.ttlMs ?? DISCOVER_TTL_MS;
25
+ const failTtlMs = opts.failTtlMs ?? DISCOVER_FAIL_TTL_MS;
26
+ const timeoutMs = opts.timeoutMs ?? 10_000;
27
+ const doFetch = opts.fetchImpl ?? fetch;
28
+ const now = opts.now ?? Date.now;
29
+ const cache = new Map();
30
+ const inFlight = new Map();
31
+ const generation = new Map();
32
+ const fetchDiscoverJson = async function fetchDiscoverJson(url) {
33
+ const hit = cache.get(url);
34
+ if (hit && hit.at !== undefined && now() - hit.at < ttlMs)
35
+ return hit.body;
36
+ // Someone is already asking. Join them rather than making it worse — under
37
+ // a slow upstream this is the difference between one request and one per
38
+ // poll per tab.
39
+ const running = inFlight.get(url);
40
+ if (running)
41
+ return running;
42
+ if (hit && hit.failedAt !== undefined && now() - hit.failedAt < failTtlMs) {
43
+ // Inside the failure window nothing is fetched. Stale beats nothing;
44
+ // with nothing to serve, the caller gets the error it would have got.
45
+ if (hit.at !== undefined)
46
+ return hit.body;
47
+ throw hit.error ?? new Error("upstream unavailable");
48
+ }
49
+ const gen = generation.get(url) ?? 0;
50
+ const attempt = (async () => {
51
+ const ctl = new AbortController();
52
+ const timer = setTimeout(() => ctl.abort(), timeoutMs);
53
+ try {
54
+ const r = await doFetch(url, { signal: ctl.signal });
55
+ if (!r.ok)
56
+ throw new Error(`upstream ${r.status}`);
57
+ const body = await r.json();
58
+ if ((generation.get(url) ?? 0) === gen)
59
+ cache.set(url, { body, at: now() });
60
+ return body;
61
+ }
62
+ catch (e) {
63
+ const err = e instanceof Error ? e : new Error(String(e));
64
+ const prev = cache.get(url);
65
+ // Keep whatever good body we had: it is what the next 60 s will serve.
66
+ if ((generation.get(url) ?? 0) === gen) {
67
+ cache.set(url, { body: prev?.body, at: prev?.at, failedAt: now(), error: err });
68
+ }
69
+ if (prev && prev.at !== undefined)
70
+ return prev.body;
71
+ throw err;
72
+ }
73
+ finally {
74
+ clearTimeout(timer);
75
+ // After an invalidate the entry is already gone and a newer request
76
+ // may own the slot; the generation says which of us this is.
77
+ if ((generation.get(url) ?? 0) === gen)
78
+ inFlight.delete(url);
79
+ }
80
+ })();
81
+ inFlight.set(url, attempt);
82
+ return attempt;
83
+ };
84
+ fetchDiscoverJson.invalidate = (url) => {
85
+ cache.delete(url);
86
+ inFlight.delete(url);
87
+ generation.set(url, (generation.get(url) ?? 0) + 1);
88
+ };
89
+ return fetchDiscoverJson;
90
+ }
package/dist/server.js CHANGED
@@ -25,6 +25,7 @@ import * as peerAddr from "@decentnetwork/peer";
25
25
  import { bridgeRoster } from "./bridge-roster.js";
26
26
  import { isSupervised } from "./relaunch.js";
27
27
  import { historyStats } from "./assistant-stats.js";
28
+ import { createDiscoverFetch } from "./discover-cache.js";
28
29
  // Directory holding the built desktop UI bundle (index.html, app.js, vendor/).
29
30
  // scripts/build-ui.mjs emits it next to this compiled module at dist/ui/desktop/.
30
31
  const DESKTOP_DIR = join(dirname(fileURLToPath(import.meta.url)), "desktop");
@@ -33,28 +34,10 @@ const DESKTOP_DIR = join(dirname(fileURLToPath(import.meta.url)), "desktop");
33
34
  // (which probes all exits on each poll) stays responsive over a lossy mesh.
34
35
  const EXIT_PROBE_PORT = 8888;
35
36
  const EXIT_PROBE_TIMEOUT_MS = 2000;
36
- /** Server-side fetch for the discovery directories (leaderboard / ENS names),
37
- * with a small TTL cache so tab switches don't hammer the upstreams. */
38
- const DISCOVER_CACHE_TTL_MS = 5 * 60_000;
39
- const discoverCache = new Map();
40
- async function fetchDiscoverJson(url) {
41
- const hit = discoverCache.get(url);
42
- if (hit && Date.now() - hit.at < DISCOVER_CACHE_TTL_MS)
43
- return hit.body;
44
- const ctl = new AbortController();
45
- const timer = setTimeout(() => ctl.abort(), 10_000);
46
- try {
47
- const r = await fetch(url, { signal: ctl.signal });
48
- if (!r.ok)
49
- throw new Error(`upstream ${r.status}`);
50
- const body = await r.json();
51
- discoverCache.set(url, { at: Date.now(), body });
52
- return body;
53
- }
54
- finally {
55
- clearTimeout(timer);
56
- }
57
- }
37
+ /** Server-side fetch for the discovery directories (leaderboard / ENS names).
38
+ * Successes are cached, FAILURES ARE TOO, and concurrent callers share one
39
+ * request — see discover-cache.ts for the outage this comes from. */
40
+ const fetchDiscoverJson = createDiscoverFetch();
58
41
  // ── beagles.eth names (ens-gateway worker) ──────────────────────────────────
59
42
  // The same registry the mobile apps register into. Web-side registration
60
43
  // signs the record with THIS node's Carrier identity (XEdDSA via the backend
@@ -190,7 +173,7 @@ async function ensSignAndSet(call, record) {
190
173
  }
191
174
  // The record changed — drop the cached /names listing so the Names tab
192
175
  // reflects it immediately instead of after the 5-min TTL.
193
- discoverCache.delete(`${ENS_GATEWAY}/names`);
176
+ fetchDiscoverJson.invalidate(`${ENS_GATEWAY}/names`);
194
177
  return { ok: true };
195
178
  }
196
179
  catch (e) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/beagle",
3
- "version": "0.1.117",
3
+ "version": "0.1.118",
4
4
  "description": "Beagle — P2P chat, file transfer and calls for regular users, on the Decent Network. No admin privilege required.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -35,7 +35,7 @@
35
35
  "yargs": "^17.7.2"
36
36
  },
37
37
  "devDependencies": {
38
- "@decentnetwork/beagle-ui": "^0.2.31",
38
+ "@decentnetwork/beagle-ui": "^0.2.32",
39
39
  "@types/js-yaml": "^4.0.9",
40
40
  "@types/node": "^20.11.0",
41
41
  "@types/yargs": "^17.0.32",