@decentnetwork/peer 0.1.135 → 0.1.137

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.
@@ -0,0 +1,60 @@
1
+ /**
2
+ * WebRTC ICE helpers matching Android Carrier WebrtcClient.getIceServers() /
3
+ * CarrierExtension.getTurnServerInfo() (carrier_get_turn_server).
4
+ *
5
+ * Online Android Beagle gathers STUN+TURN against a Carrier bootstrap node's
6
+ * coturn (UDP/3478) using Carrier long-term credentials. Browser / Node WebRTC
7
+ * stacks need the same list — not the tokyo.fi.chat static relay used only as
8
+ * a fallback for the peer SDK's own messaging UDP relay path.
9
+ */
10
+ import { CARRIER_TURN_PORT, CARRIER_TURN_REALM } from "./turn-creds.js";
11
+ import type { NetworkNode } from "./types/peer.js";
12
+ /** Same shape as Android CarrierExtension.TurnServerInfo / CarrierTurnServer. */
13
+ export interface CarrierTurnServerInfo {
14
+ server: string;
15
+ port: number;
16
+ username: string;
17
+ password: string;
18
+ realm: string;
19
+ }
20
+ /** Browser / wrtc RTCIceServer-compatible entry. */
21
+ export interface RtcIceServer {
22
+ urls: string | string[];
23
+ username?: string;
24
+ credential?: string;
25
+ }
26
+ export interface BuildIceServersOptions {
27
+ /** Bootstrap / bootnodes that run Carrier TURN on port 3478. */
28
+ bootstrapNodes: NetworkNode[];
29
+ ourUserid: string;
30
+ ourSecretKey: Uint8Array;
31
+ /**
32
+ * How many bootstrap TURNs to advertise. Android returns one; browsers often
33
+ * benefit from a few (default 3). Cap with this.
34
+ */
35
+ limit?: number;
36
+ /** Also advertise turn:?transport=tcp (useful for UDP-blocked browsers). */
37
+ includeTcpTurn?: boolean;
38
+ }
39
+ /**
40
+ * Derive TURN info for one bootnode — peer analogue of
41
+ * carrier_get_turn_server / CarrierExtension.getTurnServerInfo().
42
+ */
43
+ export declare function getTurnServerInfoForBootnode(opts: {
44
+ bootnode: NetworkNode;
45
+ ourUserid: string;
46
+ ourSecretKey: Uint8Array;
47
+ }): CarrierTurnServerInfo;
48
+ /**
49
+ * Build RTCIceServer entries from TURN info the way Android WebrtcClient does:
50
+ * stun:host:port + turn:host:port with the same username/password.
51
+ */
52
+ export declare function iceServersFromTurnInfo(info: CarrierTurnServerInfo, opts?: {
53
+ includeTcpTurn?: boolean;
54
+ }): RtcIceServer[];
55
+ /**
56
+ * Build a WebRTC iceServers list from Carrier bootstrap nodes.
57
+ * Fresh nonce/credentials on every call (same as Android).
58
+ */
59
+ export declare function getIceServers(opts: BuildIceServersOptions): RtcIceServer[];
60
+ export { CARRIER_TURN_PORT, CARRIER_TURN_REALM };
@@ -0,0 +1,78 @@
1
+ /**
2
+ * WebRTC ICE helpers matching Android Carrier WebrtcClient.getIceServers() /
3
+ * CarrierExtension.getTurnServerInfo() (carrier_get_turn_server).
4
+ *
5
+ * Online Android Beagle gathers STUN+TURN against a Carrier bootstrap node's
6
+ * coturn (UDP/3478) using Carrier long-term credentials. Browser / Node WebRTC
7
+ * stacks need the same list — not the tokyo.fi.chat static relay used only as
8
+ * a fallback for the peer SDK's own messaging UDP relay path.
9
+ */
10
+ import { base58ToBytes } from "./utils/base58.js";
11
+ import { CARRIER_TURN_PORT, CARRIER_TURN_REALM, deriveCarrierTurnCreds } from "./turn-creds.js";
12
+ function bootnodePublicKey(node) {
13
+ if (node.pkBytes && node.pkBytes.length === 32)
14
+ return node.pkBytes;
15
+ if (!node.pk)
16
+ throw new Error(`bootstrap node ${node.host} has no pk`);
17
+ return base58ToBytes(node.pk);
18
+ }
19
+ function toTurnServerInfo(creds) {
20
+ return {
21
+ server: creds.host,
22
+ port: creds.port,
23
+ username: creds.username,
24
+ password: creds.password,
25
+ realm: creds.realm
26
+ };
27
+ }
28
+ /**
29
+ * Derive TURN info for one bootnode — peer analogue of
30
+ * carrier_get_turn_server / CarrierExtension.getTurnServerInfo().
31
+ */
32
+ export function getTurnServerInfoForBootnode(opts) {
33
+ const creds = deriveCarrierTurnCreds({
34
+ bootnodeHost: opts.bootnode.host,
35
+ bootnodePublicKey: bootnodePublicKey(opts.bootnode),
36
+ ourUserid: opts.ourUserid,
37
+ ourSecretKey: opts.ourSecretKey
38
+ });
39
+ return toTurnServerInfo(creds);
40
+ }
41
+ /**
42
+ * Build RTCIceServer entries from TURN info the way Android WebrtcClient does:
43
+ * stun:host:port + turn:host:port with the same username/password.
44
+ */
45
+ export function iceServersFromTurnInfo(info, opts) {
46
+ const base = `${info.server}:${info.port}`;
47
+ const auth = { username: info.username, credential: info.password };
48
+ const out = [
49
+ { urls: `stun:${base}`, ...auth },
50
+ { urls: `turn:${base}`, ...auth }
51
+ ];
52
+ if (opts?.includeTcpTurn !== false) {
53
+ out.push({ urls: `turn:${base}?transport=tcp`, ...auth });
54
+ }
55
+ return out;
56
+ }
57
+ /**
58
+ * Build a WebRTC iceServers list from Carrier bootstrap nodes.
59
+ * Fresh nonce/credentials on every call (same as Android).
60
+ */
61
+ export function getIceServers(opts) {
62
+ const limit = opts.limit ?? 3;
63
+ const nodes = opts.bootstrapNodes.filter((n) => n?.host && (n.pk || n.pkBytes)).slice(0, limit);
64
+ if (!nodes.length) {
65
+ throw new Error("getIceServers: no bootstrap nodes with public keys");
66
+ }
67
+ const ice = [];
68
+ for (const bootnode of nodes) {
69
+ const info = getTurnServerInfoForBootnode({
70
+ bootnode,
71
+ ourUserid: opts.ourUserid,
72
+ ourSecretKey: opts.ourSecretKey
73
+ });
74
+ ice.push(...iceServersFromTurnInfo(info, { includeTcpTurn: opts.includeTcpTurn }));
75
+ }
76
+ return ice;
77
+ }
78
+ export { CARRIER_TURN_PORT, CARRIER_TURN_REALM };
package/dist/index.d.ts CHANGED
@@ -6,6 +6,10 @@ export { NET_PACKET_ONION_ANNOUNCE_REQUEST, NET_PACKET_ONION_ANNOUNCE_RESPONSE,
6
6
  export { signDetached, verifyDetached, SIGNATURE_LENGTH } from "./crypto/sign.js";
7
7
  export { createCarrierRendezvousAuthProof, RENDEZVOUS_AUTH_KEY_DOMAIN, RENDEZVOUS_AUTH_PROOF_LENGTH } from "./crypto/rendezvous.js";
8
8
  export { base58ToBytes, bytesToBase58 } from "./utils/base58.js";
9
+ export { CARRIER_TURN_PORT, CARRIER_TURN_REALM, CARRIER_TURN_USER_SUFFIX, deriveCarrierTurnCreds } from "./turn-creds.js";
10
+ export type { CarrierTurnCreds } from "./turn-creds.js";
11
+ export { getIceServers, getTurnServerInfoForBootnode, iceServersFromTurnInfo } from "./ice-servers.js";
12
+ export type { BuildIceServersOptions, CarrierTurnServerInfo, RtcIceServer } from "./ice-servers.js";
9
13
  export { LegacyProtocolNotImplementedError } from "./runtime/errors.js";
10
14
  export type { CarrierPacket, FriendMessagePacket, FriendRequestPacket, InviteReqPacket, InviteRspPacket } from "./compat/packet.js";
11
15
  export type { ToxDhtCryptoRequest } from "./compat/tox-dht-crypto.js";
package/dist/index.js CHANGED
@@ -6,4 +6,6 @@ export { NET_PACKET_ONION_ANNOUNCE_REQUEST, NET_PACKET_ONION_ANNOUNCE_RESPONSE,
6
6
  export { signDetached, verifyDetached, SIGNATURE_LENGTH } from "./crypto/sign.js";
7
7
  export { createCarrierRendezvousAuthProof, RENDEZVOUS_AUTH_KEY_DOMAIN, RENDEZVOUS_AUTH_PROOF_LENGTH } from "./crypto/rendezvous.js";
8
8
  export { base58ToBytes, bytesToBase58 } from "./utils/base58.js";
9
+ export { CARRIER_TURN_PORT, CARRIER_TURN_REALM, CARRIER_TURN_USER_SUFFIX, deriveCarrierTurnCreds } from "./turn-creds.js";
10
+ export { getIceServers, getTurnServerInfoForBootnode, iceServersFromTurnInfo } from "./ice-servers.js";
9
11
  export { LegacyProtocolNotImplementedError } from "./runtime/errors.js";
package/dist/peer.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { type BootstrapResult } from "./compat/bootstrap.js";
2
2
  import type { FriendRecord } from "./store/friends.js";
3
+ import { type CarrierTurnServerInfo, type RtcIceServer } from "./ice-servers.js";
3
4
  import type { CustomPacketEvent, FriendConnectionEvent, FriendRequest, FriendInfoEvent, InlineFileEvent, InviteEvent, InviteResponseEvent, LookupResult, NetworkNode, PeerOptions, SendTextUntilAckOptions, TextMessage } from "./types/peer.js";
4
5
  export declare class Peer {
5
6
  #private;
@@ -18,6 +19,21 @@ export declare class Peer {
18
19
  */
19
20
  sign(message: Uint8Array): Uint8Array;
20
21
  address(): string;
22
+ /**
23
+ * Carrier bootstrap TURN credentials — peer analogue of Android
24
+ * CarrierExtension.getTurnServerInfo() / carrier_get_turn_server().
25
+ * Uses the first configured bootstrap node with a public key (fresh nonce
26
+ * each call). For WebRTC, prefer {@link getIceServers}.
27
+ */
28
+ getTurnServerInfo(bootnodeIndex?: number): CarrierTurnServerInfo;
29
+ /**
30
+ * RTCIceServer list for browser / native WebRTC, matching Android
31
+ * WebrtcClient.getIceServers() (Carrier bootstrap STUN+TURN, not tokyo).
32
+ */
33
+ getIceServers(opts?: {
34
+ limit?: number;
35
+ includeTcpTurn?: boolean;
36
+ }): RtcIceServer[];
21
37
  joinNetwork(): Promise<BootstrapResult>;
22
38
  lookup(pubkey: string): Promise<LookupResult>;
23
39
  announceSelf(timeoutMs?: number): Promise<NetworkNode[]>;
@@ -87,11 +103,13 @@ export declare class Peer {
87
103
  /**
88
104
  * Send text and keep retransmitting until the peer explicitly ACKs it.
89
105
  *
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.
106
+ * The ACK means the far side's SDK durably received the message — it is
107
+ * sent BEFORE the peer's onText handlers run, so a peer that is busy in
108
+ * application code (a model call, a disk-bound job) still ACKs instantly.
109
+ * It does NOT mean the application processed the message; if you need that,
110
+ * have the far side send an application-level reply. Pass a stable
111
+ * deliveryId when retrying an item from an application outbox across
112
+ * process restarts.
95
113
  */
96
114
  sendTextUntilAck(pubkey: string, text: string, opts?: SendTextUntilAckOptions): Promise<{
97
115
  deliveryId: string;
@@ -99,6 +117,13 @@ export declare class Peer {
99
117
  waitForFriendConnected(pubkey: string, timeoutMs?: number): Promise<boolean>;
100
118
  onFriendRequest(cb: (req: FriendRequest) => void): void;
101
119
  onText(cb: (msg: TextMessage) => unknown | Promise<unknown>): void;
120
+ /** @internal Test seam: deliver a text message exactly as if it arrived off
121
+ * the wire. Exists so the ACK-before-handlers ordering is testable without
122
+ * a network — do not use outside tests. */
123
+ _testDispatchTextMessage(msg: {
124
+ pubkey: string;
125
+ text: string;
126
+ }): Promise<void>;
102
127
  /** Files received inline over (bulk)messages — the iOS/C Carrier apps'
103
128
  * native way of sending images/audio online (FileModel JSON envelope). */
104
129
  onInlineFile(cb: (evt: InlineFileEvent) => void): void;
package/dist/peer.js CHANGED
@@ -23,6 +23,7 @@ import { UdpTransport } from "./transport/udp.js";
23
23
  import { bytesToHex, concatBytes, randomBytes } from "./utils/bytes.js";
24
24
  import { buildBindingRequest, decodeStun, decodeXorMappedAddress, findAttr, STUN_ATTR_XOR_MAPPED_ADDRESS, STUN_BINDING_SUCCESS } from "./stun.js";
25
25
  import { TurnClient } from "./turn.js";
26
+ import { getIceServers as buildRtcIceServers, getTurnServerInfoForBootnode } from "./ice-servers.js";
26
27
  import { createBoundUdp4Socket, closeDgramSocket } from "./transport/dgram-lifecycle.js";
27
28
  // Dedicated TURN relay servers — fixed public relays used as a stable
28
29
  // fallback path when direct UDP hole-punch fails or flaps (symmetric NAT,
@@ -831,6 +832,43 @@ export class Peer {
831
832
  }
832
833
  return carrierAddressFromPublicKey(this.#keyPair.publicKey);
833
834
  }
835
+ /**
836
+ * Carrier bootstrap TURN credentials — peer analogue of Android
837
+ * CarrierExtension.getTurnServerInfo() / carrier_get_turn_server().
838
+ * Uses the first configured bootstrap node with a public key (fresh nonce
839
+ * each call). For WebRTC, prefer {@link getIceServers}.
840
+ */
841
+ getTurnServerInfo(bootnodeIndex = 0) {
842
+ if (!this.#keyPair) {
843
+ throw new Error("Peer is not started");
844
+ }
845
+ const nodes = this.#opts.bootstrapNodes.filter((n) => n?.host && (n.pk || n.pkBytes));
846
+ const bootnode = nodes[bootnodeIndex] ?? nodes[0];
847
+ if (!bootnode) {
848
+ throw new Error("getTurnServerInfo: no bootstrap nodes configured");
849
+ }
850
+ return getTurnServerInfoForBootnode({
851
+ bootnode,
852
+ ourUserid: this.userid(),
853
+ ourSecretKey: this.#keyPair.secretKey
854
+ });
855
+ }
856
+ /**
857
+ * RTCIceServer list for browser / native WebRTC, matching Android
858
+ * WebrtcClient.getIceServers() (Carrier bootstrap STUN+TURN, not tokyo).
859
+ */
860
+ getIceServers(opts) {
861
+ if (!this.#keyPair) {
862
+ throw new Error("Peer is not started");
863
+ }
864
+ return buildRtcIceServers({
865
+ bootstrapNodes: this.#opts.bootstrapNodes,
866
+ ourUserid: this.userid(),
867
+ ourSecretKey: this.#keyPair.secretKey,
868
+ limit: opts?.limit,
869
+ includeTcpTurn: opts?.includeTcpTurn
870
+ });
871
+ }
834
872
  async joinNetwork() {
835
873
  if (!this.#bootstrap) {
836
874
  throw new Error("Peer is not started");
@@ -1330,11 +1368,13 @@ export class Peer {
1330
1368
  /**
1331
1369
  * Send text and keep retransmitting until the peer explicitly ACKs it.
1332
1370
  *
1333
- * The receiving peer sends that ACK only after every onText handler returns
1334
- * successfully. If a handler persists to an inbox, return its write Promise
1335
- * from the handler; then this method resolves only after that durable write
1336
- * completed on the far side. Pass a stable deliveryId when retrying an item
1337
- * from an application outbox across process restarts.
1371
+ * The ACK means the far side's SDK durably received the message — it is
1372
+ * sent BEFORE the peer's onText handlers run, so a peer that is busy in
1373
+ * application code (a model call, a disk-bound job) still ACKs instantly.
1374
+ * It does NOT mean the application processed the message; if you need that,
1375
+ * have the far side send an application-level reply. Pass a stable
1376
+ * deliveryId when retrying an item from an application outbox across
1377
+ * process restarts.
1338
1378
  */
1339
1379
  async sendTextUntilAck(pubkey, text, opts = {}) {
1340
1380
  const deliveryId = opts.deliveryId ?? createTextDeliveryId();
@@ -1441,6 +1481,28 @@ export class Peer {
1441
1481
  ? async () => { await this.#sendTextAck(msg.pubkey, deliveryId); }
1442
1482
  : undefined;
1443
1483
  const delivered = { ...msg, text, deliveryId, ack };
1484
+ // ACK BEFORE the handlers, not after. The ACK means "the SDK durably
1485
+ // received this", never "the application finished processing it" — the
1486
+ // two were conflated here and it broke real chat twice over:
1487
+ //
1488
+ // 1. A peer whose handler was slow (beagle-help awaiting a 10s+ model
1489
+ // call) sent its ACK 10s late — inside sendText's 15s auto window —
1490
+ // so senders got "ACK timed out" for messages that were delivered
1491
+ // AND answered. The UI then dropped the sender's own message as
1492
+ // failed: "对方在回答一个看不见的问题".
1493
+ // 2. The delivery id was only registered after the handlers returned,
1494
+ // so the sender's 5s retransmits arriving DURING a slow handler
1495
+ // re-dispatched the same message — the busy peer processed it again
1496
+ // (and hit its model again), making the overload worse.
1497
+ //
1498
+ // Registering + ACKing up front fixes both: retries hit the dedupe path
1499
+ // and re-ACK, and a busy application can no longer turn into a transport
1500
+ // failure. A handler that needs "processed" semantics must build its own
1501
+ // application-level reply — that is what an answer message is.
1502
+ if (deliveryId) {
1503
+ this.#rememberDeliveredTextId(deliveryId);
1504
+ void ack?.();
1505
+ }
1444
1506
  try {
1445
1507
  for (const handler of this.#textHandlers) {
1446
1508
  await handler(delivered);
@@ -1448,13 +1510,14 @@ export class Peer {
1448
1510
  }
1449
1511
  catch (error) {
1450
1512
  this.#debugLog(`text handler failed for ${msg.pubkey}: ${error.message}`);
1451
- return;
1452
- }
1453
- if (deliveryId) {
1454
- this.#rememberDeliveredTextId(deliveryId);
1455
- await ack?.();
1456
1513
  }
1457
1514
  }
1515
+ /** @internal Test seam: deliver a text message exactly as if it arrived off
1516
+ * the wire. Exists so the ACK-before-handlers ordering is testable without
1517
+ * a network — do not use outside tests. */
1518
+ _testDispatchTextMessage(msg) {
1519
+ return this.#dispatchTextMessage(msg);
1520
+ }
1458
1521
  async #sendTextAck(pubkey, deliveryId) {
1459
1522
  try {
1460
1523
  await this.sendText(pubkey, encodeTextAckEnvelope({ t: "ack", id: deliveryId }));
package/dist/turn.js CHANGED
@@ -92,12 +92,9 @@ export class TurnClient {
92
92
  this.#nonce = nonceAttr;
93
93
  this.#integrityKey = longTermIntegrityKey(this.creds.username, this.#realm, this.creds.password);
94
94
  // Step 2: re-send ALLOCATE authenticated
95
- const authed = await this.#request(TURN_ALLOCATE_REQUEST, [
95
+ const authed = await this.#authedRequest(TURN_ALLOCATE_REQUEST, TURN_ALLOCATE_SUCCESS, [
96
96
  { type: STUN_ATTR_REQUESTED_TRANSPORT, value: Uint8Array.of(17, 0, 0, 0) },
97
- { type: STUN_ATTR_SOFTWARE, value: Buffer.from(SOFTWARE_NAME, "utf8") },
98
- { type: STUN_ATTR_USERNAME, value: Buffer.from(this.creds.username, "utf8") },
99
- { type: STUN_ATTR_REALM, value: Buffer.from(this.#realm, "utf8") },
100
- { type: STUN_ATTR_NONCE, value: this.#nonce }
97
+ { type: STUN_ATTR_SOFTWARE, value: Buffer.from(SOFTWARE_NAME, "utf8") }
101
98
  ]);
102
99
  if (authed.type !== TURN_ALLOCATE_SUCCESS) {
103
100
  const err = findAttr(authed, STUN_ATTR_ERROR_CODE);
@@ -117,11 +114,8 @@ export class TurnClient {
117
114
  if (!this.#integrityKey || !this.#nonce) {
118
115
  throw new Error("createPermission called before allocate()");
119
116
  }
120
- const resp = await this.#request(TURN_CREATE_PERMISSION_REQUEST, [
121
- { type: STUN_ATTR_XOR_PEER_ADDRESS, value: encodeXorMappedAddress(peer, newTransactionId()) },
122
- { type: STUN_ATTR_USERNAME, value: Buffer.from(this.creds.username, "utf8") },
123
- { type: STUN_ATTR_REALM, value: Buffer.from(this.#realm, "utf8") },
124
- { type: STUN_ATTR_NONCE, value: this.#nonce }
117
+ const resp = await this.#authedRequest(TURN_CREATE_PERMISSION_REQUEST, TURN_CREATE_PERMISSION_SUCCESS, [
118
+ { type: STUN_ATTR_XOR_PEER_ADDRESS, value: encodeXorMappedAddress(peer, newTransactionId()) }
125
119
  ]);
126
120
  if (resp.type !== TURN_CREATE_PERMISSION_SUCCESS) {
127
121
  const err = findAttr(resp, STUN_ATTR_ERROR_CODE);
@@ -152,14 +146,12 @@ export class TurnClient {
152
146
  if (!this.#integrityKey || !this.#nonce) {
153
147
  throw new Error("refresh called before allocate()");
154
148
  }
155
- const resp = await this.#request(TURN_REFRESH_REQUEST, [
156
- { type: STUN_ATTR_LIFETIME, value: u32Bytes(600) },
157
- { type: STUN_ATTR_USERNAME, value: Buffer.from(this.creds.username, "utf8") },
158
- { type: STUN_ATTR_REALM, value: Buffer.from(this.#realm, "utf8") },
159
- { type: STUN_ATTR_NONCE, value: this.#nonce }
149
+ const resp = await this.#authedRequest(TURN_REFRESH_REQUEST, TURN_REFRESH_SUCCESS, [
150
+ { type: STUN_ATTR_LIFETIME, value: u32Bytes(600) }
160
151
  ]);
161
152
  if (resp.type !== TURN_REFRESH_SUCCESS) {
162
- // Stale nonce server may have rotated. Re-allocate on next call.
153
+ // A genuine failure (438 was already retried with the fresh nonce by
154
+ // #authedRequest). Drop the allocation so the next caller re-allocates.
163
155
  this.#allocation = undefined;
164
156
  const err = findAttr(resp, STUN_ATTR_ERROR_CODE);
165
157
  const decoded = err ? decodeErrorCode(err) : undefined;
@@ -206,6 +198,48 @@ export class TurnClient {
206
198
  resolve(msg);
207
199
  }
208
200
  };
201
+ /**
202
+ * Send an authenticated request, honoring nonce rotation.
203
+ *
204
+ * 438 (Stale Nonce) is NOT a failure — it is the protocol's way of rotating
205
+ * the nonce (RFC 5766 §4.3 / RFC 8489 §9.2): the error response carries the
206
+ * NEW nonce (and possibly a new realm), and the client is expected to adopt
207
+ * it and re-send the same request. coturn rotates every few hours; treating
208
+ * the 438 as fatal tore down a healthy relay on every rotation and left the
209
+ * refresh loop dead until the next #ensureTurnRelay (INBOX 2026-08-05).
210
+ *
211
+ * One retry only: a server that answers the fresh nonce with another 438 is
212
+ * genuinely refusing us, and looping would spin.
213
+ */
214
+ async #authedRequest(type, successType, attrs) {
215
+ const build = () => [
216
+ ...attrs,
217
+ { type: STUN_ATTR_USERNAME, value: Buffer.from(this.creds.username, "utf8") },
218
+ { type: STUN_ATTR_REALM, value: Buffer.from(this.#realm, "utf8") },
219
+ { type: STUN_ATTR_NONCE, value: this.#nonce }
220
+ ];
221
+ const resp = await this.#request(type, build());
222
+ if (resp.type === successType)
223
+ return resp;
224
+ const err = findAttr(resp, STUN_ATTR_ERROR_CODE);
225
+ const decoded = err ? decodeErrorCode(err) : undefined;
226
+ if (decoded?.code !== 438)
227
+ return resp;
228
+ const newNonce = findAttr(resp, STUN_ATTR_NONCE);
229
+ if (!newNonce)
230
+ return resp; // malformed 438 — nothing to retry with
231
+ this.#nonce = newNonce;
232
+ const newRealm = findAttr(resp, STUN_ATTR_REALM);
233
+ if (newRealm) {
234
+ const realm = Buffer.from(newRealm).toString("utf8");
235
+ if (realm !== this.#realm) {
236
+ // Realm change means the integrity key derives differently too.
237
+ this.#realm = realm;
238
+ this.#integrityKey = longTermIntegrityKey(this.creds.username, this.#realm, this.creds.password);
239
+ }
240
+ }
241
+ return this.#request(type, build());
242
+ }
209
243
  async #request(type, attrs) {
210
244
  const txn = newTransactionId();
211
245
  const key = Buffer.from(txn).toString("hex");
@@ -125,9 +125,10 @@ export type TextMessage = {
125
125
  /** Stable id for SDK-level acknowledged delivery. Present only for messages
126
126
  * sent with sendTextUntilAck(). Receivers can persist this id for dedupe. */
127
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. */
128
+ /** Re-send the delivery ACK. The SDK ACKs automatically BEFORE handlers run
129
+ * the ACK means "SDK received", never "application processed", so a slow
130
+ * handler (model call, disk job) cannot fake a delivery failure at the
131
+ * sender. Kept on the message for idempotent manual re-ACKs only. */
131
132
  ack?: () => Promise<void>;
132
133
  /** Delivery path: "online" = live net_crypto session (direct/relay), "offline"
133
134
  * = express store-and-forward. Lets the UI color the two differently so a user
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/peer",
3
- "version": "0.1.135",
3
+ "version": "0.1.137",
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",