@decentnetwork/peer 0.1.111 → 0.1.112

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/index.d.ts CHANGED
@@ -10,4 +10,4 @@ export { LegacyProtocolNotImplementedError } from "./runtime/errors.js";
10
10
  export type { CarrierPacket, FriendMessagePacket, FriendRequestPacket, InviteReqPacket, InviteRspPacket } from "./compat/packet.js";
11
11
  export type { ToxDhtCryptoRequest } from "./compat/tox-dht-crypto.js";
12
12
  export type { CarrierAddressParts } from "./compat/address.js";
13
- export type { CompatibilityMode, CustomPacketEvent, FriendConnectionEvent, FriendConnectionStatus, FriendInfoEvent, FriendRequest, InlineFileEvent, InviteEvent, InviteResponseEvent, LookupResult, NetworkNode, PeerOptions, TextMessage } from "./types/peer.js";
13
+ export type { CompatibilityMode, CustomPacketEvent, FriendConnectionEvent, FriendConnectionStatus, FriendInfoEvent, FriendRequest, InlineFileEvent, InviteEvent, InviteResponseEvent, LookupResult, NetworkNode, PeerOptions, SendTextUntilAckOptions, TextMessage } from "./types/peer.js";
package/dist/peer.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { type BootstrapResult } from "./compat/bootstrap.js";
2
2
  import type { FriendRecord } from "./store/friends.js";
3
- import type { CustomPacketEvent, FriendConnectionEvent, FriendRequest, FriendInfoEvent, InlineFileEvent, InviteEvent, InviteResponseEvent, LookupResult, NetworkNode, PeerOptions, TextMessage } from "./types/peer.js";
3
+ import type { CustomPacketEvent, FriendConnectionEvent, FriendRequest, FriendInfoEvent, InlineFileEvent, InviteEvent, InviteResponseEvent, LookupResult, NetworkNode, PeerOptions, SendTextUntilAckOptions, TextMessage } from "./types/peer.js";
4
4
  export declare class Peer {
5
5
  #private;
6
6
  private constructor();
@@ -84,9 +84,21 @@ export declare class Peer {
84
84
  */
85
85
  removeFriend(pubkey: string): boolean;
86
86
  sendText(pubkey: string, text: string): Promise<void>;
87
+ /**
88
+ * Send text and keep retransmitting until the peer explicitly ACKs it.
89
+ *
90
+ * The receiving peer sends that ACK only after every onText handler returns
91
+ * successfully. If a handler persists to an inbox, return its write Promise
92
+ * from the handler; then this method resolves only after that durable write
93
+ * completed on the far side. Pass a stable deliveryId when retrying an item
94
+ * from an application outbox across process restarts.
95
+ */
96
+ sendTextUntilAck(pubkey: string, text: string, opts?: SendTextUntilAckOptions): Promise<{
97
+ deliveryId: string;
98
+ }>;
87
99
  waitForFriendConnected(pubkey: string, timeoutMs?: number): Promise<boolean>;
88
100
  onFriendRequest(cb: (req: FriendRequest) => void): void;
89
- onText(cb: (msg: TextMessage) => void): void;
101
+ onText(cb: (msg: TextMessage) => unknown | Promise<unknown>): void;
90
102
  /** Files received inline over (bulk)messages — the iOS/C Carrier apps'
91
103
  * native way of sending images/audio online (FileModel JSON envelope). */
92
104
  onInlineFile(cb: (evt: InlineFileEvent) => void): void;
package/dist/peer.js CHANGED
@@ -168,15 +168,18 @@ const PEER_NICKNAME = process.env.DECENT_PEER_NAME ?? "@decentnetwork/peer";
168
168
  const PEER_STATUS_MESSAGE = process.env.DECENT_PEER_STATUS_MESSAGE ?? "decent peer";
169
169
  const GREETING_TEXT = process.env.DECENT_GREETING_TEXT ?? "";
170
170
  // AgentNet wire-protocol version advertised in the userinfo profile (field 7).
171
- // Bump when the wire capabilities change so peers can negotiate. v1 marks a peer
172
- // that carries this field AND supports the toxcore file-transfer channel
173
- // (PACKET_ID_FILE 80-82) for large files — a sender can prefer that over inline
174
- // bulkmsg (capped at CARRIER_MAX_APP_BULKMSG_LEN) when the peer's protoVersion>=1.
175
- const AGENTNET_PROTO_VERSION = 1;
171
+ // v1 marks a peer that supports the toxcore file-transfer channel. v2 adds
172
+ // application-level text delivery ACKs: senders keep outbox entries until the
173
+ // receiver's onText handler has returned successfully.
174
+ const AGENTNET_PROTO_VERSION = 2;
176
175
  // Peer package version, advertised as the default appVersion when the embedder
177
176
  // doesn't override it. Read lazily so a bundler that inlines this file doesn't
178
177
  // need the package.json at runtime.
179
- const PEER_PKG_VERSION = "0.1.91";
178
+ const PEER_PKG_VERSION = "0.1.112";
179
+ const TEXT_ACK_PREFIX = "\x1eDNPACK1:";
180
+ const TEXT_ACK_TIMEOUT_MS = readEnvInt("DECENT_TEXT_ACK_TIMEOUT_MS", 300_000);
181
+ const TEXT_ACK_RETRY_MS = readEnvInt("DECENT_TEXT_ACK_RETRY_MS", 5_000);
182
+ const TEXT_AUTO_ACK_TIMEOUT_MS = readEnvInt("DECENT_TEXT_AUTO_ACK_TIMEOUT_MS", 15_000);
180
183
  // Toxcore Messenger.h packet IDs (live inside encrypted 0x1b crypto data plain payload)
181
184
  const PACKET_ID_PADDING = 0;
182
185
  const PACKET_ID_REQUEST = 1; // request retransmission of unreceived packets
@@ -273,6 +276,10 @@ export class Peer {
273
276
  #nodeBlacklist = new Map();
274
277
  #pendingFriendRequests = new Map();
275
278
  #friends = new Map();
279
+ #textHandlers = new Set();
280
+ #pendingTextAcks = new Map();
281
+ #deliveredTextIds = new Set();
282
+ #deliveredTextOrder = [];
276
283
  #friendStoreFile;
277
284
  #persistSeq = 0; // makes atomic friend-store temp filenames unique per write
278
285
  #cookieSymmetricKey;
@@ -1059,6 +1066,16 @@ export class Peer {
1059
1066
  }
1060
1067
  }
1061
1068
  async sendText(pubkey, text) {
1069
+ if (text.length > 0 && !text.startsWith(TEXT_ACK_PREFIX) && this.#shouldRequireTextAck(pubkey)) {
1070
+ await this.sendTextUntilAck(pubkey, text, {
1071
+ timeoutMs: TEXT_AUTO_ACK_TIMEOUT_MS,
1072
+ retryIntervalMs: Math.min(TEXT_ACK_RETRY_MS, TEXT_AUTO_ACK_TIMEOUT_MS)
1073
+ });
1074
+ return;
1075
+ }
1076
+ await this.#sendTextPlain(pubkey, text);
1077
+ }
1078
+ async #sendTextPlain(pubkey, text) {
1062
1079
  const friend = this.#friends.get(pubkey);
1063
1080
  if (!friend) {
1064
1081
  throw new Error(`Not a friend: ${pubkey}`);
@@ -1175,6 +1192,75 @@ export class Peer {
1175
1192
  }
1176
1193
  throw new Error("friend is offline and no express node is configured");
1177
1194
  }
1195
+ #shouldRequireTextAck(pubkey) {
1196
+ const friend = this.#friends.get(pubkey);
1197
+ if ((friend?.protoVersion ?? 0) >= 2)
1198
+ return true;
1199
+ // decentlan data-plane peers run with expressControlPlaneOnly so an
1200
+ // accepted local send must not be treated as final delivery. A short ACK
1201
+ // timeout makes the caller keep its outbox item and retry on reconnect.
1202
+ return this.#opts.expressControlPlaneOnly === true;
1203
+ }
1204
+ /**
1205
+ * Send text and keep retransmitting until the peer explicitly ACKs it.
1206
+ *
1207
+ * The receiving peer sends that ACK only after every onText handler returns
1208
+ * successfully. If a handler persists to an inbox, return its write Promise
1209
+ * from the handler; then this method resolves only after that durable write
1210
+ * completed on the far side. Pass a stable deliveryId when retrying an item
1211
+ * from an application outbox across process restarts.
1212
+ */
1213
+ async sendTextUntilAck(pubkey, text, opts = {}) {
1214
+ const deliveryId = opts.deliveryId ?? createTextDeliveryId();
1215
+ const retryIntervalMs = Math.max(250, opts.retryIntervalMs ?? TEXT_ACK_RETRY_MS);
1216
+ const timeoutMs = Math.max(retryIntervalMs, opts.timeoutMs ?? TEXT_ACK_TIMEOUT_MS);
1217
+ const envelope = encodeTextAckEnvelope({ t: "msg", id: deliveryId, text });
1218
+ const started = Date.now();
1219
+ let lastError;
1220
+ while (Date.now() - started < timeoutMs) {
1221
+ const remaining = Math.max(1, timeoutMs - (Date.now() - started));
1222
+ const waitMs = Math.min(retryIntervalMs, remaining);
1223
+ const ackPromise = this.#waitForTextAck(deliveryId, waitMs);
1224
+ try {
1225
+ await this.#sendTextPlain(pubkey, envelope);
1226
+ }
1227
+ catch (error) {
1228
+ this.#cancelTextAckWait(deliveryId);
1229
+ lastError = error;
1230
+ await sleep(waitMs);
1231
+ continue;
1232
+ }
1233
+ if (await ackPromise) {
1234
+ return { deliveryId };
1235
+ }
1236
+ }
1237
+ this.#cancelTextAckWait(deliveryId);
1238
+ throw lastError ?? new Error(`text delivery ACK timed out for ${pubkey}`);
1239
+ }
1240
+ #waitForTextAck(deliveryId, timeoutMs) {
1241
+ return new Promise((resolve) => {
1242
+ const timer = setTimeout(() => {
1243
+ this.#pendingTextAcks.delete(deliveryId);
1244
+ resolve(false);
1245
+ }, timeoutMs);
1246
+ timer.unref?.();
1247
+ this.#pendingTextAcks.set(deliveryId, {
1248
+ resolve: () => {
1249
+ clearTimeout(timer);
1250
+ this.#pendingTextAcks.delete(deliveryId);
1251
+ resolve(true);
1252
+ },
1253
+ reject: () => {
1254
+ clearTimeout(timer);
1255
+ this.#pendingTextAcks.delete(deliveryId);
1256
+ resolve(false);
1257
+ }
1258
+ });
1259
+ });
1260
+ }
1261
+ #cancelTextAckWait(deliveryId) {
1262
+ this.#pendingTextAcks.get(deliveryId)?.reject(new Error("text delivery ACK wait cancelled"));
1263
+ }
1178
1264
  waitForFriendConnected(pubkey, timeoutMs = 30000) {
1179
1265
  return this.#waitForFriendConnected(pubkey, timeoutMs);
1180
1266
  }
@@ -1202,7 +1288,65 @@ export class Peer {
1202
1288
  this.#events.on("friendRequest", cb);
1203
1289
  }
1204
1290
  onText(cb) {
1205
- this.#events.on("text", cb);
1291
+ this.#textHandlers.add(cb);
1292
+ }
1293
+ async #dispatchTextMessage(msg) {
1294
+ const envelope = decodeTextAckEnvelope(msg.text);
1295
+ if (envelope?.t === "ack") {
1296
+ this.#pendingTextAcks.get(envelope.id)?.resolve();
1297
+ return;
1298
+ }
1299
+ let deliveryId;
1300
+ let text = msg.text;
1301
+ if (envelope?.t === "msg") {
1302
+ deliveryId = envelope.id;
1303
+ text = envelope.text;
1304
+ if (this.#deliveredTextIds.has(deliveryId)) {
1305
+ await this.#sendTextAck(msg.pubkey, deliveryId);
1306
+ return;
1307
+ }
1308
+ }
1309
+ if (this.#textHandlers.size === 0) {
1310
+ if (deliveryId)
1311
+ this.#debugLog(`text delivery ${deliveryId} from ${msg.pubkey} has no onText handlers; not ACKing`);
1312
+ return;
1313
+ }
1314
+ const ack = deliveryId
1315
+ ? async () => { await this.#sendTextAck(msg.pubkey, deliveryId); }
1316
+ : undefined;
1317
+ const delivered = { ...msg, text, deliveryId, ack };
1318
+ try {
1319
+ for (const handler of this.#textHandlers) {
1320
+ await handler(delivered);
1321
+ }
1322
+ }
1323
+ catch (error) {
1324
+ this.#debugLog(`text handler failed for ${msg.pubkey}: ${error.message}`);
1325
+ return;
1326
+ }
1327
+ if (deliveryId) {
1328
+ this.#rememberDeliveredTextId(deliveryId);
1329
+ await ack?.();
1330
+ }
1331
+ }
1332
+ async #sendTextAck(pubkey, deliveryId) {
1333
+ try {
1334
+ await this.sendText(pubkey, encodeTextAckEnvelope({ t: "ack", id: deliveryId }));
1335
+ }
1336
+ catch (error) {
1337
+ this.#debugLog(`text ACK send failed for ${pubkey}: ${error.message}`);
1338
+ }
1339
+ }
1340
+ #rememberDeliveredTextId(deliveryId) {
1341
+ if (this.#deliveredTextIds.has(deliveryId))
1342
+ return;
1343
+ this.#deliveredTextIds.add(deliveryId);
1344
+ this.#deliveredTextOrder.push(deliveryId);
1345
+ while (this.#deliveredTextOrder.length > 4096) {
1346
+ const old = this.#deliveredTextOrder.shift();
1347
+ if (old)
1348
+ this.#deliveredTextIds.delete(old);
1349
+ }
1206
1350
  }
1207
1351
  /** Files received inline over (bulk)messages — the iOS/C Carrier apps'
1208
1352
  * native way of sending images/audio online (FileModel JSON envelope). */
@@ -2620,7 +2764,7 @@ export class Peer {
2620
2764
  // (iOS posts the whole thing as one express MESSAGE packet).
2621
2765
  if (this.#tryEmitInlineFile(fromUserId, text, "offline"))
2622
2766
  return;
2623
- this.#events.emit("text", { pubkey: fromUserId, text, via: "offline" });
2767
+ void this.#dispatchTextMessage({ pubkey: fromUserId, text, via: "offline" });
2624
2768
  }
2625
2769
  catch {
2626
2770
  // Ignore invalid offline payloads.
@@ -3607,7 +3751,7 @@ export class Peer {
3607
3751
  // as files, not as a wall of base64 text.
3608
3752
  if (this.#tryEmitInlineFile(friendId, text, "online"))
3609
3753
  return;
3610
- this.#events.emit("text", {
3754
+ void this.#dispatchTextMessage({
3611
3755
  pubkey: friendId,
3612
3756
  text,
3613
3757
  via: "online"
@@ -6057,6 +6201,33 @@ function decodeUtf8Best(payload) {
6057
6201
  return "";
6058
6202
  }
6059
6203
  }
6204
+ function encodeTextAckEnvelope(envelope) {
6205
+ return TEXT_ACK_PREFIX + Buffer.from(JSON.stringify(envelope), "utf8").toString("base64url");
6206
+ }
6207
+ function decodeTextAckEnvelope(text) {
6208
+ if (!text.startsWith(TEXT_ACK_PREFIX))
6209
+ return undefined;
6210
+ try {
6211
+ const raw = Buffer.from(text.slice(TEXT_ACK_PREFIX.length), "base64url").toString("utf8");
6212
+ const parsed = JSON.parse(raw);
6213
+ if (parsed.t === "ack" && typeof parsed.id === "string" && parsed.id.length > 0) {
6214
+ return { t: "ack", id: parsed.id };
6215
+ }
6216
+ if (parsed.t === "msg" &&
6217
+ typeof parsed.id === "string" &&
6218
+ parsed.id.length > 0 &&
6219
+ typeof parsed.text === "string") {
6220
+ return { t: "msg", id: parsed.id, text: parsed.text };
6221
+ }
6222
+ }
6223
+ catch {
6224
+ return undefined;
6225
+ }
6226
+ return undefined;
6227
+ }
6228
+ function createTextDeliveryId() {
6229
+ return `${Date.now().toString(36)}-${Buffer.from(randomBytes(12)).toString("base64url")}`;
6230
+ }
6060
6231
  function tryDecodeCarrierMessagePacket(payload) {
6061
6232
  try {
6062
6233
  const decoded = decodeCarrierPacket(payload);
@@ -122,11 +122,26 @@ export type FriendInfoEvent = {
122
122
  export type TextMessage = {
123
123
  pubkey: string;
124
124
  text: string;
125
+ /** Stable id for SDK-level acknowledged delivery. Present only for messages
126
+ * sent with sendTextUntilAck(). Receivers can persist this id for dedupe. */
127
+ deliveryId?: string;
128
+ /** Send the delivery ACK. The SDK calls this automatically after all onText
129
+ * handlers return successfully; handlers that need durable inbox semantics
130
+ * should return a Promise that resolves only after the inbox write is done. */
131
+ ack?: () => Promise<void>;
125
132
  /** Delivery path: "online" = live net_crypto session (direct/relay), "offline"
126
133
  * = express store-and-forward. Lets the UI color the two differently so a user
127
134
  * can see when online delivery is failing and only offline messages land. */
128
135
  via?: "online" | "offline";
129
136
  };
137
+ export type SendTextUntilAckOptions = {
138
+ /** Stable id for retries across a caller-managed outbox. Defaults to a random id. */
139
+ deliveryId?: string;
140
+ /** How long to keep retrying before rejecting. Defaults to 5 minutes. */
141
+ timeoutMs?: number;
142
+ /** Delay between retransmit attempts while no ACK has arrived. Defaults to 5s. */
143
+ retryIntervalMs?: number;
144
+ };
130
145
  /**
131
146
  * An application-defined custom packet received from a friend, over the
132
147
  * toxcore custom packet ranges: lossless `160–191` (reliable, ordered) or
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/peer",
3
- "version": "0.1.111",
3
+ "version": "0.1.112",
4
4
  "description": "Pure TypeScript port of Elastos Carrier (toxcore-derived) P2P messaging. DHT, onion routing, TCP relay, FlatBuffers app payloads, Express offline relay. Wire-compatible with iOS Beagle and the Carrier C SDK.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",