@decentnetwork/lan 0.1.165 → 0.1.175

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
@@ -0,0 +1,69 @@
1
+ import { EventEmitter } from "node:events";
2
+ import { type RtcSignal } from "@decentnetwork/peer-webrtc";
3
+ /**
4
+ * Configuration for the offline-call fallback. Defaults target the public
5
+ * Beagle gateways; the appKey/appName must match what the target device
6
+ * registered with (see the Beagle app's Service.swift).
7
+ */
8
+ export interface OfflineCallConfig {
9
+ /** Master switch. When false the bridge is inert. */
10
+ enabled: boolean;
11
+ /** Beagle push appKey (matches the callee's registration). */
12
+ appKey: string;
13
+ /** Beagle push appName — the callee app's CFBundleName ("Beagle"/"webrtc"). */
14
+ appName: string;
15
+ /** Socket.IO signaling gateway (primary). */
16
+ socketUrl: string;
17
+ /** Ordered socket.IO gateways to try — connects to the first that comes up,
18
+ * switches to the next on failure. Defaults to [socketUrl]. Both call peers
19
+ * must agree on a server, so keep this list in sync with the Beagle apps. */
20
+ socketUrls?: string[];
21
+ /** Push-API endpoints (POST /push-api/push-message), tried in order. */
22
+ pushEndpoints?: string[];
23
+ }
24
+ export declare const DEFAULT_OFFLINE_CONFIG: Omit<OfflineCallConfig, "enabled">;
25
+ /**
26
+ * Offline-call bridge — the socket.io + push path Beagle uses to reach a peer
27
+ * whose app is backgrounded/offline (so a live Carrier session doesn't exist).
28
+ *
29
+ * The daemon uses this as a FALLBACK: `sendCallSignal` tries Carrier first and
30
+ * drops to this only when there's no live session. Signaling then rides the
31
+ * socket.io room (keyed by the RtcSignal `callId`); the initial offer also fires
32
+ * a push to wake the callee. Inbound socket.io signals are re-emitted as
33
+ * `"signal"` events so the daemon forwards them to the browser exactly like
34
+ * Carrier invites.
35
+ *
36
+ * Runs entirely in the daemon (Node): socket.io-client for signaling, fetch for
37
+ * push. socket.io-client is an OPTIONAL dependency — if it isn't installed the
38
+ * bridge stays disabled and logs a hint rather than crashing.
39
+ */
40
+ export declare class OfflineCallBridge extends EventEmitter {
41
+ #private;
42
+ constructor(opts: {
43
+ config: OfflineCallConfig;
44
+ selfUserId: string;
45
+ selfName: string;
46
+ log?: (msg: string) => void;
47
+ });
48
+ get enabled(): boolean;
49
+ /**
50
+ * Send a call signal to `userid` over the offline path. Decodes the signal to
51
+ * key the room by `callId`; for the initial offer it also pushes to wake the
52
+ * callee. Throws if the bridge can't be brought up (caller keeps the original
53
+ * Carrier error).
54
+ */
55
+ /** Extract the callId from a raw signal payload (undefined if not decodable). */
56
+ callIdOf(data: Uint8Array | string): string | undefined;
57
+ /** True once this call has fallen back to the offline path. */
58
+ isOfflineCall(callId: string): boolean;
59
+ /** Mark a call as committed to the Carrier (online) path. */
60
+ markOnlineCall(callId: string): void;
61
+ /** True once this call has committed to the Carrier path. */
62
+ isOnlineCall(callId: string): boolean;
63
+ /** Drop a finished call's transport state (best-effort cleanup). */
64
+ forgetCall(callId: string): void;
65
+ sendSignal(userid: string, data: Uint8Array | string, hasVideo: boolean): Promise<void>;
66
+ /** Tear down the socket connection (e.g. on daemon shutdown). */
67
+ close(): void;
68
+ }
69
+ export type { RtcSignal };
@@ -0,0 +1,192 @@
1
+ import { EventEmitter } from "node:events";
2
+ import { SocketIoSignaling, BeaglePushClient, decodeSignal, encodeSignalBytes } from "@decentnetwork/peer-webrtc";
3
+ export const DEFAULT_OFFLINE_CONFIG = {
4
+ // From the Beagle app (Service.swift). Overridable via config.
5
+ appKey: "08726e938c624c4da9dcd9a85f2bbcd8",
6
+ appName: "Beagle",
7
+ socketUrl: "https://io.beagle.chat",
8
+ socketUrls: ["https://io.beagle.chat"],
9
+ pushEndpoints: [
10
+ "https://pushapi.beagle.chat/push-api/push-message",
11
+ "https://www.callpass.cn/push-api/push-message",
12
+ "https://tokyo.fi.chat:3004/push-api/push-message"
13
+ ]
14
+ };
15
+ /**
16
+ * Offline-call bridge — the socket.io + push path Beagle uses to reach a peer
17
+ * whose app is backgrounded/offline (so a live Carrier session doesn't exist).
18
+ *
19
+ * The daemon uses this as a FALLBACK: `sendCallSignal` tries Carrier first and
20
+ * drops to this only when there's no live session. Signaling then rides the
21
+ * socket.io room (keyed by the RtcSignal `callId`); the initial offer also fires
22
+ * a push to wake the callee. Inbound socket.io signals are re-emitted as
23
+ * `"signal"` events so the daemon forwards them to the browser exactly like
24
+ * Carrier invites.
25
+ *
26
+ * Runs entirely in the daemon (Node): socket.io-client for signaling, fetch for
27
+ * push. socket.io-client is an OPTIONAL dependency — if it isn't installed the
28
+ * bridge stays disabled and logs a hint rather than crashing.
29
+ */
30
+ export class OfflineCallBridge extends EventEmitter {
31
+ #cfg;
32
+ #selfUserId;
33
+ #selfName;
34
+ #log;
35
+ #push;
36
+ #signaling;
37
+ #socket;
38
+ #connecting;
39
+ /** callIds that have fallen to the offline path — subsequent signals for them
40
+ * skip the Carrier retry entirely (no per-signal 1.5s stall). */
41
+ #offlineCalls = new Set();
42
+ /** callIds committed to the Carrier (online) path — a later transient session
43
+ * flap must NOT divert their signals to socket.io (splitting transports
44
+ * mid-call breaks ICE: the peer gets candidates over two channels). */
45
+ #onlineCalls = new Set();
46
+ constructor(opts) {
47
+ super();
48
+ this.#cfg = opts.config;
49
+ this.#selfUserId = opts.selfUserId;
50
+ this.#selfName = opts.selfName;
51
+ this.#log = opts.log ?? (() => { });
52
+ }
53
+ get enabled() {
54
+ return this.#cfg.enabled;
55
+ }
56
+ /**
57
+ * Send a call signal to `userid` over the offline path. Decodes the signal to
58
+ * key the room by `callId`; for the initial offer it also pushes to wake the
59
+ * callee. Throws if the bridge can't be brought up (caller keeps the original
60
+ * Carrier error).
61
+ */
62
+ /** Extract the callId from a raw signal payload (undefined if not decodable). */
63
+ callIdOf(data) {
64
+ try {
65
+ return decodeSignal(data).callId || undefined;
66
+ }
67
+ catch {
68
+ return undefined;
69
+ }
70
+ }
71
+ /** True once this call has fallen back to the offline path. */
72
+ isOfflineCall(callId) {
73
+ return this.#offlineCalls.has(callId);
74
+ }
75
+ /** Mark a call as committed to the Carrier (online) path. */
76
+ markOnlineCall(callId) {
77
+ this.#onlineCalls.add(callId);
78
+ }
79
+ /** True once this call has committed to the Carrier path. */
80
+ isOnlineCall(callId) {
81
+ return this.#onlineCalls.has(callId);
82
+ }
83
+ /** Drop a finished call's transport state (best-effort cleanup). */
84
+ forgetCall(callId) {
85
+ this.#offlineCalls.delete(callId);
86
+ this.#onlineCalls.delete(callId);
87
+ }
88
+ async sendSignal(userid, data, hasVideo) {
89
+ if (!this.#cfg.enabled)
90
+ throw new Error("offline calls disabled");
91
+ const signal = decodeSignal(data);
92
+ if (!signal.callId)
93
+ throw new Error("offline call signal has no callId");
94
+ this.#offlineCalls.add(signal.callId);
95
+ await this.#ensureConnected();
96
+ // Ring the callee for the first outbound signal of a call (the offer).
97
+ if (signal.type === "offer" && this.#push) {
98
+ const woke = await this.#push
99
+ .ring(userid, {
100
+ callerUserId: this.#selfUserId,
101
+ callId: signal.callId,
102
+ callerName: this.#selfName,
103
+ hasVideo,
104
+ channel: "socketio"
105
+ })
106
+ .catch(() => false);
107
+ this.#log(`offline call: push ${woke ? "sent" : "FAILED"} to ${userid} (call ${signal.callId})`);
108
+ }
109
+ this.#signaling.joinCall(signal.callId);
110
+ const kind = signal.type === "candidate"
111
+ ? `candidate(${signal.candidates?.length ?? 0})`
112
+ : signal.type === "event" ? `event:${signal.event}` : signal.type;
113
+ this.#log(`offline call: SEND ${kind} to ${userid} (call ${signal.callId})`);
114
+ this.#signaling.send(userid, signal);
115
+ }
116
+ /** Bring up the socket.io connection + signaling/push clients once. */
117
+ async #ensureConnected() {
118
+ if (this.#signaling)
119
+ return;
120
+ if (this.#connecting)
121
+ return this.#connecting;
122
+ this.#connecting = this.#connect();
123
+ try {
124
+ await this.#connecting;
125
+ }
126
+ finally {
127
+ this.#connecting = undefined;
128
+ }
129
+ }
130
+ async #connect() {
131
+ let io;
132
+ try {
133
+ // Optional dependency — imported lazily so the daemon runs without it.
134
+ const mod = (await import("socket.io-client"));
135
+ io = mod.io;
136
+ }
137
+ catch {
138
+ throw new Error("offline calls need socket.io-client — run `npm install socket.io-client` in decentlan");
139
+ }
140
+ // Try each gateway in order; connect to the first that comes up.
141
+ const urls = this.#cfg.socketUrls?.length ? this.#cfg.socketUrls : [this.#cfg.socketUrl];
142
+ let connectedUrl;
143
+ for (const url of urls) {
144
+ const sock = io(url, { transports: ["websocket"], forceNew: true, reconnection: false });
145
+ const ok = await new Promise((resolve) => {
146
+ const t = setTimeout(() => resolve(false), 7000);
147
+ sock.on("connect", () => { clearTimeout(t); resolve(true); });
148
+ sock.on("connect_error", () => { clearTimeout(t); resolve(false); });
149
+ });
150
+ if (ok) {
151
+ this.#socket = sock;
152
+ connectedUrl = url;
153
+ break;
154
+ }
155
+ this.#log(`offline call: socket gateway ${url} unreachable — trying next`);
156
+ try {
157
+ sock.disconnect?.();
158
+ }
159
+ catch { /* best-effort */ }
160
+ }
161
+ if (!this.#socket)
162
+ throw new Error(`offline calls: no reachable socket gateway (${urls.join(", ")})`);
163
+ this.#signaling = new SocketIoSignaling(this.#socket, { userId: this.#selfUserId });
164
+ this.#signaling.onSignal((peerId, signal) => {
165
+ // Re-emit inbound socket.io signals just like a Carrier invite so the
166
+ // daemon's existing call-signal queue forwards them to the browser.
167
+ const kind = signal.type === "candidate"
168
+ ? `candidate(${signal.candidates?.length ?? 0})`
169
+ : signal.type === "event" ? `event:${signal.event}` : signal.type;
170
+ this.#log(`offline call: RECV ${kind} from ${peerId} (call ${signal.callId ?? "?"})`);
171
+ this.emit("signal", { userid: peerId, data: encodeSignalBytes(signal) });
172
+ });
173
+ this.#push = new BeaglePushClient({
174
+ appKey: this.#cfg.appKey,
175
+ appName: this.#cfg.appName,
176
+ endpoints: this.#cfg.pushEndpoints
177
+ });
178
+ this.#log(`offline call bridge connected to ${connectedUrl}`);
179
+ }
180
+ /** Tear down the socket connection (e.g. on daemon shutdown). */
181
+ close() {
182
+ try {
183
+ this.#socket?.disconnect?.();
184
+ }
185
+ catch {
186
+ /* best-effort */
187
+ }
188
+ this.#socket = undefined;
189
+ this.#signaling = undefined;
190
+ this.#push = undefined;
191
+ }
192
+ }
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import { Peer } from "@decentnetwork/peer";
6
6
  import { EventEmitter } from "events";
7
+ import { type OfflineCallConfig } from "./offline-call.js";
7
8
  import { PacketSession } from "./packet-session.js";
8
9
  import type { PeerIdentity, RemotePeer } from "./types.js";
9
10
  import type { BootstrapNode } from "../types.js";
@@ -21,6 +22,10 @@ export interface PeerManagerOptions {
21
22
  /** Directory for persisting partially-received files so a dropped/restarted
22
23
  * transfer resumes instead of restarting (断点续传). */
23
24
  fileResumeDir?: string;
25
+ /** Offline-call fallback (socket.io + push). When enabled, a call to a peer
26
+ * with no live Carrier session rings it via the Beagle push gateway and
27
+ * signals over socket.io instead of failing. */
28
+ offlineCall?: OfflineCallConfig;
24
29
  }
25
30
  export declare class PeerManager extends EventEmitter {
26
31
  private peer;
@@ -28,6 +33,9 @@ export declare class PeerManager extends EventEmitter {
28
33
  private sessions;
29
34
  private sessionCounter;
30
35
  private logger;
36
+ private offlineBridge;
37
+ private offlineConfig;
38
+ private nickname;
31
39
  constructor();
32
40
  create(opts: PeerManagerOptions): Promise<void>;
33
41
  start(): Promise<void>;
@@ -70,10 +78,12 @@ export declare class PeerManager extends EventEmitter {
70
78
  * Send a WebRTC call-signaling payload to a friend over the Carrier
71
79
  * friend-invite channel (the "carrier" extension), the exact transport the
72
80
  * iOS/Android Beagle apps listen on for calls. `data` is an RtcSignal JSON
73
- * string/bytes (see @decentnetwork/peer-webrtc). Realtime requires a live
74
- * session, no offline fallback.
81
+ * string/bytes (see @decentnetwork/peer-webrtc). Realtime over Carrier when a
82
+ * live session exists; otherwise falls back to the offline path (socket.io +
83
+ * push) when configured, so a call to a backgrounded/offline phone still
84
+ * rings instead of failing.
75
85
  */
76
- sendCallSignal(userid: string, data: Uint8Array | string): Promise<void>;
86
+ sendCallSignal(userid: string, data: Uint8Array | string, hasVideo?: boolean): Promise<void>;
77
87
  /** True when the friend is a NATIVE Carrier client (iOS/Android/C): its
78
88
  * relay/DHT key differs from its identity key. JS peers announce their
79
89
  * identity key as their DHT key, so the two match. */
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import { Peer } from "@decentnetwork/peer";
6
6
  import { EventEmitter } from "events";
7
+ import { OfflineCallBridge } from "./offline-call.js";
7
8
  import { PacketSession } from "./packet-session.js";
8
9
  import { FrameCodec } from "./frame.js";
9
10
  import { FRAME_OPCODE_HANDSHAKE_ACK, PACKET_ID_DL_SESSION, PACKET_ID_DL_DORA, PACKET_ID_DL_IP, PACKET_ID_DL_RATE, } from "./types.js";
@@ -14,12 +15,17 @@ export class PeerManager extends EventEmitter {
14
15
  sessions = new Map();
15
16
  sessionCounter = 1;
16
17
  logger;
18
+ offlineBridge = null;
19
+ offlineConfig = null;
20
+ nickname = "";
17
21
  constructor() {
18
22
  super();
19
23
  this.logger = new Logger({ prefix: "PeerManager" });
20
24
  }
21
25
  async create(opts) {
22
26
  this.logger.info("Creating Peer instance");
27
+ this.offlineConfig = opts.offlineCall ?? null;
28
+ this.nickname = opts.nickname ?? "";
23
29
  this.peer = await Peer.create({
24
30
  keyFile: opts.keyFile,
25
31
  compatibilityMode: "legacy",
@@ -50,6 +56,21 @@ export class PeerManager extends EventEmitter {
50
56
  userid: this.peer.userid(),
51
57
  };
52
58
  this.logger.info(`Peer started: ${this.identity.address}`);
59
+ // Bring up the offline-call fallback (socket.io + push) if configured.
60
+ // Signals it receives are re-emitted as "call-signal" — identical to a
61
+ // Carrier invite — so the daemon's existing browser bridge handles them.
62
+ if (this.offlineConfig?.enabled) {
63
+ this.offlineBridge = new OfflineCallBridge({
64
+ config: this.offlineConfig,
65
+ selfUserId: this.identity.userid,
66
+ selfName: this.nickname || this.identity.userid,
67
+ log: (m) => this.logger.info(m),
68
+ });
69
+ this.offlineBridge.on("signal", (evt) => {
70
+ this.emit("call-signal", { pubkey: evt.userid, data: evt.data });
71
+ });
72
+ this.logger.info("Offline-call fallback enabled (socket.io + push)");
73
+ }
53
74
  }
54
75
  async joinNetwork() {
55
76
  if (!this.peer) {
@@ -133,13 +154,45 @@ export class PeerManager extends EventEmitter {
133
154
  * Send a WebRTC call-signaling payload to a friend over the Carrier
134
155
  * friend-invite channel (the "carrier" extension), the exact transport the
135
156
  * iOS/Android Beagle apps listen on for calls. `data` is an RtcSignal JSON
136
- * string/bytes (see @decentnetwork/peer-webrtc). Realtime requires a live
137
- * session, no offline fallback.
157
+ * string/bytes (see @decentnetwork/peer-webrtc). Realtime over Carrier when a
158
+ * live session exists; otherwise falls back to the offline path (socket.io +
159
+ * push) when configured, so a call to a backgrounded/offline phone still
160
+ * rings instead of failing.
138
161
  */
139
- async sendCallSignal(userid, data) {
162
+ async sendCallSignal(userid, data, hasVideo = true) {
140
163
  if (!this.peer)
141
164
  throw new Error("Peer not created. Call create() first.");
142
- await this.peer.sendInvite(userid, data, { ext: "carrier" });
165
+ // Once a call has fallen to the offline path, keep every later signal for it
166
+ // on that path — don't re-attempt Carrier per signal (that stalled the UI
167
+ // ~1.5s each and reordered signals across transports).
168
+ const callId = this.offlineBridge?.enabled ? this.offlineBridge.callIdOf(data) : undefined;
169
+ if (callId && this.offlineBridge.isOfflineCall(callId)) {
170
+ await this.offlineBridge.sendSignal(userid, data, hasVideo);
171
+ return;
172
+ }
173
+ try {
174
+ // Fail fast (1.5s, not 5s) so an offline peer drops to push quickly.
175
+ await this.peer.sendInvite(userid, data, { ext: "carrier", establishTimeoutMs: 1500 });
176
+ // Committed to Carrier — keep this call there even if it briefly flaps.
177
+ if (callId)
178
+ this.offlineBridge?.markOnlineCall(callId);
179
+ }
180
+ catch (err) {
181
+ // A call already committed to Carrier must NOT split to socket.io on a
182
+ // transient flap (it breaks ICE). Drop this signal; ICE recovers via the
183
+ // other candidates / a retried offer.
184
+ if (callId && this.offlineBridge?.isOnlineCall(callId)) {
185
+ this.logger.info(`sendCallSignal: dropped a Carrier signal for online call ${callId} (${err.message})`);
186
+ return;
187
+ }
188
+ // No live session and not yet committed to Carrier — use the offline path.
189
+ if (this.offlineBridge?.enabled) {
190
+ this.logger.info(`sendCallSignal: Carrier failed (${err.message}) — offline path to ${userid}`);
191
+ await this.offlineBridge.sendSignal(userid, data, hasVideo);
192
+ return;
193
+ }
194
+ throw err;
195
+ }
143
196
  }
144
197
  /** True when the friend is a NATIVE Carrier client (iOS/Android/C): its
145
198
  * relay/DHT key differs from its identity key. JS peers announce their
@@ -5,6 +5,7 @@ import { resolve } from "path";
5
5
  import { EventEmitter } from "events";
6
6
  import { existsSync, readFileSync, writeFileSync, unlinkSync } from "fs";
7
7
  import { PeerManager } from "../carrier/peer-manager.js";
8
+ import { DEFAULT_OFFLINE_CONFIG } from "../carrier/offline-call.js";
8
9
  import { TunDevice } from "../tun/tun-device.js";
9
10
  import { RouteManager } from "../tun/route-manager.js";
10
11
  import { Ipam } from "../ipam/ipam.js";
@@ -292,6 +293,18 @@ export class DaemonServer {
292
293
  // Persist partially-received files so a dropped/restarted transfer
293
294
  // resumes instead of restarting from 0 (断点续传).
294
295
  fileResumeDir: resolve(this.configDir, "downloads", ".partial"),
296
+ // Offline-call fallback (socket.io + push): ring an offline/backgrounded
297
+ // phone via the Beagle push gateway when there's no live Carrier
298
+ // session. Enabled by default with the public Beagle gateways; override
299
+ // appKey/appName/socketUrl under `call:` in config.yaml.
300
+ offlineCall: {
301
+ enabled: this.config.call?.offlineEnabled ?? true,
302
+ appKey: this.config.call?.appKey ?? DEFAULT_OFFLINE_CONFIG.appKey,
303
+ appName: this.config.call?.appName ?? DEFAULT_OFFLINE_CONFIG.appName,
304
+ socketUrl: this.config.call?.socketUrl ?? DEFAULT_OFFLINE_CONFIG.socketUrl,
305
+ socketUrls: this.config.call?.socketUrls ?? DEFAULT_OFFLINE_CONFIG.socketUrls,
306
+ pushEndpoints: this.config.call?.pushEndpoints ?? DEFAULT_OFFLINE_CONFIG.pushEndpoints,
307
+ },
295
308
  });
296
309
  await this.peerManager.start();
297
310
  this.logger.info(`Identity: ${this.peerManager.getAddress()}`);
@@ -467,10 +480,12 @@ export class DaemonServer {
467
480
  // Beagle apps themselves use. Detected via the DHT-key signature.
468
481
  if (this.peerManager?.isNativeFriend(userid)) {
469
482
  // The FileModel JSON envelope is base64 (inflates ~1.34x) + a little
470
- // JSON overhead, and the whole thing must fit the 5MB bulkmsg cap
471
- // (CARRIER_MAX_APP_BULKMSG_LEN). 3.5MB raw ~4.7MB envelope, safely
472
- // under 5MB. (Was a conservative 2MB.)
473
- const INLINE_MAX = 3.5 * 1024 * 1024;
483
+ // JSON overhead, and the whole thing must fit the 16MB bulkmsg cap
484
+ // (CARRIER_MAX_APP_BULKMSG_LEN, raised from 5MB alongside the receive
485
+ // reorder buffer in peer >= 0.1.87). 11MB raw ~14.7MB envelope,
486
+ // safely under 16MB. Needs BOTH ends on the raised cap. (Very large /
487
+ // streaming transfers should use toxcore file transfer, not inline.)
488
+ const INLINE_MAX = 11 * 1024 * 1024;
474
489
  if (data.length > INLINE_MAX) {
475
490
  throw new Error(`This friend is a native (iOS/Android) client — files are sent inline and limited to ` +
476
491
  `${(INLINE_MAX / 1024 / 1024).toFixed(1)} MB (this is ${(data.length / 1024 / 1024).toFixed(1)} MB).`);
@@ -563,6 +578,15 @@ export class DaemonServer {
563
578
  });
564
579
  }
565
580
  const signals = this.callSignalQueue.splice(0, this.callSignalQueue.length);
581
+ if (signals.length) {
582
+ const kinds = signals.map((s) => { try {
583
+ return JSON.parse(s.data).type;
584
+ }
585
+ catch {
586
+ return "?";
587
+ } }).join(",");
588
+ this.logger.info(`call-poll -> browser: ${signals.length} signal(s) [${kinds}]`);
589
+ }
566
590
  return { signals };
567
591
  },
568
592
  subscribe: (emit) => {
package/dist/types.d.ts CHANGED
@@ -193,6 +193,23 @@ export interface DecentAgentNetConfig {
193
193
  friends?: FriendsConfig;
194
194
  dora?: DoraConfig;
195
195
  forwarding?: ForwardingConfig;
196
+ call?: CallConfig;
197
+ }
198
+ export interface CallConfig {
199
+ /** Enable the offline-call fallback (socket.io + push). Default true. When a
200
+ * call target has no live Carrier session, ring it via the Beagle push
201
+ * gateway and signal over socket.io instead of failing. */
202
+ offlineEnabled?: boolean;
203
+ /** Beagle push appKey — must match what the callee's app registered with. */
204
+ appKey?: string;
205
+ /** Beagle push appName (the callee app's CFBundleName, e.g. "Beagle"). */
206
+ appName?: string;
207
+ /** Socket.IO signaling gateway URL (primary). */
208
+ socketUrl?: string;
209
+ /** Ordered socket.IO gateways to try (failover). Defaults to [socketUrl]. */
210
+ socketUrls?: string[];
211
+ /** Push-API endpoints (POST /push-api/push-message), tried in order. */
212
+ pushEndpoints?: string[];
196
213
  }
197
214
  export interface ForwardingConfig {
198
215
  /** Pace + fair-queue egress IP data (default true). Prevents one bursty flow
@@ -1633,18 +1633,17 @@ function useCallController(selfId) {
1633
1633
  engineRef.current = null;
1634
1634
  };
1635
1635
  }, [selfId]);
1636
- const start = React.useCallback(async (peerId, video) => {
1636
+ const start = React.useCallback((peerId, video) => {
1637
1637
  const eng = engineRef.current;
1638
1638
  if (!eng) {
1639
1639
  alert("Calls unavailable (WebRTC not supported here)");
1640
1640
  return;
1641
1641
  }
1642
- try {
1643
- const callId = await eng.call(peerId, { audio: true, video: !!video });
1644
- setActive({ callId, peerId, video: !!video, direction: "outgoing", state: "ringing" });
1645
- } catch (e) {
1642
+ setActive({ callId: null, peerId, video: !!video, direction: "outgoing", state: "ringing" });
1643
+ eng.call(peerId, { audio: true, video: !!video }).then((callId) => setActive((a) => a && a.peerId === peerId && a.callId === null ? { ...a, callId } : a)).catch((e) => {
1644
+ setActive((a) => a && a.peerId === peerId ? null : a);
1646
1645
  alert("Could not start call: " + (e && e.message || e));
1647
- }
1646
+ });
1648
1647
  }, []);
1649
1648
  const accept = React.useCallback(async () => {
1650
1649
  const eng = engineRef.current;
@@ -1666,7 +1665,7 @@ function useCallController(selfId) {
1666
1665
  const hangup = React.useCallback(() => {
1667
1666
  const eng = engineRef.current;
1668
1667
  setActive((a) => {
1669
- if (eng && a) eng.hangup(a.callId);
1668
+ if (eng && a && a.callId) eng.hangup(a.callId);
1670
1669
  return null;
1671
1670
  });
1672
1671
  setLocalStream(null);
@@ -1674,11 +1673,11 @@ function useCallController(selfId) {
1674
1673
  }, []);
1675
1674
  const setMuted = React.useCallback((m) => {
1676
1675
  const eng = engineRef.current, a = active;
1677
- if (eng && a) eng.setLocalTrackEnabled(a.callId, "audio", !m);
1676
+ if (eng && a && a.callId) eng.setLocalTrackEnabled(a.callId, "audio", !m);
1678
1677
  }, [active]);
1679
1678
  const setVideoOn = React.useCallback((v) => {
1680
1679
  const eng = engineRef.current, a = active;
1681
- if (eng && a) eng.setLocalTrackEnabled(a.callId, "video", v);
1680
+ if (eng && a && a.callId) eng.setLocalTrackEnabled(a.callId, "video", v);
1682
1681
  }, [active]);
1683
1682
  return { incoming, active, localStream, remoteStream, start, accept, reject, hangup, setMuted, setVideoOn };
1684
1683
  }
@@ -1694,8 +1693,53 @@ function AudioSink({ stream }) {
1694
1693
  }, [stream]);
1695
1694
  return /* @__PURE__ */ React.createElement("audio", { ref, autoPlay: true });
1696
1695
  }
1696
+ function useRingtone(active) {
1697
+ React.useEffect(() => {
1698
+ if (!active) return void 0;
1699
+ let ctx;
1700
+ try {
1701
+ ctx = new (window.AudioContext || window.webkitAudioContext)();
1702
+ } catch (e) {
1703
+ return void 0;
1704
+ }
1705
+ if (ctx.state === "suspended") {
1706
+ ctx.resume().catch(() => {
1707
+ });
1708
+ }
1709
+ let stopped = false;
1710
+ const timers = [];
1711
+ const burst = () => {
1712
+ if (stopped || ctx.state === "closed") return;
1713
+ [440, 480].forEach((f) => {
1714
+ const o = ctx.createOscillator(), g = ctx.createGain();
1715
+ o.type = "sine";
1716
+ o.frequency.value = f;
1717
+ o.connect(g);
1718
+ g.connect(ctx.destination);
1719
+ const t = ctx.currentTime;
1720
+ g.gain.setValueAtTime(1e-4, t);
1721
+ g.gain.exponentialRampToValueAtTime(0.12, t + 0.05);
1722
+ g.gain.setValueAtTime(0.12, t + 0.9);
1723
+ g.gain.exponentialRampToValueAtTime(1e-4, t + 1);
1724
+ o.start(t);
1725
+ o.stop(t + 1.05);
1726
+ });
1727
+ timers.push(setTimeout(burst, 3e3));
1728
+ };
1729
+ burst();
1730
+ return () => {
1731
+ stopped = true;
1732
+ timers.forEach(clearTimeout);
1733
+ try {
1734
+ ctx.close();
1735
+ } catch (e) {
1736
+ }
1737
+ };
1738
+ }, [active]);
1739
+ }
1697
1740
  function IncomingCallModal({ T, ctl, peers }) {
1698
1741
  const inc = ctl.incoming;
1742
+ useRingtone(!!inc);
1699
1743
  if (!inc) return null;
1700
1744
  const peer = (peers || []).find((p) => p.id === inc.peerId) || { id: inc.peerId };
1701
1745
  const name = peer.alias || peer.name || shortKey(inc.peerId, 10, 6);
@@ -28,10 +28,12 @@ var PeerWebRTC = (() => {
28
28
  // node_modules/@decentnetwork/peer-webrtc/dist/index.js
29
29
  var dist_exports = {};
30
30
  __export(dist_exports, {
31
+ BeaglePushClient: () => BeaglePushClient,
31
32
  BroadcastChannelSignaling: () => BroadcastChannelSignaling,
32
33
  CallEngine: () => CallEngine,
33
34
  CarrierSignaling: () => CarrierSignaling,
34
35
  Emitter: () => Emitter,
36
+ SocketIoSignaling: () => SocketIoSignaling,
35
37
  decodeSignal: () => decodeSignal,
36
38
  decodeSignalBytesGuard: () => decodeSignalBytesGuard,
37
39
  encodeSignal: () => encodeSignal,
@@ -197,17 +199,26 @@ var PeerWebRTC = (() => {
197
199
  * "remoteStream"/"ended" as the call progresses.
198
200
  */
199
201
  async call(peerId, kinds = {}) {
200
- var _a, _b, _c;
202
+ var _a, _b, _c, _d, _e;
201
203
  const audio = (_a = kinds.audio) != null ? _a : true;
202
204
  const video = (_b = kinds.video) != null ? _b : false;
203
205
  const data = (_c = kinds.data) != null ? _c : false;
204
206
  const callId = __privateMethod(this, _CallEngine_instances, newCallId_fn).call(this);
205
207
  const session = __privateMethod(this, _CallEngine_instances, createSession_fn).call(this, callId, peerId, "outgoing", { audio, video, data });
206
208
  await __privateMethod(this, _CallEngine_instances, attachLocalMedia_fn).call(this, session);
207
- const offer = await session.pc.createOffer({
208
- offerToReceiveAudio: audio,
209
- offerToReceiveVideo: video
210
- });
209
+ if (audio && !((_d = session.localStream) == null ? void 0 : _d.getAudioTracks().length)) {
210
+ try {
211
+ session.pc.addTransceiver("audio", { direction: "recvonly" });
212
+ } catch {
213
+ }
214
+ }
215
+ if (video && !((_e = session.localStream) == null ? void 0 : _e.getVideoTracks().length)) {
216
+ try {
217
+ session.pc.addTransceiver("video", { direction: "recvonly" });
218
+ } catch {
219
+ }
220
+ }
221
+ const offer = await session.pc.createOffer();
211
222
  await session.pc.setLocalDescription(offer);
212
223
  const options = [];
213
224
  if (audio)
@@ -330,8 +341,9 @@ var PeerWebRTC = (() => {
330
341
  };
331
342
  onAnswer_fn = function(peerId, signal) {
332
343
  const session = __privateGet(this, _sessions).get(__privateMethod(this, _CallEngine_instances, normId_fn).call(this, signal.callId));
333
- if (!session || session.peerId !== peerId || !signal.sdp)
344
+ if (!session || !signal.sdp)
334
345
  return;
346
+ session.peerId = peerId;
335
347
  void (async () => {
336
348
  try {
337
349
  await session.pc.setRemoteDescription({ type: "answer", sdp: signal.sdp });
@@ -347,7 +359,7 @@ var PeerWebRTC = (() => {
347
359
  onCandidate_fn = function(peerId, signal) {
348
360
  var _a;
349
361
  const session = __privateGet(this, _sessions).get(__privateMethod(this, _CallEngine_instances, normId_fn).call(this, signal.callId));
350
- if (!session || session.peerId !== peerId || !signal.candidates)
362
+ if (!session || !signal.candidates)
351
363
  return;
352
364
  for (const c of signal.candidates) {
353
365
  const init = {
@@ -365,7 +377,7 @@ var PeerWebRTC = (() => {
365
377
  onBye_fn = function(peerId, signal) {
366
378
  var _a;
367
379
  const session = __privateGet(this, _sessions).get(__privateMethod(this, _CallEngine_instances, normId_fn).call(this, signal.callId));
368
- if (!session || session.peerId !== peerId)
380
+ if (!session)
369
381
  return;
370
382
  const reason = (_a = signal.reason) != null ? _a : "normal";
371
383
  const state = reason === "busy" ? "busy" : reason === "declined" ? "declined" : "ended";
@@ -375,7 +387,7 @@ var PeerWebRTC = (() => {
375
387
  };
376
388
  onAuxiliary_fn = function(peerId, signal) {
377
389
  const session = __privateGet(this, _sessions).get(__privateMethod(this, _CallEngine_instances, normId_fn).call(this, signal.callId));
378
- if (!session || session.peerId !== peerId)
390
+ if (!session)
379
391
  return;
380
392
  if (signal.type === "event") {
381
393
  if (signal.event === "ringing")
@@ -467,7 +479,24 @@ var PeerWebRTC = (() => {
467
479
  return;
468
480
  if (!__privateGet(this, _opts).getLocalMedia || !session.audio && !session.video)
469
481
  return;
470
- const stream = await __privateGet(this, _opts).getLocalMedia({ audio: session.audio, video: session.video });
482
+ let stream;
483
+ try {
484
+ stream = await __privateGet(this, _opts).getLocalMedia({ audio: session.audio, video: session.video });
485
+ } catch (err) {
486
+ __privateMethod(this, _CallEngine_instances, log_fn).call(this, `getLocalMedia(audio:${session.audio},video:${session.video}) failed: ${err.message}`);
487
+ if (session.video && session.audio) {
488
+ try {
489
+ stream = await __privateGet(this, _opts).getLocalMedia({ audio: true, video: false });
490
+ __privateMethod(this, _CallEngine_instances, log_fn).call(this, `local camera unavailable \u2014 sending audio only, still receiving video, call ${session.callId}`);
491
+ } catch (err2) {
492
+ __privateMethod(this, _CallEngine_instances, log_fn).call(this, `audio-only fallback failed too: ${err2.message}`);
493
+ }
494
+ }
495
+ }
496
+ if (!stream) {
497
+ __privateMethod(this, _CallEngine_instances, log_fn).call(this, `no local media for ${session.callId}; connecting receive-only`);
498
+ return;
499
+ }
471
500
  session.localStream = stream;
472
501
  for (const track of stream.getTracks()) {
473
502
  session.pc.addTrack(track, stream);
@@ -631,5 +660,237 @@ var PeerWebRTC = (() => {
631
660
  };
632
661
  _peer = new WeakMap();
633
662
  _ext = new WeakMap();
663
+
664
+ // node_modules/@decentnetwork/peer-webrtc/dist/signaling/socketio.js
665
+ var _socket, _userId, _handler2, _rooms, _pending, _lastOffer, _wired, _SocketIoSignaling_instances, roomKey_fn, ensureRoom_fn, emitMessage_fn, wire_fn;
666
+ var SocketIoSignaling = class {
667
+ constructor(socket, opts) {
668
+ __privateAdd(this, _SocketIoSignaling_instances);
669
+ __privateAdd(this, _socket);
670
+ __privateAdd(this, _userId);
671
+ __privateAdd(this, _handler2);
672
+ /** Rooms we've asked to join (callId -> confirmed self-membership). */
673
+ __privateAdd(this, _rooms, /* @__PURE__ */ new Map());
674
+ /** Signals queued per room until the server confirms our membership. */
675
+ __privateAdd(this, _pending, /* @__PURE__ */ new Map());
676
+ /** Last offer per callId, RE-SENT when the peer joins the room — the callee
677
+ * (woken by a push) joins seconds after we sent the offer, and socket.io does
678
+ * NOT replay, so without this the peer never sees the offer and never
679
+ * answers (offline call stuck at "connecting"). */
680
+ __privateAdd(this, _lastOffer, /* @__PURE__ */ new Map());
681
+ /** The remote peer id per room, learned from inbound messages so a queued
682
+ * peer-greeting can be addressed. */
683
+ __privateAdd(this, _wired, false);
684
+ __privateSet(this, _socket, socket);
685
+ __privateSet(this, _userId, opts.userId);
686
+ }
687
+ onSignal(handler) {
688
+ __privateSet(this, _handler2, handler);
689
+ __privateMethod(this, _SocketIoSignaling_instances, wire_fn).call(this);
690
+ }
691
+ /**
692
+ * Deliver a signal to `peerId`. Joins the call's room (keyed by
693
+ * `signal.callId`) on first use and queues until the server confirms
694
+ * membership, then emits — mirroring the iOS provider, which queues until
695
+ * `connectToRoom`.
696
+ */
697
+ send(peerId, signal) {
698
+ var _a;
699
+ __privateMethod(this, _SocketIoSignaling_instances, wire_fn).call(this);
700
+ const callId = signal.callId;
701
+ if (!callId) {
702
+ __privateMethod(this, _SocketIoSignaling_instances, emitMessage_fn).call(this, signal);
703
+ return;
704
+ }
705
+ if (signal.type === "offer")
706
+ __privateGet(this, _lastOffer).set(callId, signal);
707
+ const room = __privateMethod(this, _SocketIoSignaling_instances, ensureRoom_fn).call(this, callId);
708
+ if (room.joined) {
709
+ __privateMethod(this, _SocketIoSignaling_instances, emitMessage_fn).call(this, signal);
710
+ } else {
711
+ const q = (_a = __privateGet(this, _pending).get(callId)) != null ? _a : [];
712
+ q.push(signal);
713
+ __privateGet(this, _pending).set(callId, q);
714
+ }
715
+ }
716
+ /**
717
+ * Explicitly join the room for an INCOMING call (e.g. after a push
718
+ * notification delivered the `callId`), so inbound signals for it are
719
+ * received. Safe to call more than once.
720
+ */
721
+ joinCall(callId) {
722
+ __privateMethod(this, _SocketIoSignaling_instances, wire_fn).call(this);
723
+ __privateMethod(this, _SocketIoSignaling_instances, ensureRoom_fn).call(this, callId);
724
+ }
725
+ /** Leave a call's room (best-effort) and forget its queued state. */
726
+ leaveCall(callId) {
727
+ __privateGet(this, _rooms).delete(callId);
728
+ __privateGet(this, _pending).delete(callId);
729
+ __privateGet(this, _lastOffer).delete(callId);
730
+ __privateGet(this, _socket).emit("leave-channel", { channel: __privateMethod(this, _SocketIoSignaling_instances, roomKey_fn).call(this, callId), sender: __privateGet(this, _userId) });
731
+ }
732
+ };
733
+ _socket = new WeakMap();
734
+ _userId = new WeakMap();
735
+ _handler2 = new WeakMap();
736
+ _rooms = new WeakMap();
737
+ _pending = new WeakMap();
738
+ _lastOffer = new WeakMap();
739
+ _wired = new WeakMap();
740
+ _SocketIoSignaling_instances = new WeakSet();
741
+ /** The socket.io ROOM key. The native Beagle apps join the room with the
742
+ * call's `UUID.uuidString`, which Swift renders UPPERCASE — so a lowercase
743
+ * `crypto.randomUUID()` from JS would land us in a DIFFERENT room and we'd
744
+ * never receive the peer's answer/candidates. Canonicalize to UPPERCASE to
745
+ * share the native peer's room. (The RtcSignal payload callId stays as-is;
746
+ * the CallEngine matches it case-insensitively.) */
747
+ roomKey_fn = function(callId) {
748
+ return callId.toUpperCase();
749
+ };
750
+ ensureRoom_fn = function(callId) {
751
+ let room = __privateGet(this, _rooms).get(callId);
752
+ if (!room) {
753
+ room = { joined: false };
754
+ __privateGet(this, _rooms).set(callId, room);
755
+ __privateGet(this, _socket).emit("new-channel", { channel: __privateMethod(this, _SocketIoSignaling_instances, roomKey_fn).call(this, callId), sender: __privateGet(this, _userId) });
756
+ }
757
+ return room;
758
+ };
759
+ emitMessage_fn = function(signal) {
760
+ __privateGet(this, _socket).emit("message", { data: encodeSignal(signal), sender: __privateGet(this, _userId) });
761
+ };
762
+ wire_fn = function() {
763
+ if (__privateGet(this, _wired))
764
+ return;
765
+ __privateSet(this, _wired, true);
766
+ __privateGet(this, _socket).on("message", (...args) => {
767
+ var _a;
768
+ const payload = firstObject(args);
769
+ if (!(payload == null ? void 0 : payload.data) || !payload.sender)
770
+ return;
771
+ if (payload.sender === __privateGet(this, _userId))
772
+ return;
773
+ const signal = decodeSignalBytesGuard(payload.data);
774
+ if (signal)
775
+ (_a = __privateGet(this, _handler2)) == null ? void 0 : _a.call(this, payload.sender, signal);
776
+ });
777
+ __privateGet(this, _socket).on("connectToRoom", (...args) => {
778
+ const payload = firstObject(args);
779
+ const sender = payload == null ? void 0 : payload.sender;
780
+ if (!sender)
781
+ return;
782
+ if (sender === __privateGet(this, _userId)) {
783
+ for (const [callId, room] of __privateGet(this, _rooms)) {
784
+ room.joined = true;
785
+ const q = __privateGet(this, _pending).get(callId);
786
+ if (q && q.length) {
787
+ for (const sig of q)
788
+ __privateMethod(this, _SocketIoSignaling_instances, emitMessage_fn).call(this, sig);
789
+ __privateGet(this, _pending).delete(callId);
790
+ }
791
+ }
792
+ } else {
793
+ for (const callId of __privateGet(this, _rooms).keys()) {
794
+ __privateMethod(this, _SocketIoSignaling_instances, emitMessage_fn).call(this, { type: "event", callId, event: "online" });
795
+ const offer = __privateGet(this, _lastOffer).get(callId);
796
+ if (offer)
797
+ __privateMethod(this, _SocketIoSignaling_instances, emitMessage_fn).call(this, offer);
798
+ }
799
+ }
800
+ });
801
+ __privateGet(this, _socket).on("disconnectToRoom", (...args) => {
802
+ const payload = firstObject(args);
803
+ const sender = payload == null ? void 0 : payload.sender;
804
+ if (sender && sender === __privateGet(this, _userId)) {
805
+ for (const room of __privateGet(this, _rooms).values())
806
+ room.joined = false;
807
+ }
808
+ });
809
+ };
810
+ function firstObject(args) {
811
+ const first = args[0];
812
+ return first && typeof first === "object" ? first : void 0;
813
+ }
814
+
815
+ // node_modules/@decentnetwork/peer-webrtc/dist/signaling/push.js
816
+ var DEFAULT_ENDPOINTS = [
817
+ "https://pushapi.beagle.chat/push-api/push-message",
818
+ "https://www.callpass.cn/push-api/push-message",
819
+ "https://tokyo.fi.chat:3004/push-api/push-message"
820
+ ];
821
+ var _appKey, _appName, _endpoints, _fetch;
822
+ var BeaglePushClient = class {
823
+ constructor(opts) {
824
+ __privateAdd(this, _appKey);
825
+ __privateAdd(this, _appName);
826
+ __privateAdd(this, _endpoints);
827
+ __privateAdd(this, _fetch);
828
+ var _a, _b;
829
+ __privateSet(this, _appKey, opts.appKey);
830
+ __privateSet(this, _appName, opts.appName);
831
+ __privateSet(this, _endpoints, ((_a = opts.endpoints) == null ? void 0 : _a.length) ? opts.endpoints : DEFAULT_ENDPOINTS);
832
+ const f = (_b = opts.fetch) != null ? _b : globalThis.fetch;
833
+ if (!f)
834
+ throw new Error("BeaglePushClient: no fetch available \u2014 pass opts.fetch");
835
+ __privateSet(this, _fetch, f);
836
+ }
837
+ /**
838
+ * Ring an offline peer. `calleeUserId` is the destination (their Carrier user
839
+ * id); the payload describes the call. Tries each endpoint until one accepts.
840
+ * Resolves `true` on success, `false` if every endpoint failed.
841
+ */
842
+ async sendCallPush(calleeUserId, payload) {
843
+ var _a;
844
+ const params = new URLSearchParams({
845
+ account: calleeUserId,
846
+ payload: JSON.stringify(payload),
847
+ appKey: __privateGet(this, _appKey),
848
+ appName: __privateGet(this, _appName)
849
+ }).toString();
850
+ for (const endpoint of __privateGet(this, _endpoints)) {
851
+ try {
852
+ const url = endpoint + (endpoint.includes("?") ? "&" : "?") + params;
853
+ const res = await __privateGet(this, _fetch).call(this, url, { method: "POST" });
854
+ if (!res.ok)
855
+ continue;
856
+ const json = await res.json().catch(() => ({}));
857
+ const code = json.code;
858
+ const ok = code === void 0 || code === 0 || code === "0" || ((_a = json.data) == null ? void 0 : _a.success) === true;
859
+ if (ok)
860
+ return true;
861
+ } catch {
862
+ }
863
+ }
864
+ return false;
865
+ }
866
+ /** Convenience: ring `calleeUserId` for a call over `channel`. */
867
+ ring(calleeUserId, args) {
868
+ var _a;
869
+ return this.sendCallPush(calleeUserId, {
870
+ userId: args.callerUserId,
871
+ hasVideo: args.hasVideo ? "true" : "false",
872
+ callId: args.callId,
873
+ callName: args.callerName,
874
+ action: "call",
875
+ channel: (_a = args.channel) != null ? _a : "socketio"
876
+ });
877
+ }
878
+ /** Convenience: cancel a ring (e.g. caller hung up before answer). */
879
+ cancel(calleeUserId, args) {
880
+ var _a;
881
+ return this.sendCallPush(calleeUserId, {
882
+ userId: args.callerUserId,
883
+ hasVideo: args.hasVideo ? "true" : "false",
884
+ callId: args.callId,
885
+ callName: args.callerName,
886
+ action: "decline",
887
+ channel: (_a = args.channel) != null ? _a : "socketio"
888
+ });
889
+ }
890
+ };
891
+ _appKey = new WeakMap();
892
+ _appName = new WeakMap();
893
+ _endpoints = new WeakMap();
894
+ _fetch = new WeakMap();
634
895
  return __toCommonJS(dist_exports);
635
896
  })();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/lan",
3
- "version": "0.1.165",
3
+ "version": "0.1.175",
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",
@@ -79,12 +79,13 @@
79
79
  },
80
80
  "dependencies": {
81
81
  "@decentnetwork/dora": "^0.1.13",
82
- "@decentnetwork/peer": "^0.1.86",
83
- "@decentnetwork/peer-webrtc": "^0.1.1",
82
+ "@decentnetwork/peer": "^0.1.91",
83
+ "@decentnetwork/peer-webrtc": "^0.2.7",
84
84
  "ink": "^5.2.1",
85
85
  "js-yaml": "^4.1.0",
86
86
  "node-forge": "^1.4.0",
87
87
  "react": "^18.3.1",
88
+ "socket.io-client": "^4.7.5",
88
89
  "yargs": "^17.7.2"
89
90
  },
90
91
  "devDependencies": {