@decentnetwork/lan 0.1.102 → 0.1.104

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.
Binary file
Binary file
Binary file
Binary file
@@ -435,10 +435,24 @@ function App({ client }) {
435
435
  }
436
436
  };
437
437
  void tick();
438
- const iv = setInterval(() => void tick(), 1400);
438
+ let iv = setInterval(() => {
439
+ if (alive) void tick();
440
+ }, 6e3);
441
+ const stop = client.subscribe(
442
+ () => {
443
+ if (alive) void tick();
444
+ },
445
+ () => {
446
+ clearInterval(iv);
447
+ iv = setInterval(() => {
448
+ if (alive) void tick();
449
+ }, 2500);
450
+ }
451
+ );
439
452
  return () => {
440
453
  alive = false;
441
454
  clearInterval(iv);
455
+ stop();
442
456
  };
443
457
  }, [client, selId]);
444
458
  React.useEffect(() => {
@@ -909,6 +923,64 @@ var DaemonClient = class _DaemonClient {
909
923
  });
910
924
  });
911
925
  }
926
+ /**
927
+ * Open a persistent subscription to daemon push events (chat / presence /
928
+ * friend-request). Calls `onEvent` for each event and auto-reconnects with
929
+ * backoff if the socket drops. Returns a stop() to tear it down.
930
+ *
931
+ * Degrades gracefully: an older daemon that doesn't know the `subscribe`
932
+ * op replies `{ok:false}` and closes — we surface that via `onUnsupported`
933
+ * so the caller can keep its polling fallback instead of reconnecting in a
934
+ * tight loop. A daemon that supports it acks with `{subscribed:true}` first.
935
+ */
936
+ subscribe(onEvent, onUnsupported) {
937
+ let stopped = false;
938
+ let sock;
939
+ let retry;
940
+ const connect = () => {
941
+ if (stopped) return;
942
+ let buf = "";
943
+ let acked = false;
944
+ sock = createConnection(this.sockPath);
945
+ sock.on("connect", () => sock.write(JSON.stringify({ op: "subscribe" }) + "\n"));
946
+ sock.on("data", (c) => {
947
+ buf += c.toString("utf-8");
948
+ let nl;
949
+ while ((nl = buf.indexOf("\n")) >= 0) {
950
+ const line = buf.slice(0, nl);
951
+ buf = buf.slice(nl + 1);
952
+ if (!line) continue;
953
+ try {
954
+ const msg = JSON.parse(line);
955
+ if (!acked) {
956
+ acked = true;
957
+ if (msg.ok === false) {
958
+ stopped = true;
959
+ sock?.end();
960
+ onUnsupported?.();
961
+ return;
962
+ }
963
+ continue;
964
+ }
965
+ if (msg.event) onEvent(msg.event);
966
+ } catch {
967
+ }
968
+ }
969
+ });
970
+ const reconnect = () => {
971
+ if (stopped) return;
972
+ retry = setTimeout(connect, 1500);
973
+ };
974
+ sock.on("error", reconnect);
975
+ sock.on("close", reconnect);
976
+ };
977
+ connect();
978
+ return () => {
979
+ stopped = true;
980
+ if (retry) clearTimeout(retry);
981
+ sock?.destroy();
982
+ };
983
+ }
912
984
  static data(r) {
913
985
  return r.ok && r.data ? r.data : {};
914
986
  }
@@ -56,6 +56,12 @@ export interface IpcHandlers {
56
56
  name?: string;
57
57
  description?: string;
58
58
  }) => Promise<void>;
59
+ /** Subscribe to daemon push events (chat / presence / friend-request). The
60
+ * IPC server keeps the connection open and calls `emit` for each event;
61
+ * the returned function unsubscribes (called on socket close). Optional —
62
+ * when absent, clients fall back to polling. Lets the TUI/UI react to new
63
+ * messages instantly instead of polling on a tight interval. */
64
+ subscribe?: (emit: (event: Record<string, unknown>) => void) => () => void;
59
65
  /** Offer a local file (by path) to a friend over toxcore file transfer. */
60
66
  fileSend: (userid: string, path: string) => Promise<Record<string, unknown>>;
61
67
  /** Mark a conversation read up to `ts` (defaults to now) — clears unread. */
@@ -76,7 +82,7 @@ export interface IpcHandlers {
76
82
  selfRestart: () => Promise<Record<string, unknown>>;
77
83
  }
78
84
  export interface IpcRequest {
79
- op: "friend-request" | "ping" | "diag" | "friends-pending" | "friends-accept" | "friends-reject" | "chat-send" | "chat-history" | "friends-list" | "friend-remove" | "friend-set-alias" | "set-profile" | "file-send" | "chat-mark-read" | "proxy-reload" | "self-restart";
85
+ op: "friend-request" | "ping" | "diag" | "friends-pending" | "friends-accept" | "friends-reject" | "chat-send" | "chat-history" | "friends-list" | "friend-remove" | "friend-set-alias" | "set-profile" | "file-send" | "chat-mark-read" | "subscribe" | "proxy-reload" | "self-restart";
80
86
  address?: string;
81
87
  hello?: string;
82
88
  userid?: string;
@@ -109,6 +109,44 @@ export class IpcServer {
109
109
  reply({ ok: false, error: "malformed request (not JSON)" });
110
110
  return;
111
111
  }
112
+ // Streaming subscription: keep the socket OPEN and push newline-JSON
113
+ // events until the client disconnects. This is the one op that breaks
114
+ // the one-shot model — it lets the TUI/UI react to new messages and
115
+ // presence changes instantly instead of polling on a tight interval.
116
+ if (req.op === "subscribe") {
117
+ if (!this.handlers.subscribe) {
118
+ reply({ ok: false, error: "subscribe not supported" });
119
+ return;
120
+ }
121
+ // First line acks the subscription so the client knows push is live
122
+ // (and can distinguish an old daemon that rejects the op).
123
+ try {
124
+ sock.write(JSON.stringify({ ok: true, data: { subscribed: true } }) + "\n");
125
+ }
126
+ catch {
127
+ sock.end();
128
+ return;
129
+ }
130
+ const unsubscribe = this.handlers.subscribe((event) => {
131
+ try {
132
+ sock.write(JSON.stringify({ event }) + "\n");
133
+ }
134
+ catch {
135
+ // socket gone; cleanup happens via the close handler
136
+ }
137
+ });
138
+ const cleanup = () => {
139
+ try {
140
+ unsubscribe();
141
+ }
142
+ catch {
143
+ // best-effort
144
+ }
145
+ };
146
+ sock.on("close", cleanup);
147
+ sock.on("end", cleanup);
148
+ return;
149
+ }
112
150
  void this.dispatch(req)
113
151
  .then((data) => reply({ ok: true, ...(data ? { data } : {}) }))
114
152
  .catch((err) => reply({ ok: false, error: err instanceof Error ? err.message : String(err) }));
@@ -37,6 +37,10 @@ export declare class DaemonServer {
37
37
  * the `agentnet ui` chat window; not persisted across restarts. */
38
38
  private messageStore?;
39
39
  private friendMeta?;
40
+ /** Push-event fan-out for IPC subscribers (TUI/UI). Fires on inbound/outbound
41
+ * chat, presence changes, and friend-requests so clients can refresh on
42
+ * demand instead of polling on a tight interval. */
43
+ private readonly ipcEvents;
40
44
  private startedAt;
41
45
  private isRunning;
42
46
  private statusTimer?;
@@ -2,6 +2,7 @@
2
2
  * Daemon Server — wires together all components
3
3
  */
4
4
  import { resolve } from "path";
5
+ import { EventEmitter } from "events";
5
6
  import { existsSync, readFileSync, writeFileSync, unlinkSync } from "fs";
6
7
  import { PeerManager } from "../carrier/peer-manager.js";
7
8
  import { TunDevice } from "../tun/tun-device.js";
@@ -56,6 +57,10 @@ export class DaemonServer {
56
57
  * the `agentnet ui` chat window; not persisted across restarts. */
57
58
  messageStore;
58
59
  friendMeta;
60
+ /** Push-event fan-out for IPC subscribers (TUI/UI). Fires on inbound/outbound
61
+ * chat, presence changes, and friend-requests so clients can refresh on
62
+ * demand instead of polling on a tight interval. */
63
+ ipcEvents = new EventEmitter();
59
64
  startedAt = 0;
60
65
  isRunning = false;
61
66
  statusTimer;
@@ -204,6 +209,7 @@ export class DaemonServer {
204
209
  // Start IPC as soon as the peer is up. The CLI uses it to drive
205
210
  // operations that need the daemon's Carrier identity (e.g.
206
211
  // friend-request) without spawning a competing Peer instance.
212
+ this.ipcEvents.setMaxListeners(50);
207
213
  this.ipcServer = new IpcServer(ipcSocketPath(this.config.carrier.dataDir), {
208
214
  friendRequest: async (address, hello) => {
209
215
  await this.peerManager.sendFriendRequest(address, hello);
@@ -309,6 +315,11 @@ export class DaemonServer {
309
315
  chatMarkRead: async (userid, ts) => {
310
316
  this.friendMeta?.markRead(userid, ts);
311
317
  },
318
+ subscribe: (emit) => {
319
+ const onEvent = (event) => emit(event);
320
+ this.ipcEvents.on("event", onEvent);
321
+ return () => this.ipcEvents.off("event", onEvent);
322
+ },
312
323
  proxyReload: async () => {
313
324
  // Re-read the proxy allowlist from disk and push it into the
314
325
  // running proxy without a daemon restart (which would drop
@@ -555,6 +566,11 @@ export class DaemonServer {
555
566
  this.peerManager.on("message", (pubkey, text) => {
556
567
  this.logChat(pubkey, "in", text);
557
568
  });
569
+ // Presence: push to IPC subscribers so the TUI/UI reflects online/offline
570
+ // transitions immediately instead of on the next poll tick.
571
+ this.peerManager.on("friend-connection", (evt) => {
572
+ this.ipcEvents.emit("event", { type: "presence", userid: evt?.pubkey, status: evt?.status });
573
+ });
558
574
  // File transfer (toxcore-standard): auto-accept offers from friends and
559
575
  // save completed files under <configDir>/downloads/.
560
576
  this.peerManager.on("file-offer", (o) => {
@@ -580,6 +596,7 @@ export class DaemonServer {
580
596
  void pubkeyHexToUserid(req.pubkey).then((userid) => {
581
597
  const who = `${req.name || "(unnamed)"} ${userid}`;
582
598
  const hello = req.hello ? ` hello="${req.hello.slice(0, 60)}"` : "";
599
+ this.ipcEvents.emit("event", { type: "request", userid });
583
600
  if (autoAcceptFriends) {
584
601
  this.logger.info(`Friend request from ${who}${hello} — auto-accepting`);
585
602
  this.peerManager?.acceptFriendRequest(req.pubkey).catch((err) => {
@@ -802,6 +819,7 @@ export class DaemonServer {
802
819
  logChat(userid, dir, text) {
803
820
  this.messageStore?.append(userid, dir, text);
804
821
  this.friendMeta?.ensure(userid);
822
+ this.ipcEvents.emit("event", { type: "chat", userid, dir });
805
823
  }
806
824
  /** Per-peer timestamp of the last self-heal friend-request re-send, so
807
825
  * the watchdog escalates at most once per SELF_HEAL_REFRIEND_MS. */
@@ -1220,7 +1220,7 @@ const STR = {
1220
1220
  add: "Add",
1221
1221
  search: "search peers / ip\u2026",
1222
1222
  addSending: "sending friend-request\u2026",
1223
- addSent: "friend-request sent \u2014 they appear once they accept",
1223
+ addSent: "friend-request sent \u2014 delivered when they\u2019re online; appears here once they accept",
1224
1224
  addFailed: "could not send",
1225
1225
  requests: "Friend requests",
1226
1226
  accept: "accept",
@@ -1295,7 +1295,7 @@ const STR = {
1295
1295
  add: "\u6DFB\u52A0",
1296
1296
  search: "\u641C\u7D22\u597D\u53CB / IP\u2026",
1297
1297
  addSending: "\u6B63\u5728\u53D1\u9001\u597D\u53CB\u8BF7\u6C42\u2026",
1298
- addSent: "\u597D\u53CB\u8BF7\u6C42\u5DF2\u53D1\u9001 \u2014 \u5BF9\u65B9\u63A5\u53D7\u540E\u4F1A\u51FA\u73B0\u5728\u5217\u8868",
1298
+ addSent: "\u597D\u53CB\u8BF7\u6C42\u5DF2\u53D1\u9001 \u2014 \u5BF9\u65B9\u4E0A\u7EBF\u540E\u9001\u8FBE\uFF0C\u63A5\u53D7\u540E\u51FA\u73B0\u5728\u5217\u8868",
1299
1299
  addFailed: "\u53D1\u9001\u5931\u8D25",
1300
1300
  requests: "\u597D\u53CB\u8BF7\u6C42",
1301
1301
  accept: "\u63A5\u53D7",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/lan",
3
- "version": "0.1.102",
3
+ "version": "0.1.104",
4
4
  "description": "Private virtual LAN for self-hosted services and AI agents, built on Elastos Carrier. NAT-traversal, name service, ACL, all over a peer-to-peer mesh — no public IP required.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",