@decentnetwork/peer 0.1.0

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.
Files changed (79) hide show
  1. package/LICENSE +31 -0
  2. package/README.md +97 -0
  3. package/dist/cli.d.ts +2 -0
  4. package/dist/cli.js +245 -0
  5. package/dist/compat/address.d.ts +13 -0
  6. package/dist/compat/address.js +69 -0
  7. package/dist/compat/bootstrap.d.ts +29 -0
  8. package/dist/compat/bootstrap.js +178 -0
  9. package/dist/compat/dht.d.ts +3 -0
  10. package/dist/compat/dht.js +9 -0
  11. package/dist/compat/express.d.ts +21 -0
  12. package/dist/compat/express.js +263 -0
  13. package/dist/compat/friend.d.ts +4 -0
  14. package/dist/compat/friend.js +12 -0
  15. package/dist/compat/net-crypto.d.ts +84 -0
  16. package/dist/compat/net-crypto.js +278 -0
  17. package/dist/compat/packet.d.ts +55 -0
  18. package/dist/compat/packet.js +154 -0
  19. package/dist/compat/session.d.ts +3 -0
  20. package/dist/compat/session.js +7 -0
  21. package/dist/compat/tcp-relay-pool.d.ts +85 -0
  22. package/dist/compat/tcp-relay-pool.js +342 -0
  23. package/dist/compat/tcp-relay.d.ts +96 -0
  24. package/dist/compat/tcp-relay.js +489 -0
  25. package/dist/compat/text.d.ts +3 -0
  26. package/dist/compat/text.js +8 -0
  27. package/dist/compat/tox-dht-crypto.d.ts +18 -0
  28. package/dist/compat/tox-dht-crypto.js +69 -0
  29. package/dist/compat/tox-onion.d.ts +66 -0
  30. package/dist/compat/tox-onion.js +172 -0
  31. package/dist/crypto/box.d.ts +1 -0
  32. package/dist/crypto/box.js +3 -0
  33. package/dist/crypto/keypair.d.ts +5 -0
  34. package/dist/crypto/keypair.js +37 -0
  35. package/dist/crypto/nonce.d.ts +1 -0
  36. package/dist/crypto/nonce.js +1 -0
  37. package/dist/crypto/sign.d.ts +1 -0
  38. package/dist/crypto/sign.js +3 -0
  39. package/dist/index.d.ts +10 -0
  40. package/dist/index.js +6 -0
  41. package/dist/peer.d.ts +45 -0
  42. package/dist/peer.js +3425 -0
  43. package/dist/runtime/errors.d.ts +3 -0
  44. package/dist/runtime/errors.js +6 -0
  45. package/dist/runtime/events.d.ts +4 -0
  46. package/dist/runtime/events.js +1 -0
  47. package/dist/runtime/lifecycle.d.ts +7 -0
  48. package/dist/runtime/lifecycle.js +12 -0
  49. package/dist/store/config.d.ts +2 -0
  50. package/dist/store/config.js +1 -0
  51. package/dist/store/friends.d.ts +13 -0
  52. package/dist/store/friends.js +1 -0
  53. package/dist/store/state.d.ts +3 -0
  54. package/dist/store/state.js +1 -0
  55. package/dist/transport/socket.d.ts +4 -0
  56. package/dist/transport/socket.js +1 -0
  57. package/dist/transport/tcp.d.ts +3 -0
  58. package/dist/transport/tcp.js +5 -0
  59. package/dist/transport/udp.d.ts +24 -0
  60. package/dist/transport/udp.js +90 -0
  61. package/dist/types/bootstrap.d.ts +2 -0
  62. package/dist/types/bootstrap.js +1 -0
  63. package/dist/types/dht.d.ts +3 -0
  64. package/dist/types/dht.js +1 -0
  65. package/dist/types/friend.d.ts +1 -0
  66. package/dist/types/friend.js +1 -0
  67. package/dist/types/message.d.ts +1 -0
  68. package/dist/types/message.js +1 -0
  69. package/dist/types/peer.d.ts +51 -0
  70. package/dist/types/peer.js +1 -0
  71. package/dist/types/session.d.ts +1 -0
  72. package/dist/types/session.js +1 -0
  73. package/dist/utils/base58.d.ts +2 -0
  74. package/dist/utils/base58.js +51 -0
  75. package/dist/utils/bytes.d.ts +4 -0
  76. package/dist/utils/bytes.js +31 -0
  77. package/docs/INSTALL.md +103 -0
  78. package/docs/USAGE_GUIDE.md +724 -0
  79. package/package.json +77 -0
@@ -0,0 +1,55 @@
1
+ export declare const PACKET_TYPE_USERINFO = 3;
2
+ export declare const PACKET_TYPE_FRIEND_REQUEST = 6;
3
+ export declare const PACKET_TYPE_MESSAGE = 33;
4
+ export type UserInfoPacket = {
5
+ type: typeof PACKET_TYPE_USERINFO;
6
+ avatar: boolean;
7
+ name: string;
8
+ descr: string;
9
+ phone: string;
10
+ gender: string;
11
+ email: string;
12
+ region: string;
13
+ };
14
+ export type FriendRequestPacket = {
15
+ type: typeof PACKET_TYPE_FRIEND_REQUEST;
16
+ name: string;
17
+ descr: string;
18
+ hello: string;
19
+ };
20
+ export type FriendMessagePacket = {
21
+ type: typeof PACKET_TYPE_MESSAGE;
22
+ ext?: string;
23
+ data: Uint8Array;
24
+ };
25
+ export type CarrierPacket = UserInfoPacket | FriendRequestPacket | FriendMessagePacket;
26
+ /**
27
+ * Encode a Carrier `userinfo` packet (FB type 3). The Carrier C SDK sends
28
+ * this every time the local user's name / status message / profile fields
29
+ * change, transported via toxcore's PACKET_ID_STATUSMESSAGE (49).
30
+ *
31
+ * Field layout per src/carrier/packet.fbs:
32
+ * field 0 = avatar : bool (default false)
33
+ * field 1 = name : string
34
+ * field 2 = descr : string
35
+ * field 3 = phone : string
36
+ * field 4 = gender : string
37
+ * field 5 = email : string
38
+ * field 6 = region : string
39
+ */
40
+ export declare function encodeUserInfoPacket(opts: {
41
+ avatar?: boolean;
42
+ name?: string;
43
+ descr?: string;
44
+ phone?: string;
45
+ gender?: string;
46
+ email?: string;
47
+ region?: string;
48
+ }): Uint8Array;
49
+ export declare function encodeFriendRequestPacket(opts: {
50
+ name?: string;
51
+ descr?: string;
52
+ hello?: string;
53
+ }): Uint8Array;
54
+ export declare function encodeFriendMessagePacket(data: Uint8Array | string, ext?: string): Uint8Array;
55
+ export declare function decodeCarrierPacket(bytes: Uint8Array): CarrierPacket;
@@ -0,0 +1,154 @@
1
+ import { Builder, ByteBuffer, Encoding } from "flatbuffers";
2
+ export const PACKET_TYPE_USERINFO = 3;
3
+ export const PACKET_TYPE_FRIEND_REQUEST = 6;
4
+ export const PACKET_TYPE_MESSAGE = 33;
5
+ // Body type indices into FlatBuffers `anybody` union (carrier/packet.fbs).
6
+ // The .fbs declares: userinfo, friendreq, friendmsg, invitereq, invitersp,
7
+ // bulkmsg — flatbuffers maps those to 1, 2, 3, 4, 5, 6 (NONE = 0).
8
+ const ANYBODY_USERINFO = 1;
9
+ const ANYBODY_FRIENDREQ = 2;
10
+ const ANYBODY_FRIENDMSG = 3;
11
+ /**
12
+ * Encode a Carrier `userinfo` packet (FB type 3). The Carrier C SDK sends
13
+ * this every time the local user's name / status message / profile fields
14
+ * change, transported via toxcore's PACKET_ID_STATUSMESSAGE (49).
15
+ *
16
+ * Field layout per src/carrier/packet.fbs:
17
+ * field 0 = avatar : bool (default false)
18
+ * field 1 = name : string
19
+ * field 2 = descr : string
20
+ * field 3 = phone : string
21
+ * field 4 = gender : string
22
+ * field 5 = email : string
23
+ * field 6 = region : string
24
+ */
25
+ export function encodeUserInfoPacket(opts) {
26
+ const builder = new Builder(256);
27
+ const name = builder.createString(opts.name ?? "");
28
+ const descr = builder.createString(opts.descr ?? "");
29
+ const phone = builder.createString(opts.phone ?? "");
30
+ const gender = builder.createString(opts.gender ?? "");
31
+ const email = builder.createString(opts.email ?? "");
32
+ const region = builder.createString(opts.region ?? "");
33
+ builder.startObject(7);
34
+ // Add high field numbers first so vtable reflects all slots.
35
+ builder.addFieldOffset(6, region, 0);
36
+ builder.addFieldOffset(5, email, 0);
37
+ builder.addFieldOffset(4, gender, 0);
38
+ builder.addFieldOffset(3, phone, 0);
39
+ builder.addFieldOffset(2, descr, 0);
40
+ builder.addFieldOffset(1, name, 0);
41
+ if (opts.avatar) {
42
+ builder.addFieldInt8(0, 1, 0);
43
+ }
44
+ const userinfo = builder.endObject();
45
+ return finishCarrierPacket(builder, PACKET_TYPE_USERINFO, ANYBODY_USERINFO, userinfo);
46
+ }
47
+ export function encodeFriendRequestPacket(opts) {
48
+ const builder = new Builder(256);
49
+ const name = builder.createString(opts.name ?? "");
50
+ const descr = builder.createString(opts.descr ?? "");
51
+ const hello = builder.createString(opts.hello ?? "");
52
+ builder.startObject(3);
53
+ builder.addFieldOffset(2, hello, 0);
54
+ builder.addFieldOffset(1, descr, 0);
55
+ builder.addFieldOffset(0, name, 0);
56
+ const friendReq = builder.endObject();
57
+ return finishCarrierPacket(builder, PACKET_TYPE_FRIEND_REQUEST, ANYBODY_FRIENDREQ, friendReq);
58
+ }
59
+ export function encodeFriendMessagePacket(data, ext) {
60
+ const builder = new Builder(256);
61
+ const messageData = typeof data === "string" ? new TextEncoder().encode(data) : data;
62
+ const msg = builder.createByteVector(messageData);
63
+ const extOffset = ext ? builder.createString(ext) : 0;
64
+ builder.startObject(2);
65
+ builder.addFieldOffset(1, msg, 0);
66
+ if (extOffset) {
67
+ builder.addFieldOffset(0, extOffset, 0);
68
+ }
69
+ const friendMsg = builder.endObject();
70
+ return finishCarrierPacket(builder, PACKET_TYPE_MESSAGE, ANYBODY_FRIENDMSG, friendMsg);
71
+ }
72
+ export function decodeCarrierPacket(bytes) {
73
+ const bb = new ByteBuffer(bytes);
74
+ const root = bb.readInt32(bb.position()) + bb.position();
75
+ const packet = { bb, bb_pos: root };
76
+ const type = readUint8Field(packet, 0);
77
+ const bodyType = readUint8Field(packet, 1);
78
+ const body = readTableField(packet, 2);
79
+ if (!body) {
80
+ throw new Error("carrier packet body is missing");
81
+ }
82
+ if (type === PACKET_TYPE_USERINFO && bodyType === ANYBODY_USERINFO) {
83
+ return {
84
+ type,
85
+ avatar: readBoolField(body, 0),
86
+ name: readStringField(body, 1) ?? "",
87
+ descr: readStringField(body, 2) ?? "",
88
+ phone: readStringField(body, 3) ?? "",
89
+ gender: readStringField(body, 4) ?? "",
90
+ email: readStringField(body, 5) ?? "",
91
+ region: readStringField(body, 6) ?? ""
92
+ };
93
+ }
94
+ if (type === PACKET_TYPE_FRIEND_REQUEST && bodyType === ANYBODY_FRIENDREQ) {
95
+ return {
96
+ type,
97
+ name: readStringField(body, 0) ?? "",
98
+ descr: readStringField(body, 1) ?? "",
99
+ hello: readStringField(body, 2) ?? ""
100
+ };
101
+ }
102
+ if (type === PACKET_TYPE_MESSAGE && bodyType === ANYBODY_FRIENDMSG) {
103
+ return {
104
+ type,
105
+ ext: readStringField(body, 0) ?? undefined,
106
+ data: readByteVectorField(body, 1) ?? new Uint8Array()
107
+ };
108
+ }
109
+ throw new Error(`unsupported carrier packet type/body: ${type}/${bodyType}`);
110
+ }
111
+ function finishCarrierPacket(builder, type, bodyType, bodyOffset) {
112
+ builder.startObject(3);
113
+ builder.addFieldOffset(2, bodyOffset, 0);
114
+ builder.addFieldInt8(1, bodyType, 0);
115
+ builder.addFieldInt8(0, type, 0);
116
+ const packet = builder.endObject();
117
+ builder.finish(packet);
118
+ return builder.asUint8Array();
119
+ }
120
+ function readBoolField(table, field) {
121
+ const offset = table.bb.__offset(table.bb_pos, 4 + field * 2);
122
+ return offset ? table.bb.readUint8(table.bb_pos + offset) !== 0 : false;
123
+ }
124
+ function readUint8Field(table, field) {
125
+ const offset = table.bb.__offset(table.bb_pos, 4 + field * 2);
126
+ return offset ? table.bb.readUint8(table.bb_pos + offset) : 0;
127
+ }
128
+ function readStringField(table, field) {
129
+ const offset = table.bb.__offset(table.bb_pos, 4 + field * 2);
130
+ if (!offset) {
131
+ return undefined;
132
+ }
133
+ return table.bb.__string(table.bb_pos + offset, Encoding.UTF16_STRING);
134
+ }
135
+ function readTableField(table, field) {
136
+ const offset = table.bb.__offset(table.bb_pos, 4 + field * 2);
137
+ if (!offset) {
138
+ return undefined;
139
+ }
140
+ const tableOffset = table.bb_pos + offset;
141
+ return {
142
+ bb: table.bb,
143
+ bb_pos: tableOffset + table.bb.readInt32(tableOffset)
144
+ };
145
+ }
146
+ function readByteVectorField(table, field) {
147
+ const offset = table.bb.__offset(table.bb_pos, 4 + field * 2);
148
+ if (!offset) {
149
+ return undefined;
150
+ }
151
+ const vector = table.bb.__vector(table.bb_pos + offset);
152
+ const length = table.bb.__vector_len(table.bb_pos + offset);
153
+ return table.bb.bytes().slice(vector, vector + length);
154
+ }
@@ -0,0 +1,3 @@
1
+ export declare class LegacySessionClient {
2
+ startSession(pubkey: string): Promise<never>;
3
+ }
@@ -0,0 +1,7 @@
1
+ import { LegacyProtocolNotImplementedError } from "../runtime/errors.js";
2
+ export class LegacySessionClient {
3
+ async startSession(pubkey) {
4
+ void pubkey;
5
+ throw new LegacyProtocolNotImplementedError("Legacy session");
6
+ }
7
+ }
@@ -0,0 +1,85 @@
1
+ import { EventEmitter } from "node:events";
2
+ import type { KeyPair } from "../crypto/keypair.js";
3
+ import type { NetworkNode } from "../types/peer.js";
4
+ export type TcpRelayPoolOptions = {
5
+ relays: NetworkNode[];
6
+ selfKeyPair: KeyPair;
7
+ maxConnections?: number;
8
+ /** Optional debug label inherited by underlying TcpRelayClients. */
9
+ label?: string;
10
+ };
11
+ export type TcpRelayPoolEvents = {
12
+ /** A friend is now online on at least one relay. */
13
+ friendOnline: (friendPublicKey: Uint8Array) => void;
14
+ /** A friend is no longer online on any relay. */
15
+ friendOffline: (friendPublicKey: Uint8Array) => void;
16
+ /** Inbound DATA forwarded by some relay. */
17
+ peerData: (friendPublicKey: Uint8Array, payload: Uint8Array) => void;
18
+ /** Inbound OOB_RECV (used for friend requests via TCP). */
19
+ oob: (senderPublicKey: Uint8Array, payload: Uint8Array) => void;
20
+ /** Number of currently-connected relays changed (for diagnostics). */
21
+ status: (connected: number, total: number) => void;
22
+ };
23
+ export declare class TcpRelayPool extends EventEmitter {
24
+ #private;
25
+ constructor(opts: TcpRelayPoolOptions);
26
+ /**
27
+ * Initialize the pool. Selects up to `maxConnections` relays from the
28
+ * supplied list (picks them in order; the operator can prioritize by
29
+ * sorting). Returns the count of initial connections successfully
30
+ * opened.
31
+ */
32
+ start(): Promise<number>;
33
+ /** Tear down all relay connections. */
34
+ stop(): Promise<void>;
35
+ /**
36
+ * Dynamically add a TCP relay to the pool — used when a friend's
37
+ * DHT-PK extras advertise a relay address we weren't already on.
38
+ * Without this, two peers on disjoint relay sets can never establish
39
+ * a TCP path even if both relays are reachable. Idempotent: skips if
40
+ * the host:port pair is already in the pool.
41
+ *
42
+ * Returns the index of the relay (existing or newly added).
43
+ */
44
+ addRelay(node: NetworkNode): number;
45
+ /**
46
+ * Ask every connected relay to route packets for this friend. Idempotent;
47
+ * subsequent calls for an already-requested key are no-ops on each
48
+ * underlying client.
49
+ */
50
+ requestRoute(friendPublicKey: Uint8Array): void;
51
+ /** True if at least one relay reports this friend currently online. */
52
+ isFriendOnline(friendPublicKey: Uint8Array): boolean;
53
+ /**
54
+ * OOB_SEND — fire-and-forget delivery to any peer connected to any of
55
+ * our relays, bypassing routing. Used for the initial cookie request
56
+ * / cookie response / crypto handshake exchange before either side
57
+ * has issued ROUTING_REQUEST. Mirrors toxcore's tcp_send_oob_packet
58
+ * which net_crypto.c calls via send_oob_packet for the same purpose.
59
+ * Returns the count of relays the OOB packet was written to.
60
+ */
61
+ sendOobToFriend(friendPublicKey: Uint8Array, payload: Uint8Array): number;
62
+ /**
63
+ * Forward a DATA payload to a friend through any relay where they're
64
+ * online. Returns the count of relays that accepted the write — 0 if
65
+ * the friend isn't online on any TCP relay.
66
+ */
67
+ sendToFriend(friendPublicKey: Uint8Array, payload: Uint8Array): number;
68
+ /** Diagnostics. */
69
+ connectedCount(): number;
70
+ /**
71
+ * Snapshot of currently-connected relays for inclusion in DHT-PK
72
+ * announce extras. Mirrors toxcore's `tcp_copy_connected_relays`.
73
+ * Returns up to `max` entries, each with the relay's public key and
74
+ * its host:port — enough for the receiver to add the relay to its
75
+ * own pool via TcpRelayPool.addRelay and start routing through it.
76
+ */
77
+ connectedRelays(max?: number): Array<{
78
+ host: string;
79
+ port: number;
80
+ serverPublicKey: Uint8Array;
81
+ }>;
82
+ on<E extends keyof TcpRelayPoolEvents>(event: E, listener: TcpRelayPoolEvents[E]): this;
83
+ once<E extends keyof TcpRelayPoolEvents>(event: E, listener: TcpRelayPoolEvents[E]): this;
84
+ off<E extends keyof TcpRelayPoolEvents>(event: E, listener: TcpRelayPoolEvents[E]): this;
85
+ }
@@ -0,0 +1,342 @@
1
+ // Multi-relay TCP pool — owns a small number of TcpRelayClient
2
+ // connections to the operator-configured Carrier bootstrap nodes (each
3
+ // of which also runs a TCP relay server) and provides a unified
4
+ // per-friend send/receive surface to the Peer class.
5
+ //
6
+ // Design choices:
7
+ // - Up to N parallel relays (default 3, matches toxcore's
8
+ // TCP_CONNECTIONS_PER_FRIEND constant).
9
+ // - For every friend the Peer cares about, we issue ROUTING_REQUEST on
10
+ // every connected relay. The first relay where the friend is also
11
+ // routed produces a CONNECTION_NOTIFICATION. From then on we can
12
+ // forward DATA through that relay.
13
+ // - Inbound peerData from any relay is surfaced to a single event so
14
+ // the Peer doesn't need to know which relay delivered the bytes.
15
+ // - Outbound sendToFriend tries every relay that has the friend
16
+ // online, returning true if at least one accepted the write. iOS
17
+ // Beagle does the same — sends in parallel and lets net_crypto's
18
+ // packet number dedup handle duplicates.
19
+ // - Reconnect: if a relay socket drops, we re-open and re-issue route
20
+ // requests for all known friends. Bounded backoff so a permanently
21
+ // down relay doesn't burn CPU.
22
+ import { EventEmitter } from "node:events";
23
+ import { base58ToBytes } from "../utils/base58.js";
24
+ import { TcpRelayClient } from "./tcp-relay.js";
25
+ const KEY_SIZE = 32;
26
+ const MAX_RELAY_CONNECTIONS = 3;
27
+ const RECONNECT_BASE_MS = 5_000;
28
+ const RECONNECT_MAX_MS = 120_000;
29
+ export class TcpRelayPool extends EventEmitter {
30
+ #opts;
31
+ #relays = [];
32
+ #stopped = false;
33
+ // Friends we want routed on every connected relay. Re-asserted on
34
+ // reconnect.
35
+ #wantedFriends = new Map(); // hex -> raw pubkey
36
+ // Friend -> set of relay-indices currently reporting the friend online.
37
+ // We emit `friendOnline` on first transition to size 1, `friendOffline`
38
+ // on transition back to 0.
39
+ #onlineRelaysByFriend = new Map();
40
+ #debug = process.env.DECENT_DEBUG === "1";
41
+ constructor(opts) {
42
+ super();
43
+ this.#opts = opts;
44
+ }
45
+ /**
46
+ * Initialize the pool. Selects up to `maxConnections` relays from the
47
+ * supplied list (picks them in order; the operator can prioritize by
48
+ * sorting). Returns the count of initial connections successfully
49
+ * opened.
50
+ */
51
+ async start() {
52
+ const max = Math.min(this.#opts.maxConnections ?? MAX_RELAY_CONNECTIONS, this.#opts.relays.length);
53
+ for (let i = 0; i < max; i++) {
54
+ const node = this.#opts.relays[i];
55
+ if (!node.pk)
56
+ continue;
57
+ let serverPk;
58
+ try {
59
+ serverPk = base58ToBytes(node.pk);
60
+ }
61
+ catch {
62
+ continue;
63
+ }
64
+ if (serverPk.length !== KEY_SIZE)
65
+ continue;
66
+ this.#relays.push({ node, serverPublicKey: serverPk, reconnectAttempt: 0 });
67
+ }
68
+ // Open all in parallel; tolerate failures (each will reconnect).
69
+ const results = await Promise.allSettled(this.#relays.map((_, idx) => this.#openOne(idx)));
70
+ const opened = results.filter((r) => r.status === "fulfilled").length;
71
+ this.#emitStatus();
72
+ return opened;
73
+ }
74
+ /** Tear down all relay connections. */
75
+ async stop() {
76
+ this.#stopped = true;
77
+ for (const r of this.#relays) {
78
+ if (r.reconnectTimer)
79
+ clearTimeout(r.reconnectTimer);
80
+ r.reconnectTimer = undefined;
81
+ try {
82
+ r.client?.close("pool stop");
83
+ }
84
+ catch { /* ignore */ }
85
+ r.client = undefined;
86
+ }
87
+ this.#relays = [];
88
+ this.#wantedFriends.clear();
89
+ this.#onlineRelaysByFriend.clear();
90
+ }
91
+ /**
92
+ * Dynamically add a TCP relay to the pool — used when a friend's
93
+ * DHT-PK extras advertise a relay address we weren't already on.
94
+ * Without this, two peers on disjoint relay sets can never establish
95
+ * a TCP path even if both relays are reachable. Idempotent: skips if
96
+ * the host:port pair is already in the pool.
97
+ *
98
+ * Returns the index of the relay (existing or newly added).
99
+ */
100
+ addRelay(node) {
101
+ if (this.#stopped)
102
+ return -1;
103
+ if (!node.pk)
104
+ return -1;
105
+ let serverPk;
106
+ try {
107
+ serverPk = base58ToBytes(node.pk);
108
+ }
109
+ catch {
110
+ return -1;
111
+ }
112
+ if (serverPk.length !== KEY_SIZE)
113
+ return -1;
114
+ // Already in pool?
115
+ for (let i = 0; i < this.#relays.length; i++) {
116
+ const r = this.#relays[i];
117
+ if (r.node.host === node.host && r.node.port === node.port) {
118
+ return i;
119
+ }
120
+ }
121
+ const idx = this.#relays.length;
122
+ this.#relays.push({ node, serverPublicKey: serverPk, reconnectAttempt: 0 });
123
+ this.#log(`addRelay ${node.host}:${node.port} (idx=${idx})`);
124
+ void this.#openOne(idx).catch(() => { });
125
+ return idx;
126
+ }
127
+ /**
128
+ * Ask every connected relay to route packets for this friend. Idempotent;
129
+ * subsequent calls for an already-requested key are no-ops on each
130
+ * underlying client.
131
+ */
132
+ requestRoute(friendPublicKey) {
133
+ if (friendPublicKey.length !== KEY_SIZE)
134
+ return;
135
+ const hex = Buffer.from(friendPublicKey).toString("hex");
136
+ this.#wantedFriends.set(hex, new Uint8Array(friendPublicKey));
137
+ for (const r of this.#relays) {
138
+ if (r.client?.state() === "connected") {
139
+ r.client.requestRoute(friendPublicKey);
140
+ }
141
+ }
142
+ }
143
+ /** True if at least one relay reports this friend currently online. */
144
+ isFriendOnline(friendPublicKey) {
145
+ if (friendPublicKey.length !== KEY_SIZE)
146
+ return false;
147
+ const set = this.#onlineRelaysByFriend.get(Buffer.from(friendPublicKey).toString("hex"));
148
+ return !!(set && set.size > 0);
149
+ }
150
+ /**
151
+ * OOB_SEND — fire-and-forget delivery to any peer connected to any of
152
+ * our relays, bypassing routing. Used for the initial cookie request
153
+ * / cookie response / crypto handshake exchange before either side
154
+ * has issued ROUTING_REQUEST. Mirrors toxcore's tcp_send_oob_packet
155
+ * which net_crypto.c calls via send_oob_packet for the same purpose.
156
+ * Returns the count of relays the OOB packet was written to.
157
+ */
158
+ sendOobToFriend(friendPublicKey, payload) {
159
+ if (friendPublicKey.length !== KEY_SIZE)
160
+ return 0;
161
+ let sent = 0;
162
+ for (const r of this.#relays) {
163
+ if (r.client?.state() !== "connected")
164
+ continue;
165
+ if (r.client.sendOob(friendPublicKey, payload)) {
166
+ sent += 1;
167
+ }
168
+ }
169
+ return sent;
170
+ }
171
+ /**
172
+ * Forward a DATA payload to a friend through any relay where they're
173
+ * online. Returns the count of relays that accepted the write — 0 if
174
+ * the friend isn't online on any TCP relay.
175
+ */
176
+ sendToFriend(friendPublicKey, payload) {
177
+ if (friendPublicKey.length !== KEY_SIZE)
178
+ return 0;
179
+ // Send via the FIRST connected relay where the friend is online; only
180
+ // fall over to the next relay if the send call returns false. Sending
181
+ // via every relay simultaneously (the previous behavior) caused each
182
+ // application-level message to be delivered N times to the receiver,
183
+ // where N = number of shared relays. The receiver's net_crypto layer
184
+ // does deduplicate by packet_number, but only after the duplicate has
185
+ // already been propagated through the message dispatcher — so peer
186
+ // callbacks (onText etc.) and any IP packet forwarding built on top
187
+ // get the duplicate too. With this change, redundancy still kicks in
188
+ // automatically when a relay rejects the write.
189
+ for (const r of this.#relays) {
190
+ if (r.client?.state() !== "connected")
191
+ continue;
192
+ if (!r.client.hasFriendOnline(friendPublicKey))
193
+ continue;
194
+ if (r.client.sendToFriend(friendPublicKey, payload)) {
195
+ return 1;
196
+ }
197
+ }
198
+ return 0;
199
+ }
200
+ /** Diagnostics. */
201
+ connectedCount() {
202
+ return this.#relays.filter((r) => r.client?.state() === "connected").length;
203
+ }
204
+ /**
205
+ * Snapshot of currently-connected relays for inclusion in DHT-PK
206
+ * announce extras. Mirrors toxcore's `tcp_copy_connected_relays`.
207
+ * Returns up to `max` entries, each with the relay's public key and
208
+ * its host:port — enough for the receiver to add the relay to its
209
+ * own pool via TcpRelayPool.addRelay and start routing through it.
210
+ */
211
+ connectedRelays(max = 4) {
212
+ const out = [];
213
+ for (const r of this.#relays) {
214
+ if (out.length >= max)
215
+ break;
216
+ if (r.client?.state() === "connected") {
217
+ out.push({
218
+ host: r.node.host,
219
+ port: r.node.port,
220
+ serverPublicKey: r.serverPublicKey
221
+ });
222
+ }
223
+ }
224
+ return out;
225
+ }
226
+ async #openOne(idx) {
227
+ if (this.#stopped)
228
+ return;
229
+ const managed = this.#relays[idx];
230
+ if (!managed)
231
+ return;
232
+ if (managed.client)
233
+ return; // already trying
234
+ const client = new TcpRelayClient({
235
+ host: managed.node.host,
236
+ port: managed.node.port,
237
+ serverPublicKey: managed.serverPublicKey,
238
+ selfKeyPair: this.#opts.selfKeyPair,
239
+ label: this.#opts.label
240
+ ? `${this.#opts.label}#${idx}`
241
+ : `relay#${idx}`
242
+ });
243
+ managed.client = client;
244
+ client.on("open", () => {
245
+ managed.reconnectAttempt = 0;
246
+ this.#log(`relay ${idx} ${managed.node.host}:${managed.node.port} open`);
247
+ // (Re-)issue routes for all wanted friends.
248
+ for (const friend of this.#wantedFriends.values()) {
249
+ client.requestRoute(friend);
250
+ }
251
+ this.#emitStatus();
252
+ });
253
+ client.on("close", (reason) => {
254
+ this.#log(`relay ${idx} ${managed.node.host}:${managed.node.port} closed: ${reason}`);
255
+ // Demote any "online" markers held by this relay for our friends.
256
+ for (const [friendHex, set] of this.#onlineRelaysByFriend) {
257
+ if (set.delete(idx) && set.size === 0) {
258
+ const raw = this.#wantedFriends.get(friendHex);
259
+ if (raw)
260
+ this.emit("friendOffline", raw);
261
+ this.#onlineRelaysByFriend.delete(friendHex);
262
+ }
263
+ }
264
+ managed.client = undefined;
265
+ this.#emitStatus();
266
+ this.#scheduleReconnect(idx);
267
+ });
268
+ client.on("friendOnline", (friendKey) => {
269
+ const hex = Buffer.from(friendKey).toString("hex");
270
+ let set = this.#onlineRelaysByFriend.get(hex);
271
+ if (!set) {
272
+ set = new Set();
273
+ this.#onlineRelaysByFriend.set(hex, set);
274
+ }
275
+ const wasEmpty = set.size === 0;
276
+ set.add(idx);
277
+ if (wasEmpty) {
278
+ this.emit("friendOnline", new Uint8Array(friendKey));
279
+ }
280
+ });
281
+ client.on("friendOffline", (friendKey) => {
282
+ const hex = Buffer.from(friendKey).toString("hex");
283
+ const set = this.#onlineRelaysByFriend.get(hex);
284
+ if (!set)
285
+ return;
286
+ if (set.delete(idx) && set.size === 0) {
287
+ this.#onlineRelaysByFriend.delete(hex);
288
+ this.emit("friendOffline", new Uint8Array(friendKey));
289
+ }
290
+ });
291
+ client.on("peerData", (friendKey, payload) => {
292
+ this.emit("peerData", new Uint8Array(friendKey), payload);
293
+ });
294
+ client.on("oob", (senderKey, payload) => {
295
+ this.emit("oob", new Uint8Array(senderKey), payload);
296
+ });
297
+ try {
298
+ await client.connect(10_000);
299
+ }
300
+ catch (err) {
301
+ this.#log(`relay ${idx} ${managed.node.host}:${managed.node.port} connect failed: ${err.message}`);
302
+ managed.client = undefined;
303
+ this.#scheduleReconnect(idx);
304
+ throw err;
305
+ }
306
+ }
307
+ #scheduleReconnect(idx) {
308
+ if (this.#stopped)
309
+ return;
310
+ const managed = this.#relays[idx];
311
+ if (!managed)
312
+ return;
313
+ if (managed.reconnectTimer)
314
+ return;
315
+ managed.reconnectAttempt = Math.min(managed.reconnectAttempt + 1, 8);
316
+ const delay = Math.min(RECONNECT_MAX_MS, RECONNECT_BASE_MS * Math.pow(1.5, managed.reconnectAttempt - 1));
317
+ managed.reconnectTimer = setTimeout(() => {
318
+ managed.reconnectTimer = undefined;
319
+ void this.#openOne(idx).catch(() => { });
320
+ }, delay);
321
+ managed.reconnectTimer.unref?.();
322
+ this.#log(`relay ${idx} ${managed.node.host}:${managed.node.port} reconnect in ${Math.round(delay)}ms (attempt ${managed.reconnectAttempt})`);
323
+ }
324
+ #emitStatus() {
325
+ this.emit("status", this.connectedCount(), this.#relays.length);
326
+ }
327
+ #log(message) {
328
+ if (!this.#debug)
329
+ return;
330
+ const label = this.#opts.label ? `:${this.#opts.label}` : "";
331
+ console.log(`[peer-debug:tcp-pool${label}] ${message}`);
332
+ }
333
+ on(event, listener) {
334
+ return super.on(event, listener);
335
+ }
336
+ once(event, listener) {
337
+ return super.once(event, listener);
338
+ }
339
+ off(event, listener) {
340
+ return super.off(event, listener);
341
+ }
342
+ }