@decentnetwork/beagle 0.1.9 → 0.1.11

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.9";
1
+ window.__DK_UI_VERSION="0.1.11";
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"/>',
@@ -509,12 +509,16 @@ function dkRtcSignalBus() {
509
509
  }
510
510
  }
511
511
  async function pollLoop() {
512
+ let cursor = null;
512
513
  while (!stopped) {
513
514
  let signals = [];
514
515
  try {
515
- const r = await fetch("/api/call-poll", { headers: { "cache-control": "no-cache" } });
516
+ const u = cursor == null ? "/api/call-poll" : "/api/call-poll?since=" + cursor;
517
+ const r = await fetch(u, { headers: { "cache-control": "no-cache" } });
516
518
  const d = await r.json();
517
519
  signals = d && d.signals || [];
520
+ if (d && typeof d.cursor === "number")
521
+ cursor = d.cursor;
518
522
  } catch (e) {
519
523
  await new Promise((res) => setTimeout(res, 1e3));
520
524
  continue;
@@ -3145,14 +3149,19 @@ function useCallController(selfId, onCallLog) {
3145
3149
  alert("Could not start call: " + (e && e.message || e));
3146
3150
  });
3147
3151
  }, [waitEngine]);
3152
+ const incomingRef = React.useRef(null);
3153
+ React.useEffect(() => {
3154
+ incomingRef.current = incoming;
3155
+ }, [incoming]);
3156
+ const activeRef = React.useRef(null);
3157
+ React.useEffect(() => {
3158
+ activeRef.current = active;
3159
+ }, [active]);
3148
3160
  const accept = React.useCallback(() => {
3149
- let pending = null;
3150
- setIncoming((inc) => {
3151
- pending = inc;
3152
- return null;
3153
- });
3161
+ const pending = incomingRef.current;
3154
3162
  if (!pending)
3155
3163
  return;
3164
+ setIncoming(null);
3156
3165
  setActive({ callId: pending.callId, peerId: pending.peerId, video: pending.video, direction: "incoming", state: "connecting" });
3157
3166
  waitEngine().then((eng) => {
3158
3167
  if (!eng)
@@ -3162,19 +3171,17 @@ function useCallController(selfId, onCallLog) {
3162
3171
  }, [waitEngine]);
3163
3172
  const reject = React.useCallback(() => {
3164
3173
  const eng = engineRef.current;
3165
- setIncoming((inc) => {
3166
- if (eng && inc)
3167
- eng.reject(inc.callId);
3168
- return null;
3169
- });
3174
+ const inc = incomingRef.current;
3175
+ if (eng && inc)
3176
+ eng.reject(inc.callId);
3177
+ setIncoming(null);
3170
3178
  }, []);
3171
3179
  const hangup = React.useCallback(() => {
3172
3180
  const eng = engineRef.current;
3173
- setActive((a) => {
3174
- if (eng && a && a.callId)
3175
- eng.hangup(a.callId);
3176
- return null;
3177
- });
3181
+ const a = activeRef.current;
3182
+ if (eng && a && a.callId)
3183
+ eng.hangup(a.callId);
3184
+ setActive(null);
3178
3185
  setLocalStream(null);
3179
3186
  setRemoteStream(null);
3180
3187
  }, []);
@@ -36,4 +36,6 @@ export declare class EmbeddedHost implements PeerHost {
36
36
  subscribe(emit: (event: Record<string, unknown>) => void): () => void;
37
37
  stop(): Promise<void>;
38
38
  call(req: IpcRequest): Promise<IpcResponse>;
39
+ /** Signals older than this have no business ringing a phone. */
40
+ static readonly CALL_SIGNAL_TTL_MS = 90000;
39
41
  }
@@ -8,6 +8,7 @@
8
8
  // What is deliberately NOT here, versus the daemon: virtual IPs, exits, dora,
9
9
  // ACL, packet routing. Those are decentlan's job and need privilege beagle
10
10
  // refuses to ask for.
11
+ var _a;
11
12
  import { EventEmitter } from "node:events";
12
13
  import { existsSync } from "node:fs";
13
14
  import { mkdir, readFile, writeFile, unlink } from "node:fs/promises";
@@ -53,8 +54,19 @@ export class EmbeddedHost {
53
54
  #sendChains = new Map();
54
55
  /** Friend requests held for manual accept when auto-accept is off. */
55
56
  #pending = new Map();
56
- /** Inbound call signals waiting for the UI's long-poll to collect them. */
57
- #callQueue = [];
57
+ /** Inbound call signals as a broadcast ring: every poller reads at its own
58
+ * cursor, entries expire by AGE, never by being read. The previous shape —
59
+ * a queue drained by splice(0) — handed the whole batch to whichever
60
+ * poller arrived first, so with two tabs open an incoming call rang a
61
+ * random one and the tab the user was looking at stayed silent
62
+ * (INBOX 2026-08-05: "能打出去接不进来"). */
63
+ #callLog = [];
64
+ /** Seeded with the epoch so a cursor from a PREVIOUS process incarnation is
65
+ * always smaller than any new signal's seq. Seeded at 0, a restart made
66
+ * every open tab deaf: their old cursor (say 27) exceeded the fresh
67
+ * counter, so `seq > since` never matched and calls stopped ringing until
68
+ * a hard refresh. */
69
+ #callSeq = Date.now();
58
70
  #callWaiters = [];
59
71
  /** fileId -> which chat message it belongs to, so progress can patch it. */
60
72
  #activeSends = new Map();
@@ -147,8 +159,14 @@ export class EmbeddedHost {
147
159
  }
148
160
  });
149
161
  this.#node.on("call-signal", (evt) => {
150
- this.#callQueue.push({ userid: evt.pubkey, data: Buffer.from(evt.data).toString("utf-8") });
151
- // Release every long-poll waiting on this queue.
162
+ this.#callLog.push({
163
+ seq: ++this.#callSeq,
164
+ ts: Date.now(),
165
+ userid: evt.pubkey,
166
+ data: Buffer.from(evt.data).toString("utf-8"),
167
+ });
168
+ this.#pruneCallLog();
169
+ // Wake every long-poll; each reads from its own cursor, nobody consumes.
152
170
  const waiters = this.#callWaiters.splice(0);
153
171
  for (const w of waiters)
154
172
  w();
@@ -495,32 +513,46 @@ export class EmbeddedHost {
495
513
  await this.#node.sendCallSignal(uid, String(req.text ?? req.data ?? ""));
496
514
  return {};
497
515
  case "call-poll":
498
- return { signals: await this.#pollCalls() };
516
+ return this.#pollCalls(typeof req.since === "number" ? req.since : undefined);
499
517
  default:
500
518
  throw new Error(`Unsupported op in embedded backend: ${req.op}`);
501
519
  }
502
520
  }
503
- /** Long-poll: return immediately if signals are queued, else hold ~20s. The
504
- * UI re-polls, which gives near-instant offer/answer delivery without a
505
- * WebSocket. Matches the daemon's behaviour exactly so call.jsx is unchanged. */
506
- async #pollCalls() {
507
- if (this.#callQueue.length)
508
- return this.#callQueue.splice(0);
509
- await new Promise((res) => {
510
- const done = () => {
511
- clearTimeout(timer);
512
- res();
513
- };
514
- const timer = setTimeout(() => {
515
- const i = this.#callWaiters.indexOf(done);
516
- if (i >= 0)
517
- this.#callWaiters.splice(i, 1);
518
- res();
519
- }, 20_000);
520
- timer.unref?.();
521
- this.#callWaiters.push(done);
522
- });
523
- return this.#callQueue.splice(0);
521
+ /** Signals older than this have no business ringing a phone. */
522
+ static CALL_SIGNAL_TTL_MS = 90_000;
523
+ #pruneCallLog() {
524
+ const cutoff = Date.now() - _a.CALL_SIGNAL_TTL_MS;
525
+ while (this.#callLog.length && this.#callLog[0].ts < cutoff)
526
+ this.#callLog.shift();
527
+ }
528
+ /** Long-poll at a cursor: every subscriber sees every signal (broadcast),
529
+ * reading never consumes. `since` is the cursor from the previous
530
+ * response; a caller without one (older UI) starts at "now" and gets only
531
+ * future signals — exactly the old semantics minus the stealing. Returns
532
+ * immediately when the log has anything past the cursor, else holds ~20s. */
533
+ async #pollCalls(since) {
534
+ const from = since ?? this.#callSeq;
535
+ this.#pruneCallLog();
536
+ let avail = this.#callLog.filter((s) => s.seq > from);
537
+ if (!avail.length) {
538
+ await new Promise((res) => {
539
+ const done = () => {
540
+ clearTimeout(timer);
541
+ res();
542
+ };
543
+ const timer = setTimeout(() => {
544
+ const i = this.#callWaiters.indexOf(done);
545
+ if (i >= 0)
546
+ this.#callWaiters.splice(i, 1);
547
+ res();
548
+ }, 20_000);
549
+ timer.unref?.();
550
+ this.#callWaiters.push(done);
551
+ });
552
+ this.#pruneCallLog();
553
+ avail = this.#callLog.filter((s) => s.seq > from);
554
+ }
555
+ return { signals: avail.map(({ userid, data }) => ({ userid, data })), cursor: this.#callSeq };
524
556
  }
525
557
  async #fileSend(userid, path) {
526
558
  const data = await readFile(path);
@@ -578,3 +610,4 @@ export class EmbeddedHost {
578
610
  return { fileId, name: safe, size: data.length };
579
611
  }
580
612
  }
613
+ _a = EmbeddedHost;
package/dist/server.js CHANGED
@@ -900,8 +900,17 @@ export function startBeagleServer(opts) {
900
900
  // The daemon holds this up to ~20s (long-poll). opts.call's own IPC
901
901
  // timeout is 30s, so it returns before that; on any error, resolve
902
902
  // empty so the browser simply re-polls.
903
+ //
904
+ // `since` is the caller's broadcast cursor: signal delivery is
905
+ // per-subscriber, so two open tabs BOTH ring on an incoming call.
906
+ // Without it (older UI) the backend serves only future signals.
907
+ const sinceRaw = new URL(req.url || "/", "http://x").searchParams.get("since");
908
+ const since = sinceRaw !== null && sinceRaw !== "" ? Number(sinceRaw) : undefined;
903
909
  try {
904
- const r = await opts.call({ op: "call-poll" });
910
+ const r = await opts.call({
911
+ op: "call-poll",
912
+ ...(Number.isFinite(since) ? { since } : {}),
913
+ });
905
914
  sendJson(res, 200, r.ok ? r.data ?? { signals: [] } : { signals: [] });
906
915
  }
907
916
  catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/beagle",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
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",