@decentnetwork/beagle 0.1.56 → 0.1.58

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/dist/cli.js CHANGED
@@ -28,6 +28,7 @@ function parseArgs(argv) {
28
28
  " --config-dir <p> identity/config dir (default ~/.agentnet)",
29
29
  " --dora-dir <p> dora roster dir, if this machine runs a dora",
30
30
  " --backend <k> force 'daemon' or 'embedded' (default: auto)",
31
+ " --wait-daemon <s> with --backend daemon, wait this long for it (default 60)",
31
32
  " -h, --help this text",
32
33
  ].join("\n"));
33
34
  process.exit(0);
@@ -40,6 +41,7 @@ function parseArgs(argv) {
40
41
  configDir: get("--config-dir") ?? defaultConfigDir(),
41
42
  doraDir: get("--dora-dir"),
42
43
  backend: get("--backend"),
44
+ waitDaemon: get("--wait-daemon"),
43
45
  };
44
46
  }
45
47
  const readJsonVer = (file) => {
@@ -109,6 +111,7 @@ async function main() {
109
111
  onAutoAcceptChange: (enabled) => saveAutoAccept(args.configDir, enabled),
110
112
  peerVersion: resolveVer("@decentnetwork/peer"),
111
113
  force: args.backend,
114
+ waitForDaemonMs: args.waitDaemon ? Number(args.waitDaemon) * 1000 : undefined,
112
115
  }));
113
116
  }
114
117
  catch (error) {
@@ -1,4 +1,4 @@
1
- window.__DK_UI_VERSION="0.1.56";
1
+ window.__DK_UI_VERSION="0.1.58";
2
2
  const ICON_PATHS = {
3
3
  // ---- tab bar (the four must feel like one set) ----
4
4
  users: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
@@ -1275,9 +1275,39 @@ Object.assign(window, {
1275
1275
  TweakButton
1276
1276
  });
1277
1277
  function RequestsBlock({ T, requests, onAct }) {
1278
- if (!requests.length)
1278
+ const [acted, setActed] = React.useState({});
1279
+ const [failed, setFailed] = React.useState({});
1280
+ const act = (r, kind) => {
1281
+ if (acted[r.id])
1282
+ return;
1283
+ setActed((a) => ({ ...a, [r.id]: kind }));
1284
+ setFailed((f) => {
1285
+ const n = { ...f };
1286
+ delete n[r.id];
1287
+ return n;
1288
+ });
1289
+ Promise.resolve(onAct(r.id, kind)).then((res) => {
1290
+ if (res && res.ok === false) {
1291
+ setActed((a) => {
1292
+ const n = { ...a };
1293
+ delete n[r.id];
1294
+ return n;
1295
+ });
1296
+ setFailed((f) => ({ ...f, [r.id]: res.error || "failed" }));
1297
+ }
1298
+ }).catch((e) => {
1299
+ setActed((a) => {
1300
+ const n = { ...a };
1301
+ delete n[r.id];
1302
+ return n;
1303
+ });
1304
+ setFailed((f) => ({ ...f, [r.id]: String(e && e.message || e) }));
1305
+ });
1306
+ };
1307
+ const visible = requests.filter((r) => !acted[r.id]);
1308
+ if (!visible.length)
1279
1309
  return null;
1280
- return /* @__PURE__ */ React.createElement("div", { style: { padding: "4px 0 8px" } }, /* @__PURE__ */ React.createElement(Section, { label: T.requests, count: requests.length, style: { margin: "4px 4px 8px" } }), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", flexDirection: "column", gap: 6 } }, requests.map((r) => /* @__PURE__ */ React.createElement("div", { key: r.id, style: {
1310
+ return /* @__PURE__ */ React.createElement("div", { style: { padding: "4px 0 8px" } }, /* @__PURE__ */ React.createElement(Section, { label: T.requests, count: visible.length, style: { margin: "4px 4px 8px" } }), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", flexDirection: "column", gap: 6 } }, visible.map((r) => /* @__PURE__ */ React.createElement("div", { key: r.id, style: {
1281
1311
  padding: "9px 10px",
1282
1312
  borderRadius: 8,
1283
1313
  background: "var(--panel-2)",
@@ -1285,7 +1315,7 @@ function RequestsBlock({ T, requests, onAct }) {
1285
1315
  display: "flex",
1286
1316
  flexDirection: "column",
1287
1317
  gap: 8
1288
- } }, /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 8 } }, /* @__PURE__ */ React.createElement(DkIdenticon, { seed: r.carrier, size: 26, radius: 6 }), /* @__PURE__ */ React.createElement("div", { style: { minWidth: 0, flex: 1 } }, /* @__PURE__ */ React.createElement(Mono, { size: 12, copy: r.carrier, title: r.carrier }, shortKey(r.carrier, 8, 6)), /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--mono)", fontSize: 10.5, color: "var(--faint)", marginTop: 2 } }, "via ", r.via, " \xB7 ", r.time))), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", gap: 6 } }, /* @__PURE__ */ React.createElement(Btn, { tone: "ok", icon: "check", size: "sm", onClick: () => onAct(r.id, "accept"), style: { flex: 1 } }, T.accept), /* @__PURE__ */ React.createElement(Btn, { tone: "danger", icon: "x", size: "sm", onClick: () => onAct(r.id, "reject"), style: { flex: 1 } }, T.reject))))));
1318
+ } }, /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 8 } }, /* @__PURE__ */ React.createElement(DkIdenticon, { seed: r.carrier, size: 26, radius: 6 }), /* @__PURE__ */ React.createElement("div", { style: { minWidth: 0, flex: 1 } }, /* @__PURE__ */ React.createElement(Mono, { size: 12, copy: r.carrier, title: r.carrier }, shortKey(r.carrier, 8, 6)), /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--mono)", fontSize: 10.5, color: "var(--faint)", marginTop: 2 } }, "via ", r.via, " \xB7 ", r.time))), failed[r.id] && /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--ui)", fontSize: 11, color: "var(--danger)" } }, failed[r.id]), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", gap: 6 } }, /* @__PURE__ */ React.createElement(Btn, { tone: "ok", icon: "check", size: "sm", onClick: () => act(r, "accept"), style: { flex: 1 } }, T.accept), /* @__PURE__ */ React.createElement(Btn, { tone: "danger", icon: "x", size: "sm", onClick: () => act(r, "reject"), style: { flex: 1 } }, T.reject))))));
1289
1319
  }
1290
1320
  function PeerRow({ peer, T, active, onClick }) {
1291
1321
  const name = peer.alias || peer.userId;
@@ -5031,7 +5061,10 @@ function DkApp() {
5031
5061
  const onAct = (id, kind) => {
5032
5062
  const r = requests.find((x) => x.id === id);
5033
5063
  const uid = r && r.userid || id;
5034
- (kind === "accept" ? dkApi.accept(uid) : dkApi.reject(uid)).then(data.refresh);
5064
+ return (kind === "accept" ? dkApi.accept(uid) : dkApi.reject(uid)).then((res) => {
5065
+ data.refresh();
5066
+ return res;
5067
+ });
5035
5068
  };
5036
5069
  const onRemove = (peer) => {
5037
5070
  dkApi.remove(peer.id).then(data.refresh);
@@ -62,6 +62,7 @@ export class EmbeddedHost {
62
62
  * order, so a new message can never overtake an older one whose ACK is
63
63
  * still pending. Entry removed when the chain drains. */
64
64
  #sendChains = new Map();
65
+ #sweepTimer = null;
65
66
  /** Friend requests held for manual accept when auto-accept is off. */
66
67
  #pending = new Map();
67
68
  /** Inbound call signals as a broadcast ring: every poller reads at its own
@@ -143,6 +144,50 @@ export class EmbeddedHost {
143
144
  await this.#node.start();
144
145
  await this.#node.join();
145
146
  await mkdir(this.downloadsDir, { recursive: true }).catch(() => undefined);
147
+ // The sweep is what actually makes "queued" resolve.
148
+ //
149
+ // Flushing only on the connected EDGE looked right and was not: if the
150
+ // peer was already connected when the message was queued, that edge had
151
+ // long since passed and nothing ever tried again — the message sat queued
152
+ // while the friend showed online. Ask it the other way round on a timer:
153
+ // who has mail waiting, and can we reach them now. Level-triggered, so a
154
+ // missed or early event costs one interval instead of the message.
155
+ this.#sweepTimer = setInterval(() => {
156
+ if (this.#sweeping)
157
+ return;
158
+ this.#sweeping = true;
159
+ void (async () => {
160
+ try {
161
+ for (const uid of this.#messages.queuedPeers()) {
162
+ if (this.#sessionUsable(uid))
163
+ await this.#chainFlush(uid);
164
+ }
165
+ }
166
+ finally {
167
+ this.#sweeping = false;
168
+ }
169
+ })();
170
+ }, 8000);
171
+ this.#sweepTimer.unref?.();
172
+ }
173
+ #sweeping = false;
174
+ /** A session that can actually carry traffic right now. */
175
+ #sessionUsable(pubkey) {
176
+ try {
177
+ const st = this.#node.sessionStatus(pubkey);
178
+ return !!(st && st.established && (st.udpRemote || st.hasTcpRoute));
179
+ }
180
+ catch {
181
+ return false;
182
+ }
183
+ }
184
+ /** Queue a flush behind whatever that peer already has in flight, so a
185
+ * sweep and a send can never interleave and reorder the outbox. */
186
+ #chainFlush(pubkey) {
187
+ const prev = this.#sendChains.get(pubkey) ?? Promise.resolve();
188
+ const job = prev.then(() => this.#flushOutbox(pubkey).catch(() => { }));
189
+ this.#sendChains.set(pubkey, job.catch(() => { }));
190
+ return job;
146
191
  }
147
192
  #wire() {
148
193
  this.#node.on("message", (pubkey, text, via) => {
@@ -152,6 +197,9 @@ export class EmbeddedHost {
152
197
  this.#messages.append(pubkey, "in", text, Date.now(), undefined, via);
153
198
  this.#meta.ensure(pubkey);
154
199
  this.#events.emit("event", { type: "chat", userid: pubkey, dir: "in" });
200
+ // Hearing from them proves the session carries traffic — better
201
+ // evidence than any status flag, so use it.
202
+ void this.#chainFlush(pubkey);
155
203
  });
156
204
  this.#node.on("friend-request", (req) => {
157
205
  const userid = req.userid ?? req.pubkey;
@@ -179,15 +227,8 @@ export class EmbeddedHost {
179
227
  // sat visibly stuck despite the peer being online. One delayed retry
180
228
  // covers that gap; the per-send chain and later reconnects cover the
181
229
  // rest.
182
- if (e.status === "connected") {
183
- void this.#flushOutbox(e.pubkey)
184
- .catch(() => { })
185
- .then(() => {
186
- if (this.#messages.queuedOutgoing(e.pubkey).length === 0)
187
- return;
188
- return new Promise((r) => setTimeout(r, 4000)).then(() => this.#flushOutbox(e.pubkey).catch(() => { }));
189
- });
190
- }
230
+ if (e.status === "connected")
231
+ void this.#chainFlush(e.pubkey); // fast path
191
232
  });
192
233
  this.#node.on("call-signal", (evt) => {
193
234
  this.#callLog.push({
@@ -322,6 +363,10 @@ export class EmbeddedHost {
322
363
  return () => this.#events.off("event", fn);
323
364
  }
324
365
  async stop() {
366
+ if (this.#sweepTimer) {
367
+ clearInterval(this.#sweepTimer);
368
+ this.#sweepTimer = null;
369
+ }
325
370
  // Stop the peer FIRST, then release the lock. The reverse order would open
326
371
  // a window where a daemon could start while our peer is still live.
327
372
  await this.#node.stop();
@@ -405,16 +450,38 @@ export class EmbeddedHost {
405
450
  }),
406
451
  };
407
452
  }
408
- case "friend-request":
409
- await this.#node.sendFriendRequest(String(req.address ?? ""), req.hello);
453
+ case "friend-request": {
454
+ // sendFriendRequest waits for our own announce to store and then walks
455
+ // the DHT toward the target — tens of seconds is normal. server.ts
456
+ // already races this with a 2.5s timer so the HTTP layer cannot hang,
457
+ // but a validation error has to come back fast to be useful, so do the
458
+ // cheap check here and let the slow part run unattended.
459
+ const address = String(req.address ?? "");
460
+ if (!address)
461
+ throw new Error("friend-request requires an address");
462
+ void this.#node
463
+ .sendFriendRequest(address, req.hello)
464
+ .catch((e) => this.#logger.info(`friend-request to ${address.slice(0, 8)} failed: ${e.message}`));
410
465
  return {};
466
+ }
411
467
  case "friends-pending":
412
468
  return { pending: [...this.#pending.values()] };
413
469
  case "friends-accept": {
414
470
  const entry = this.#pending.get(uid);
415
- if (entry) {
471
+ if (!entry)
472
+ return {};
473
+ // Drop it from pending BEFORE the node call, and answer without
474
+ // waiting on anything the network owns. The request card is rendered
475
+ // from a poll, so awaiting here left the user clicking Accept with
476
+ // nothing happening on screen at all — the same class of bug as
477
+ // chat-send's, which this file already fixed for messages.
478
+ this.#pending.delete(uid);
479
+ try {
416
480
  await this.#node.acceptFriendRequest(entry.pubkey);
417
- this.#pending.delete(uid);
481
+ }
482
+ catch (e) {
483
+ this.#pending.set(uid, entry); // refused: leave state as it was
484
+ throw e;
418
485
  }
419
486
  return {};
420
487
  }
@@ -66,6 +66,9 @@ export declare class MessageStore {
66
66
  /** Outgoing messages still awaiting delivery (peer was offline), oldest
67
67
  * first — both queued text and queued file chips. The daemon drains this
68
68
  * on a friend's reconnect. */
69
+ /** Every peer with outgoing mail still waiting, so the outbox sweep does
70
+ * not have to ask per friend. */
71
+ queuedPeers(): string[];
69
72
  queuedOutgoing(peer: string): ChatMessage[];
70
73
  /** Append a file-transfer entry (shown as a file chip in the UI). */
71
74
  appendFile(peer: string, dir: "in" | "out", file: {
@@ -83,6 +83,16 @@ export class MessageStore {
83
83
  /** Outgoing messages still awaiting delivery (peer was offline), oldest
84
84
  * first — both queued text and queued file chips. The daemon drains this
85
85
  * on a friend's reconnect. */
86
+ /** Every peer with outgoing mail still waiting, so the outbox sweep does
87
+ * not have to ask per friend. */
88
+ queuedPeers() {
89
+ const out = [];
90
+ for (const [peer, arr] of this.byPeer) {
91
+ if (arr.some((m) => m.dir === "out" && (m.status === "queued" || m.file?.status === "queued")))
92
+ out.push(peer);
93
+ }
94
+ return out;
95
+ }
86
96
  queuedOutgoing(peer) {
87
97
  const arr = this.byPeer.get(peer) ?? [];
88
98
  return arr.filter((m) => m.dir === "out" && (m.status === "queued" || m.file?.status === "queued"));
@@ -46,6 +46,8 @@ export interface OpenPeerHostOptions {
46
46
  /** Force a backend instead of auto-detecting. Mainly for testing the
47
47
  * embedded path on a machine that also runs a daemon. */
48
48
  force?: BackendKind;
49
+ /** How long `force: "daemon"` waits for the daemon socket. Default 60s. */
50
+ waitForDaemonMs?: number;
49
51
  }
50
52
  /**
51
53
  * Pick a backend.
package/dist/peer-host.js CHANGED
@@ -77,7 +77,35 @@ export async function openPeerHost(opts) {
77
77
  return { host: new DaemonHost(dataDir), why: `decentlan daemon at ${dataDir}` };
78
78
  }
79
79
  if (opts.force === "daemon") {
80
- throw new Error(`--backend daemon was requested but no decentlan daemon is running (looked for ${ipcSocketPath(dataDir)}).`);
80
+ // Wait rather than die.
81
+ //
82
+ // A supervised beagle (pm2, systemd) routinely starts BEFORE the daemon it
83
+ // is meant to attach to. Throwing there turned a boot-order race into a
84
+ // permanent problem: beagle fell back to embedded, took <dataDir>/daemon.pid,
85
+ // and from then on `agentnet service restart` refused to start the daemon
86
+ // because a live process already held the identity — which is why upgrading
87
+ // needed a pm2 stop / restart / pm2 start dance instead of one command.
88
+ //
89
+ // Explicit --backend daemon means "this box's identity belongs to the
90
+ // daemon". Honour that by waiting for it to appear.
91
+ const waitMs = Math.max(0, opts.waitForDaemonMs ?? 60_000);
92
+ const deadline = Date.now() + waitMs;
93
+ let waited = false;
94
+ while (!daemonIsRunning(dataDir)) {
95
+ if (Date.now() >= deadline) {
96
+ throw new Error(`--backend daemon was requested but no decentlan daemon appeared within ${Math.round(waitMs / 1000)}s ` +
97
+ `(looked for ${ipcSocketPath(dataDir)}).`);
98
+ }
99
+ if (!waited) {
100
+ waited = true;
101
+ console.log(`waiting up to ${Math.round(waitMs / 1000)}s for the decentlan daemon at ${ipcSocketPath(dataDir)}…`);
102
+ }
103
+ await new Promise((r) => setTimeout(r, 1000));
104
+ }
105
+ return {
106
+ host: new DaemonHost(dataDir),
107
+ why: waited ? `decentlan daemon at ${dataDir} (after waiting)` : `decentlan daemon at ${dataDir}`,
108
+ };
81
109
  }
82
110
  // Reuse decentlan's identity when it exists so friends carry over — this is
83
111
  // the same node, not a new one. The daemon is not running, so nothing else
package/dist/server.js CHANGED
@@ -120,13 +120,43 @@ async function ensSignAndSet(call, record) {
120
120
  // nftid=<punk id> on the beagles.eth name. Proxied server-side (browser can't
121
121
  // reach the origin's port cross-origin reliably); NOTE the upstream's TLS cert
122
122
  // must be valid — renew it when these routes start failing.
123
- const PUNKS_API_URL = process.env.PUNKS_API_URL || "https://app.beagle.chat:1337";
123
+ // api.beagle.chat/punksapi, NOT app.beagle.chat:1337. That name was repointed
124
+ // to GitHub Pages when the browser client took it over, so this default aimed
125
+ // at a host that serves nothing on 1337 and every punk avatar in the desktop
126
+ // app silently fell back to an identicon. The service itself never moved — it
127
+ // is the same box, now reached through Caddy on 443 instead of its own TLS
128
+ // listener, so there is no second certificate to keep alive.
129
+ // Endpoint of last resort. The real one comes from bgservers.json (punksApi)
130
+ // via punksBase() below, so the host can move without shipping a new build —
131
+ // which is the failure this whole comment exists because of: app.beagle.chat
132
+ // :1337 was baked in here, the name was repointed at GitHub Pages, and every
133
+ // punk avatar in the app quietly became an identicon.
134
+ const PUNKS_API_URL = process.env.PUNKS_API_URL || "https://api.beagle.chat/punksapi";
135
+ const BGSERVERS_URL = process.env.BGSERVERS_URL || "https://beagle.chat/assets/bgservers.json";
136
+ let punksBaseCache = null;
137
+ function punksBase() {
138
+ if (process.env.PUNKS_API_URL)
139
+ return Promise.resolve(process.env.PUNKS_API_URL);
140
+ if (!punksBaseCache) {
141
+ punksBaseCache = (async () => {
142
+ try {
143
+ const cfg = (await fetchDiscoverJson(BGSERVERS_URL));
144
+ const u = cfg?.punksApi?.[0]?.url;
145
+ if (typeof u === "string" && u)
146
+ return u.replace(/\/+$/, "");
147
+ }
148
+ catch { /* fall through to the built-in default */ }
149
+ return PUNKS_API_URL;
150
+ })();
151
+ }
152
+ return punksBaseCache;
153
+ }
124
154
  const punkCache = new Map(); // punk id → JSON; immutable set
125
155
  async function punksFetch(path) {
126
156
  const ctl = new AbortController();
127
157
  const timer = setTimeout(() => ctl.abort(), 10_000);
128
158
  try {
129
- const r = await fetch(`${PUNKS_API_URL}${path}`, { signal: ctl.signal });
159
+ const r = await fetch(`${await punksBase()}${path}`, { signal: ctl.signal });
130
160
  if (!r.ok)
131
161
  throw new Error(`punks upstream ${r.status}`);
132
162
  return await r.json();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/beagle",
3
- "version": "0.1.56",
3
+ "version": "0.1.58",
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",