@decentnetwork/lan 0.1.255 → 0.1.257

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.
@@ -27,6 +27,17 @@ export interface PeerManagerOptions {
27
27
  * signals over socket.io instead of failing. */
28
28
  offlineCall?: OfflineCallConfig;
29
29
  }
30
+ /**
31
+ * True when a Carrier invite send failed for a reason that will recur
32
+ * identically on every retry, so retrying or waiting for a reconnect is
33
+ * pointless and the signal must take another transport or be lost.
34
+ *
35
+ * The one that bit us: the native invite channel caps application data at
36
+ * CARRIER_MAX_INVITE_DATA_LEN (8192, from carrier.h — it is a wire constant
37
+ * shared with the iOS/Android SDK, not something we can raise unilaterally),
38
+ * while a Chrome SDP offer/answer runs 8.4-9.8 KB.
39
+ */
40
+ export declare function isPermanentInviteFailure(err: unknown): boolean;
30
41
  export declare class PeerManager extends EventEmitter {
31
42
  private peer;
32
43
  private identity;
@@ -70,6 +81,18 @@ export declare class PeerManager extends EventEmitter {
70
81
  sign(message: Uint8Array): Uint8Array;
71
82
  /** Offer a file to a friend (toxcore-standard transfer). Returns the fileId. */
72
83
  sendFile(userid: string, data: Uint8Array, name: string): string | null;
84
+ /**
85
+ * Send an APPLICATION packet to a friend. The payload is opaque to
86
+ * decentlan — this is the extension point that lets an app (beagle, an
87
+ * agent, any IPC client) run its own protocol over our Carrier session
88
+ * without a change in here.
89
+ *
90
+ * `id` must be in PACKET_ID_APP_MIN..MAX. Rejecting anything else is a
91
+ * security boundary, not tidiness: our own 161-164 carry session handshakes,
92
+ * dora control and IP frames, so a caller allowed to pick those ids could
93
+ * forge a handshake or inject a packet onto a peer's TUN.
94
+ */
95
+ sendAppPacket(userid: string, id: number, data: Uint8Array): Promise<void>;
73
96
  /** Send a file INLINE over the message channel (FileModel JSON envelope,
74
97
  * bulkmsg-split) — the only file path native iOS/C Carrier clients can
75
98
  * receive online. Use for native friends; JS friends keep sendFile(). */
@@ -7,8 +7,31 @@ import { EventEmitter } from "events";
7
7
  import { OfflineCallBridge } from "./offline-call.js";
8
8
  import { PacketSession } from "./packet-session.js";
9
9
  import { FrameCodec } from "./frame.js";
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";
10
+ import { FRAME_OPCODE_HANDSHAKE_ACK, PACKET_ID_DL_SESSION, PACKET_ID_DL_DORA, PACKET_ID_DL_IP, PACKET_ID_DL_RATE, isAppPacketId, } from "./types.js";
11
11
  import { Logger } from "../utils/logger.js";
12
+ /**
13
+ * True when a Carrier invite send failed for a reason that will recur
14
+ * identically on every retry, so retrying or waiting for a reconnect is
15
+ * pointless and the signal must take another transport or be lost.
16
+ *
17
+ * The one that bit us: the native invite channel caps application data at
18
+ * CARRIER_MAX_INVITE_DATA_LEN (8192, from carrier.h — it is a wire constant
19
+ * shared with the iOS/Android SDK, not something we can raise unilaterally),
20
+ * while a Chrome SDP offer/answer runs 8.4-9.8 KB.
21
+ */
22
+ export function isPermanentInviteFailure(err) {
23
+ return /invite data must be/i.test(err?.message ?? "");
24
+ }
25
+ /** Best-effort RtcSignal type ("offer"/"answer"/"candidate"/"bye") for logs. */
26
+ function signalKind(data) {
27
+ try {
28
+ const text = typeof data === "string" ? data : Buffer.from(data).toString("utf-8");
29
+ return String(JSON.parse(text).type ?? "signal");
30
+ }
31
+ catch {
32
+ return "signal";
33
+ }
34
+ }
12
35
  export class PeerManager extends EventEmitter {
13
36
  peer = null;
14
37
  identity = null;
@@ -142,6 +165,25 @@ export class PeerManager extends EventEmitter {
142
165
  throw new Error("Peer not created. Call create() first.");
143
166
  return this.peer.sendFile(userid, data, { name });
144
167
  }
168
+ /**
169
+ * Send an APPLICATION packet to a friend. The payload is opaque to
170
+ * decentlan — this is the extension point that lets an app (beagle, an
171
+ * agent, any IPC client) run its own protocol over our Carrier session
172
+ * without a change in here.
173
+ *
174
+ * `id` must be in PACKET_ID_APP_MIN..MAX. Rejecting anything else is a
175
+ * security boundary, not tidiness: our own 161-164 carry session handshakes,
176
+ * dora control and IP frames, so a caller allowed to pick those ids could
177
+ * forge a handshake or inject a packet onto a peer's TUN.
178
+ */
179
+ async sendAppPacket(userid, id, data) {
180
+ if (!this.peer)
181
+ throw new Error("Peer not created. Call create() first.");
182
+ if (!isAppPacketId(id)) {
183
+ throw new Error(`packet id ${id} is outside the application range 165-191`);
184
+ }
185
+ await this.peer.sendCustomPacket(userid, id, data);
186
+ }
145
187
  /** Send a file INLINE over the message channel (FileModel JSON envelope,
146
188
  * bulkmsg-split) — the only file path native iOS/C Carrier clients can
147
189
  * receive online. Use for native friends; JS friends keep sendFile(). */
@@ -181,7 +223,21 @@ export class PeerManager extends EventEmitter {
181
223
  // A call already committed to Carrier must NOT split to socket.io on a
182
224
  // transient flap (it breaks ICE). Drop this signal; ICE recovers via the
183
225
  // other candidates / a retried offer.
226
+ //
227
+ // That reasoning only holds for a TRANSIENT failure. An oversize payload
228
+ // fails identically on every retry, so "ICE recovers via a retried offer"
229
+ // is false — the retry is the same too-big offer. Chrome's SDP runs
230
+ // 8.4-9.8 KB against the 8192-byte native invite cap (carrier.h), which
231
+ // silently killed every desktop→phone call: the offer never left, so the
232
+ // phone never rang, and on an inbound call our answer never left, so it
233
+ // rang and then never connected. Route a permanently-undeliverable signal
234
+ // to the offline bridge instead of dropping it on the floor.
184
235
  if (callId && this.offlineBridge?.isOnlineCall(callId)) {
236
+ if (this.offlineBridge.enabled && isPermanentInviteFailure(err)) {
237
+ this.logger.warn(`sendCallSignal: ${signalKind(data)} for call ${callId} cannot go over Carrier (${err.message}) — offline path to ${userid}`);
238
+ await this.offlineBridge.sendSignal(userid, data, hasVideo);
239
+ return;
240
+ }
185
241
  this.logger.info(`sendCallSignal: dropped a Carrier signal for online call ${callId} (${err.message})`);
186
242
  return;
187
243
  }
@@ -650,6 +706,13 @@ export class PeerManager extends EventEmitter {
650
706
  return;
651
707
  }
652
708
  this.handleDecodedFrame(pubkey, frame);
709
+ return;
710
+ }
711
+ // Application range: hand the bytes up untouched. We do not parse them,
712
+ // so a malformed app payload can never reach any decentlan code path —
713
+ // validating it is the receiving app's job, at its own trust boundary.
714
+ if (isAppPacketId(id)) {
715
+ this.emit("app-packet", pubkey, id, data);
653
716
  }
654
717
  }
655
718
  catch (error) {
@@ -12,3 +12,7 @@ export declare const PACKET_ID_DL_SESSION = 161;
12
12
  export declare const PACKET_ID_DL_DORA = 162;
13
13
  export declare const PACKET_ID_DL_IP = 163;
14
14
  export declare const PACKET_ID_DL_RATE = 164;
15
+ export declare const PACKET_ID_APP_MIN = 165;
16
+ export declare const PACKET_ID_APP_MAX = 191;
17
+ /** True when `id` is in the application range apps may send and receive on. */
18
+ export declare function isAppPacketId(id: unknown): id is number;
@@ -31,3 +31,23 @@ export const PACKET_ID_DL_IP = 163; // lossless: IP data frames (must traverse r
31
31
  // so it stops overrunning the path and inducing congestion drops. Payload: a
32
32
  // single uint32be = delivered bytes/sec. Rare + tiny, so lossless is free.
33
33
  export const PACKET_ID_DL_RATE = 164;
34
+ // --- application packet range -----------------------------------------------
35
+ // 165-191 is handed to APPLICATIONS built on this daemon (beagle, agents, any
36
+ // IPC client). decentlan carries these bytes and never interprets them, so an
37
+ // app can add a protocol of its own without a change here — the reason this
38
+ // range exists at all is so no app ever has to modify the SDK again.
39
+ //
40
+ // Lossless (160-191), like everything else we own: an app-level control message
41
+ // that vanishes on a relay-only session is worse than useless. See the note on
42
+ // PACKET_ID_DL_IP for why lossy is not an option on GFW-crossing paths.
43
+ //
44
+ // The range deliberately STARTS above our own ids. An IPC client is local, but
45
+ // it is not necessarily this daemon's code, and letting it emit on 161-164
46
+ // would let it forge session handshakes, dora control, or IP frames at a peer —
47
+ // i.e. inject onto someone's TUN. The bound is enforced on send AND on receive.
48
+ export const PACKET_ID_APP_MIN = 165;
49
+ export const PACKET_ID_APP_MAX = 191;
50
+ /** True when `id` is in the application range apps may send and receive on. */
51
+ export function isAppPacketId(id) {
52
+ return typeof id === "number" && Number.isInteger(id) && id >= PACKET_ID_APP_MIN && id <= PACKET_ID_APP_MAX;
53
+ }
@@ -94,6 +94,12 @@ export interface IpcHandlers {
94
94
  * Carrier "carrier" friend-invite extension — the channel iOS/Android Beagle
95
95
  * listen on for calls. Backs the desktop UI's audio/video calls. */
96
96
  callSignal: (userid: string, data: string) => Promise<void>;
97
+ /** Send an application packet (opaque bytes on a Carrier custom-packet id in
98
+ * the 165-191 app range) to a friend. The extension point for apps built on
99
+ * this daemon: beagle's chat components ride it, and so can anything else,
100
+ * without adding an op per feature. Inbound ones arrive as
101
+ * `{type:"app", userid, packetId, data}` events on `subscribe`. */
102
+ appSend: (userid: string, packetId: number, data: Uint8Array) => Promise<void>;
97
103
  /** Long-poll for inbound call-signaling payloads. Resolves with any queued
98
104
  * signals immediately, otherwise holds the connection until one arrives or a
99
105
  * ~20s timeout elapses (then resolves empty). The UI re-polls to get
@@ -121,7 +127,7 @@ export interface IpcHandlers {
121
127
  selfRestart: () => Promise<Record<string, unknown>>;
122
128
  }
123
129
  export interface IpcRequest {
124
- op: "friend-request" | "ping" | "diag" | "friends-pending" | "friends-accept" | "friends-reject" | "chat-send" | "chat-log-local" | "file-log-local" | "chat-history" | "friends-list" | "friend-remove" | "friend-set-alias" | "friends-autoaccept" | "set-profile" | "file-send" | "file-delete" | "file-cancel" | "file-retry" | "chat-mark-read" | "subscribe" | "sign" | "call-signal" | "call-poll" | "proxy-reload" | "proxy-access" | "self-restart";
130
+ op: "friend-request" | "ping" | "diag" | "friends-pending" | "friends-accept" | "friends-reject" | "chat-send" | "chat-log-local" | "file-log-local" | "chat-history" | "friends-list" | "friend-remove" | "friend-set-alias" | "friends-autoaccept" | "set-profile" | "file-send" | "file-delete" | "file-cancel" | "file-retry" | "chat-mark-read" | "subscribe" | "sign" | "call-signal" | "call-poll" | "proxy-reload" | "proxy-access" | "app-send" | "self-restart";
125
131
  address?: string;
126
132
  hello?: string;
127
133
  userid?: string;
@@ -137,8 +143,11 @@ export interface IpcRequest {
137
143
  ts?: number;
138
144
  enabled?: boolean;
139
145
  ids?: string[];
140
- /** RtcSignal JSON payload for the "call-signal" op. */
146
+ /** RtcSignal JSON payload for the "call-signal" op; base64 payload for
147
+ * "app-send". */
141
148
  data?: string;
149
+ /** Carrier custom-packet id for the "app-send" op (165-191). */
150
+ packetId?: number;
142
151
  }
143
152
  export interface IpcResponseOk {
144
153
  ok: true;
@@ -276,6 +276,24 @@ export class IpcServer {
276
276
  await this.handlers.callSignal(req.userid, req.data);
277
277
  return;
278
278
  }
279
+ case "app-send": {
280
+ if (!req.userid)
281
+ throw new Error("userid is required");
282
+ if (typeof req.packetId !== "number")
283
+ throw new Error("packetId is required");
284
+ if (typeof req.data !== "string")
285
+ throw new Error("data is required (base64)");
286
+ // Base64 is the only sane way to carry bytes through a newline-delimited
287
+ // JSON socket. Buffer.from is lenient — it silently skips invalid chars
288
+ // rather than throwing — so re-encode and compare: a caller that sends
289
+ // corrupt base64 must be told, not have truncated bytes put on the wire.
290
+ const bytes = Buffer.from(req.data, "base64");
291
+ if (bytes.toString("base64").replace(/=+$/, "") !== req.data.replace(/=+$/, "")) {
292
+ throw new Error("data is not valid base64");
293
+ }
294
+ await this.handlers.appSend(req.userid, req.packetId, new Uint8Array(bytes));
295
+ return { sent: bytes.length };
296
+ }
279
297
  case "call-poll":
280
298
  return await this.handlers.callPoll();
281
299
  case "proxy-reload":
@@ -698,6 +698,11 @@ export class DaemonServer {
698
698
  // session (throws if the peer is unreachable, so the UI can surface it).
699
699
  await this.peerManager.sendCallSignal(userid, data);
700
700
  },
701
+ appSend: async (userid, packetId, data) => {
702
+ // Opaque passthrough. The range check lives in PeerManager so it
703
+ // applies to every caller, not just this socket.
704
+ await this.peerManager.sendAppPacket(userid, packetId, data);
705
+ },
701
706
  callPoll: async () => {
702
707
  // Drain immediately if signals are queued; otherwise hold up to ~20s
703
708
  // for one to arrive (near-instant delivery without a WebSocket). The
@@ -1196,6 +1201,18 @@ export class DaemonServer {
1196
1201
  for (const wake of waiters)
1197
1202
  wake();
1198
1203
  });
1204
+ // Application packets (165-191) — push straight to IPC subscribers as
1205
+ // base64. The daemon never parses them: whatever protocol an app runs up
1206
+ // there is the app's business, and keeping it opaque is what lets apps
1207
+ // evolve without touching this SDK.
1208
+ this.peerManager.on("app-packet", (pubkey, packetId, data) => {
1209
+ this.ipcEvents.emit("event", {
1210
+ type: "app",
1211
+ userid: pubkey,
1212
+ packetId,
1213
+ data: Buffer.from(data).toString("base64"),
1214
+ });
1215
+ });
1199
1216
  this.peerManager.on("friend-request", (req) => {
1200
1217
  void pubkeyHexToUserid(req.pubkey).then((userid) => {
1201
1218
  const who = `${req.name || "(unnamed)"} ${userid}`;
@@ -1,4 +1,4 @@
1
- window.__DK_UI_VERSION="0.1.255";
1
+ window.__DK_UI_VERSION="0.1.257";
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"/>',
@@ -2454,6 +2454,40 @@ const CALL_ICE_SERVERS = [
2454
2454
  { urls: "turn:tokyo.fi.chat:3478", username: "allcom", credential: "allcompass" },
2455
2455
  { urls: "turn:tokyo.fi.chat:3478?transport=tcp", username: "allcom", credential: "allcompass" }
2456
2456
  ];
2457
+ const VIDEO_CODEC_KEEP = /^video\/(H264|VP8|rtx|red|ulpfec)$/i;
2458
+ const SDP_BUDGET = 8192;
2459
+ function trimVideoCodecs(pc) {
2460
+ var _a, _b, _c, _d, _e, _f, _g;
2461
+ const supported = (_c = (_b = (_a = window.RTCRtpSender) == null ? void 0 : _a.getCapabilities) == null ? void 0 : _b.call(_a, "video")) == null ? void 0 : _c.codecs;
2462
+ if (!supported) return;
2463
+ const keep = supported.filter((c) => VIDEO_CODEC_KEEP.test(c.mimeType));
2464
+ if (!keep.length || keep.length === supported.length) return;
2465
+ for (const t of pc.getTransceivers()) {
2466
+ const kind = ((_e = (_d = t.receiver) == null ? void 0 : _d.track) == null ? void 0 : _e.kind) || ((_g = (_f = t.sender) == null ? void 0 : _f.track) == null ? void 0 : _g.kind);
2467
+ if (kind !== "video" || !t.setCodecPreferences) continue;
2468
+ try {
2469
+ t.setCodecPreferences(keep);
2470
+ } catch (e) {
2471
+ console.warn("[call] setCodecPreferences failed", e);
2472
+ }
2473
+ }
2474
+ }
2475
+ function createLeanPeerConnection(config) {
2476
+ const pc = new RTCPeerConnection(config);
2477
+ for (const method of ["createOffer", "createAnswer"]) {
2478
+ const original = pc[method].bind(pc);
2479
+ pc[method] = async (...args) => {
2480
+ trimVideoCodecs(pc);
2481
+ const desc = await original(...args);
2482
+ const bytes = new TextEncoder().encode(desc.sdp || "").length;
2483
+ console[bytes > SDP_BUDGET ? "warn" : "log"](
2484
+ `[call] ${method} SDP ${bytes}B / ${SDP_BUDGET}B budget` + (bytes > SDP_BUDGET ? " \u2014 too large for the Carrier invite channel, falling back to the offline path" : "")
2485
+ );
2486
+ return desc;
2487
+ };
2488
+ }
2489
+ return pc;
2490
+ }
2457
2491
  function useCallController(selfId, onCallLog) {
2458
2492
  const [incoming, setIncoming] = React.useState(null);
2459
2493
  const [active, setActive] = React.useState(null);
@@ -2476,7 +2510,7 @@ function useCallController(selfId, onCallLog) {
2476
2510
  const engine = new CallEngine({
2477
2511
  selfId,
2478
2512
  signaling,
2479
- createPeerConnection: (c) => new RTCPeerConnection(c),
2513
+ createPeerConnection: (c) => createLeanPeerConnection(c),
2480
2514
  getLocalMedia: (k) => navigator.mediaDevices.getUserMedia({ audio: k.audio, video: k.video }),
2481
2515
  getDisplayMedia: (c) => navigator.mediaDevices.getDisplayMedia(c || { video: true, audio: false }),
2482
2516
  iceServers: CALL_ICE_SERVERS,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/lan",
3
- "version": "0.1.255",
3
+ "version": "0.1.257",
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",