@decentnetwork/peer 0.1.138 → 0.1.140

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,39 @@
1
+ /**
2
+ * Inline files over the Carrier (bulk)message channel — the only file path the
3
+ * native Beagle apps can see online (toxcore's file transfer 80-82 is invisible
4
+ * to the Carrier C SDK).
5
+ *
6
+ * There are TWO envelopes in the wild:
7
+ *
8
+ * - iOS / Beagle group chats: a FileModel JSON envelope, base64 payload —
9
+ * {"data":"<base64>","fileExtension":".jpg","fileName":"…","type":"image"}
10
+ * (handled as text in peer.ts, since it is valid UTF-8).
11
+ *
12
+ * - Android 1:1 chats (CarrierFileHelper.packFilePayload): a BINARY envelope
13
+ * with a big-endian length prefix, no base64 —
14
+ *
15
+ * [uint32 BE metaLen][meta JSON][raw file bytes]
16
+ * meta = {"type":"file","filename":…,"contentType":…,"size":N}
17
+ *
18
+ * The body is real binary, so it never survives a UTF-8 decode: it has to be
19
+ * recognised on the raw bytes, or the file is shredded into U+FFFD mojibake.
20
+ */
21
+ /** Metadata Android puts in front of the bytes. */
22
+ export type AndroidFileEnvelope = {
23
+ name: string;
24
+ contentType: string;
25
+ data: Uint8Array;
26
+ /** `size` as declared in the header — may disagree with data.length on a
27
+ * truncated payload, which the caller can report. */
28
+ declaredSize?: number;
29
+ };
30
+ /**
31
+ * Decode Android's binary file envelope. Returns undefined for anything that
32
+ * isn't one (ordinary chat text included) — never throws.
33
+ */
34
+ export declare function decodeAndroidFileEnvelope(bytes: Uint8Array): AndroidFileEnvelope | undefined;
35
+ /** Build the same envelope Android builds, byte for byte (key order included). */
36
+ export declare function encodeAndroidFileEnvelope(name: string, data: Uint8Array, contentType?: string): Uint8Array;
37
+ /** Classify an inline file the way the Beagle apps do — by MIME type when the
38
+ * sender bothered to set a real one, otherwise by extension. */
39
+ export declare function inlineFileTypeFor(name: string, contentType?: string): "image" | "audio" | "text" | "unknown";
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Inline files over the Carrier (bulk)message channel — the only file path the
3
+ * native Beagle apps can see online (toxcore's file transfer 80-82 is invisible
4
+ * to the Carrier C SDK).
5
+ *
6
+ * There are TWO envelopes in the wild:
7
+ *
8
+ * - iOS / Beagle group chats: a FileModel JSON envelope, base64 payload —
9
+ * {"data":"<base64>","fileExtension":".jpg","fileName":"…","type":"image"}
10
+ * (handled as text in peer.ts, since it is valid UTF-8).
11
+ *
12
+ * - Android 1:1 chats (CarrierFileHelper.packFilePayload): a BINARY envelope
13
+ * with a big-endian length prefix, no base64 —
14
+ *
15
+ * [uint32 BE metaLen][meta JSON][raw file bytes]
16
+ * meta = {"type":"file","filename":…,"contentType":…,"size":N}
17
+ *
18
+ * The body is real binary, so it never survives a UTF-8 decode: it has to be
19
+ * recognised on the raw bytes, or the file is shredded into U+FFFD mojibake.
20
+ */
21
+ /** Android's own unpackPayload() bounds, mirrored so we accept exactly what it
22
+ * sends and reject anything else cheaply. */
23
+ const MAX_META_LEN = 4096;
24
+ /**
25
+ * Decode Android's binary file envelope. Returns undefined for anything that
26
+ * isn't one (ordinary chat text included) — never throws.
27
+ */
28
+ export function decodeAndroidFileEnvelope(bytes) {
29
+ // A 4-byte big-endian length below 4096 always starts with two zero bytes,
30
+ // which no chat text ever does — cheap reject before touching the JSON.
31
+ if (bytes.length < 6 || bytes[0] !== 0 || bytes[1] !== 0)
32
+ return undefined;
33
+ const metaLen = ((bytes[2] << 8) | bytes[3]) >>> 0;
34
+ if (metaLen < 2 || metaLen > MAX_META_LEN || 4 + metaLen > bytes.length)
35
+ return undefined;
36
+ try {
37
+ const json = new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(4, 4 + metaLen));
38
+ const meta = JSON.parse(json);
39
+ if (meta.type !== "file" || typeof meta.filename !== "string" || meta.filename.length === 0)
40
+ return undefined;
41
+ return {
42
+ name: meta.filename,
43
+ contentType: typeof meta.contentType === "string" ? meta.contentType : "application/octet-stream",
44
+ data: bytes.slice(4 + metaLen),
45
+ declaredSize: typeof meta.size === "number" ? meta.size : undefined
46
+ };
47
+ }
48
+ catch {
49
+ return undefined;
50
+ }
51
+ }
52
+ /** Build the same envelope Android builds, byte for byte (key order included). */
53
+ export function encodeAndroidFileEnvelope(name, data, contentType = "application/octet-stream") {
54
+ const meta = new TextEncoder().encode(JSON.stringify({ type: "file", filename: name, contentType, size: data.length }));
55
+ const out = new Uint8Array(4 + meta.length + data.length);
56
+ new DataView(out.buffer).setUint32(0, meta.length, false);
57
+ out.set(meta, 4);
58
+ out.set(data, 4 + meta.length);
59
+ return out;
60
+ }
61
+ /** Classify an inline file the way the Beagle apps do — by MIME type when the
62
+ * sender bothered to set a real one, otherwise by extension. */
63
+ export function inlineFileTypeFor(name, contentType) {
64
+ const mime = (contentType ?? "").toLowerCase();
65
+ if (mime.startsWith("image/"))
66
+ return "image";
67
+ if (mime.startsWith("audio/"))
68
+ return "audio";
69
+ if (mime.startsWith("text/"))
70
+ return "text";
71
+ const dot = name.lastIndexOf(".");
72
+ const ext = dot > 0 ? name.slice(dot).toLowerCase() : "";
73
+ if ([".png", ".jpg", ".jpeg", ".gif", ".webp", ".heic", ".heif", ".bmp"].includes(ext))
74
+ return "image";
75
+ if ([".m4a", ".mp3", ".aac", ".wav", ".ogg"].includes(ext))
76
+ return "audio";
77
+ if ([".txt", ".md", ".log"].includes(ext))
78
+ return "text";
79
+ return "unknown";
80
+ }
package/dist/index.d.ts CHANGED
@@ -14,4 +14,4 @@ export { LegacyProtocolNotImplementedError } from "./runtime/errors.js";
14
14
  export type { CarrierPacket, FriendMessagePacket, FriendRequestPacket, InviteReqPacket, InviteRspPacket } from "./compat/packet.js";
15
15
  export type { ToxDhtCryptoRequest } from "./compat/tox-dht-crypto.js";
16
16
  export type { CarrierAddressParts } from "./compat/address.js";
17
- export type { CompatibilityMode, CustomPacketEvent, FriendConnectionEvent, FriendConnectionStatus, FriendInfoEvent, FriendRequest, InlineFileEvent, InviteEvent, InviteResponseEvent, LookupResult, NetworkNode, PeerOptions, SendTextUntilAckOptions, TextMessage } from "./types/peer.js";
17
+ export type { CompatibilityMode, CustomPacketEvent, FriendConnectionEvent, FriendConnectionStatus, FriendInfoEvent, FriendRequest, InlineFileEvent, InlineSendResult, InviteEvent, InviteResponseEvent, LookupResult, NetworkNode, PeerOptions, SendTextUntilAckOptions, TextMessage } from "./types/peer.js";
package/dist/peer.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { type BootstrapResult } from "./compat/bootstrap.js";
2
2
  import type { FriendRecord } from "./store/friends.js";
3
3
  import { type CarrierTurnServerInfo, type RtcIceServer } from "./ice-servers.js";
4
- import type { CustomPacketEvent, FriendConnectionEvent, FriendRequest, FriendInfoEvent, InlineFileEvent, InviteEvent, InviteResponseEvent, LookupResult, NetworkNode, PeerOptions, SendTextUntilAckOptions, TextMessage } from "./types/peer.js";
4
+ import type { CustomPacketEvent, FriendConnectionEvent, FriendRequest, FriendInfoEvent, InlineFileEvent, InlineSendResult, InviteEvent, InviteResponseEvent, LookupResult, NetworkNode, PeerOptions, SendTextUntilAckOptions, TextMessage } from "./types/peer.js";
5
5
  export declare class Peer {
6
6
  #private;
7
7
  private constructor();
@@ -140,7 +140,16 @@ export declare class Peer {
140
140
  name: string;
141
141
  data: Uint8Array;
142
142
  fileType?: "image" | "audio" | "text" | "unknown";
143
- }): Promise<void>;
143
+ deliveryTimeoutMs?: number;
144
+ }): Promise<InlineSendResult>;
145
+ /**
146
+ * True when this friend is a NATIVE Carrier client (iOS/Android/C SDK): its
147
+ * DHT/relay key differs from its identity key, where a JS peer announces its
148
+ * identity key as its DHT key. Natives cannot see the toxcore file-transfer
149
+ * protocol (sendFile) — the inline envelope is their only file path — and
150
+ * they never speak the JS text-ACK scheme.
151
+ */
152
+ isNativeFriend(pubkey: string): boolean;
144
153
  /** Fired when a peer sends a Carrier friend-invite (PACKET_TYPE_INVITE_REQUEST).
145
154
  * This is the channel the iOS/Android WebRTC SDK uses for call signaling —
146
155
  * each RtcSignal arrives as an "invite" with ext="carrier" and `data` the
package/dist/peer.js CHANGED
@@ -7,6 +7,7 @@ import { dirname, join } from "node:path";
7
7
  import nacl from "tweetnacl";
8
8
  import { carrierAddressFromPublicKey, carrierIdFromAddress, carrierIdFromPublicKey, parseCarrierAddress } from "./compat/address.js";
9
9
  import { LegacyBootstrapClient } from "./compat/bootstrap.js";
10
+ import { decodeAndroidFileEnvelope, inlineFileTypeFor } from "./compat/inline-file.js";
10
11
  import { PACKET_TYPE_MESSAGE, PACKET_TYPE_FRIEND_REQUEST, PACKET_TYPE_USERINFO, PACKET_TYPE_BULKMSG, PACKET_TYPE_INVITE_REQUEST, PACKET_TYPE_INVITE_RESPONSE, CARRIER_MAX_APP_MESSAGE_LEN, CARRIER_MAX_APP_BULKMSG_LEN, INVITE_DATA_UNIT, CARRIER_MAX_INVITE_DATA_LEN, CARRIER_EXTENSION_NAME, encodeUserInfoPacket, decodeCarrierPacket, encodeFriendMessagePacket, encodeFriendRequestPacket, encodeBulkMsgPacket, encodeInviteReqPacket, encodeRetransmitRequest } from "./compat/packet.js";
11
12
  import { NET_PACKET_ONION_ANNOUNCE_RESPONSE, NET_PACKET_ONION_DATA_RESPONSE, ONION_FRIEND_REQUEST_ID, createOnionAnnounceRequest, createOnionDataPacket, createOnionDataRequest, createOnionRequest0, createOnionRequest0Tcp, openOnionAnnounceResponse, openOnionDataPacket, openOnionDataResponse } from "./compat/tox-onion.js";
12
13
  import { CRYPTO_PACKET_DHTPK, CRYPTO_PACKET_FRIEND_REQ, NET_PACKET_CRYPTO, createToxDhtCryptoRequest, openToxDhtCryptoRequest } from "./compat/tox-dht-crypto.js";
@@ -1239,6 +1240,9 @@ export class Peer {
1239
1240
  }
1240
1241
  await this.#sendTextPlain(pubkey, text);
1241
1242
  }
1243
+ /** What #sendTextPlain actually did with the message — callers that promise
1244
+ * delivery semantics (inline files) need to know the path and, for the live
1245
+ * path, where the send window stood after the last fragment. */
1242
1246
  async #sendTextPlain(pubkey, text) {
1243
1247
  const friend = this.#friends.get(pubkey);
1244
1248
  if (!friend) {
@@ -1306,7 +1310,10 @@ export class Peer {
1306
1310
  for (const p of livePackets) {
1307
1311
  await this.#sendMessengerPacket(pubkey, PACKET_ID_MESSAGE, p);
1308
1312
  }
1309
- return;
1313
+ // Every fragment got a packet number BELOW the counter's position now —
1314
+ // when the peer's implicit acks advance the window past it, the whole
1315
+ // message is confirmed on their device.
1316
+ return { via: "online", endPacketNumber: this.#friendSessions.get(pubkey)?.sendPacketNumber };
1310
1317
  }
1311
1318
  catch (error) {
1312
1319
  const emsg = error.message;
@@ -1342,7 +1349,7 @@ export class Peer {
1342
1349
  // no session is simply a no-op.
1343
1350
  if (text.length === 0) {
1344
1351
  this.#debugLog(`sendText: empty connection-kick to ${pubkey} with no live session — dropping (not flooding express)`);
1345
- return;
1352
+ return { via: "online" };
1346
1353
  }
1347
1354
  // Offline / fallback path via Carrier express HTTP store-and-forward.
1348
1355
  // Skipped entirely in control-plane-only mode (decentlan's data plane):
@@ -1352,7 +1359,7 @@ export class Peer {
1352
1359
  if (this.#express?.hasNodes() && !this.#opts.expressControlPlaneOnly) {
1353
1360
  await this.#express.sendOfflineText(pubkey, packet);
1354
1361
  this.#debugLog(`sendText: queued via express for ${pubkey}`);
1355
- return;
1362
+ return { via: "offline" };
1356
1363
  }
1357
1364
  throw new Error("friend is offline and no express node is configured");
1358
1365
  }
@@ -1555,9 +1562,7 @@ export class Peer {
1555
1562
  const dot = opts.name.lastIndexOf(".");
1556
1563
  const ext = dot > 0 ? opts.name.slice(dot).toLowerCase() : "";
1557
1564
  const base = dot > 0 ? opts.name.slice(0, dot) : opts.name;
1558
- const type = opts.fileType ?? ([".png", ".jpg", ".jpeg", ".gif", ".webp", ".heic"].includes(ext) ? "image" :
1559
- [".m4a", ".mp3", ".aac", ".wav", ".ogg"].includes(ext) ? "audio" :
1560
- [".txt", ".md", ".log"].includes(ext) ? "text" : "unknown");
1565
+ const type = opts.fileType ?? inlineFileTypeFor(opts.name);
1561
1566
  // Key order matters for byte-identical envelopes with iOS (JSONEncoder
1562
1567
  // .sortedKeys): data, fileExtension, fileName, type — alphabetical.
1563
1568
  const envelope = JSON.stringify({
@@ -1569,7 +1574,77 @@ export class Peer {
1569
1574
  if (envelope.length > CARRIER_MAX_APP_BULKMSG_LEN) {
1570
1575
  throw new Error(`inline file too large for the message channel (${envelope.length} bytes encoded, max ${CARRIER_MAX_APP_BULKMSG_LEN})`);
1571
1576
  }
1572
- await this.sendText(pubkey, envelope);
1577
+ // The confirmation window scales with the payload: a floor for handshake
1578
+ // latency plus time to move the envelope at a conservative 50 KB/s, capped
1579
+ // so a dead session can't pin the caller for ages.
1580
+ const deliveryTimeoutMs = opts.deliveryTimeoutMs ??
1581
+ Math.min(180_000, 20_000 + Math.ceil(envelope.length / 50));
1582
+ if (this.#shouldRequireTextAck(pubkey)) {
1583
+ // JS peer: the app-level text-ACK is the strongest confirmation there is
1584
+ // — the receiving app parsed the envelope. The retry interval must be
1585
+ // longer than one full send of the payload, or a slow path gets the
1586
+ // whole multi-MB envelope re-blasted mid-flight.
1587
+ await this.sendTextUntilAck(pubkey, envelope, {
1588
+ timeoutMs: deliveryTimeoutMs,
1589
+ retryIntervalMs: Math.max(TEXT_ACK_RETRY_MS, Math.ceil(envelope.length / 100))
1590
+ });
1591
+ return { delivery: "acked" };
1592
+ }
1593
+ // Native peer (iOS/Android/C): it will never speak our text-ACK envelope,
1594
+ // but its toxcore DOES acknowledge every reliable packet — the very signal
1595
+ // its own SDK surfaces to the app as "Delivered". Wait for the send window
1596
+ // to drain past our fragments.
1597
+ const outcome = await this.#sendTextPlain(pubkey, envelope);
1598
+ if (outcome.via === "offline")
1599
+ return { delivery: "offline" };
1600
+ if (outcome.endPacketNumber === undefined)
1601
+ return { delivery: "accepted" };
1602
+ const acked = await this.#awaitTransportAck(pubkey, outcome.endPacketNumber, deliveryTimeoutMs);
1603
+ this.#debugLog(`inline file "${opts.name}" to ${pubkey}: transport ${acked ? "ACKED" : `NOT confirmed within ${deliveryTimeoutMs}ms`}`);
1604
+ return { delivery: acked ? "acked" : "accepted" };
1605
+ }
1606
+ /**
1607
+ * True once the friend's implicit acks (PACKET_ID_REQUEST walks) have moved
1608
+ * our reliable send window past `endPacketNumber` — i.e. every packet we had
1609
+ * sent by that point is confirmed received on their device. False when the
1610
+ * window doesn't drain in time or the session dies/rekeys under us (a new
1611
+ * session restarts the numbering, so the old position proves nothing).
1612
+ */
1613
+ async #awaitTransportAck(pubkey, endPacketNumber, timeoutMs) {
1614
+ const watched = this.#friendSessions.get(pubkey);
1615
+ if (!watched)
1616
+ return false;
1617
+ const deadline = Date.now() + timeoutMs;
1618
+ for (;;) {
1619
+ const session = this.#friendSessions.get(pubkey);
1620
+ if (session !== watched || !session.established)
1621
+ return false;
1622
+ const start = session.sendBufferStartNum;
1623
+ if (start !== undefined) {
1624
+ // Wraparound-safe: the window is ≤8192 packets, so a "distance" over
1625
+ // 2^31 means start has passed endPacketNumber.
1626
+ const pending = (endPacketNumber - start) >>> 0;
1627
+ if (pending === 0 || pending > 0x80000000)
1628
+ return true;
1629
+ }
1630
+ else if (!session.sendArray || session.sendArray.size === 0) {
1631
+ return true; // nothing left unacknowledged at all
1632
+ }
1633
+ if (Date.now() >= deadline)
1634
+ return false;
1635
+ await sleep(250);
1636
+ }
1637
+ }
1638
+ /**
1639
+ * True when this friend is a NATIVE Carrier client (iOS/Android/C SDK): its
1640
+ * DHT/relay key differs from its identity key, where a JS peer announces its
1641
+ * identity key as its DHT key. Natives cannot see the toxcore file-transfer
1642
+ * protocol (sendFile) — the inline envelope is their only file path — and
1643
+ * they never speak the JS text-ACK scheme.
1644
+ */
1645
+ isNativeFriend(pubkey) {
1646
+ const friend = this.#friends.get(pubkey);
1647
+ return !!(friend?.dhtPubkey && friend.dhtPubkey !== friend.pubkey);
1573
1648
  }
1574
1649
  /** Fired when a peer sends a Carrier friend-invite (PACKET_TYPE_INVITE_REQUEST).
1575
1650
  * This is the channel the iOS/Android WebRTC SDK uses for call signaling —
@@ -3013,7 +3088,11 @@ export class Peer {
3013
3088
  }
3014
3089
  }
3015
3090
  // Offline images/audio arrive as the same FileModel JSON envelope
3016
- // (iOS posts the whole thing as one express MESSAGE packet).
3091
+ // (iOS posts the whole thing as one express MESSAGE packet), or as
3092
+ // Android's binary [len][meta][bytes] envelope (ExpressNodeClient uses
3093
+ // the very same packFilePayload as the online path).
3094
+ if (this.#tryEmitBinaryInlineFile(fromUserId, decoded.data, "offline"))
3095
+ return;
3017
3096
  if (this.#tryEmitInlineFile(fromUserId, text, "offline"))
3018
3097
  return;
3019
3098
  void this.#dispatchTextMessage({ pubkey: fromUserId, text, via: "offline" });
@@ -4222,19 +4301,27 @@ export class Peer {
4222
4301
  this.#debugLog(`invite-response from ${friendId}: status=${carrier.status} ext="${carrier.ext ?? ""}"`);
4223
4302
  return;
4224
4303
  }
4304
+ // The message body as it arrived, still undecoded. Android Beagle sends
4305
+ // files as a BINARY envelope (see #tryEmitBinaryInlineFile), and raw file
4306
+ // bytes do not survive a UTF-8 decode — so the sniff has to run here, on
4307
+ // the bytes, before anything turns them into a string.
4308
+ let raw;
4225
4309
  if (carrier?.type === PACKET_TYPE_BULKMSG) {
4226
4310
  const complete = this.#assembleBulkMsg(friendId, carrier, packetNumber);
4227
4311
  if (!complete)
4228
4312
  return; // more fragments pending
4229
- text = decodeUtf8Best(complete);
4313
+ raw = complete;
4230
4314
  this.#debugLog(`bulkmsg complete from ${friendId} (${complete.length} bytes)`);
4231
4315
  }
4232
4316
  else if (carrier?.type === PACKET_TYPE_MESSAGE) {
4233
- text = new TextDecoder().decode(carrier.data);
4317
+ raw = carrier.data;
4234
4318
  }
4235
4319
  else {
4236
- text = decodeUtf8Best(inner);
4320
+ raw = inner;
4237
4321
  }
4322
+ if (this.#tryEmitBinaryInlineFile(friendId, raw, "online"))
4323
+ return;
4324
+ text = decodeUtf8Best(raw);
4238
4325
  // Carrier C peers terminate strings with a NUL — strip it so chat
4239
4326
  // text doesn't carry an invisible trailing byte.
4240
4327
  text = text?.replace(/\0+$/u, "");
@@ -4480,6 +4567,28 @@ export class Peer {
4480
4567
  return false;
4481
4568
  }
4482
4569
  }
4570
+ /** Android Beagle sends files as a BINARY envelope over the (bulk)message
4571
+ * channel — [uint32 BE metaLen][meta JSON][raw bytes], no base64. Raw file
4572
+ * bytes do not survive a UTF-8 decode, so this sniff runs on the UNDECODED
4573
+ * payload; before it existed such a message landed in the chat as a wall of
4574
+ * U+FFFD mojibake with the file lost. Returns true when handled. */
4575
+ #tryEmitBinaryInlineFile(friendId, bytes, via) {
4576
+ const envelope = decodeAndroidFileEnvelope(bytes);
4577
+ if (!envelope)
4578
+ return false;
4579
+ if (envelope.declaredSize !== undefined && envelope.declaredSize !== envelope.data.length) {
4580
+ this.#debugLog(`inline file from ${friendId}: declared ${envelope.declaredSize}B, got ${envelope.data.length}B`);
4581
+ }
4582
+ this.#events.emit("inlineFile", {
4583
+ pubkey: friendId,
4584
+ name: envelope.name,
4585
+ fileType: inlineFileTypeFor(envelope.name, envelope.contentType),
4586
+ data: envelope.data,
4587
+ via
4588
+ });
4589
+ this.#debugLog(`inline file (Android envelope) from ${friendId}: "${envelope.name}" (${envelope.data.length} bytes, ${via})`);
4590
+ return true;
4591
+ }
4483
4592
  /** Toxcore handle_request_packet: parse the peer's PACKET_ID_REQUEST and
4484
4593
  * (a) implicitly ACK every walked packet number that isn't requested,
4485
4594
  * (b) retransmit the requested ones from the send buffer. */
@@ -135,6 +135,23 @@ export type TextMessage = {
135
135
  * can see when online delivery is failing and only offline messages land. */
136
136
  via?: "online" | "offline";
137
137
  };
138
+ /**
139
+ * Outcome of an inline-file send — the caller's ONLY honest basis for a "sent"
140
+ * checkmark. "Bytes left this machine" is not delivery.
141
+ */
142
+ export type InlineSendResult = {
143
+ /**
144
+ * - "acked": the peer CONFIRMED receipt — an app-level ACK from a JS peer,
145
+ * or the toxcore transport ACK from a native one (the same signal Android
146
+ * surfaces as ReceiptState.Delivered). Safe to mark "sent".
147
+ * - "accepted": the transport took the bytes but no confirmation arrived in
148
+ * the window. NOT delivered as far as anyone can prove — mark it failed or
149
+ * queued, never "sent".
150
+ * - "offline": stored on the express relay for the peer's next pull. Mark
151
+ * it queued.
152
+ */
153
+ delivery: "acked" | "accepted" | "offline";
154
+ };
138
155
  export type SendTextUntilAckOptions = {
139
156
  /** Stable id for retries across a caller-managed outbox. Defaults to a random id. */
140
157
  deliveryId?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/peer",
3
- "version": "0.1.138",
3
+ "version": "0.1.140",
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",
@@ -74,7 +74,8 @@
74
74
  "test:tcp-onion": "pnpm run build && node scripts/tcp-onion-selftest.mjs",
75
75
  "smoke:join": "node scripts/smoke-join.mjs",
76
76
  "typecheck": "tsc -p tsconfig.json --noEmit",
77
- "test:express": "pnpm run build && node scripts/express-scheme-selftest.mjs"
77
+ "test:express": "pnpm run build && node scripts/express-scheme-selftest.mjs",
78
+ "test:inline-file": "pnpm run build && node scripts/inline-file-selftest.mjs"
78
79
  },
79
80
  "dependencies": {
80
81
  "flatbuffers": "^25.9.23",