@decentnetwork/peer 0.1.24 → 0.1.26

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.
@@ -51,59 +51,71 @@ export class LegacyExpressClient {
51
51
  if (!this.#nodes.length) {
52
52
  return;
53
53
  }
54
- let lastTimestamp = 0;
55
- const { node, body } = await this.#withAnyNode(async (candidate) => ({
56
- node: candidate,
57
- body: await this.#http(candidate, "GET", encodeURIComponent(this.#selfUserId))
58
- }));
59
- const messages = parseExpressResponseFrames(body);
60
- if (messages.length > 0) {
61
- this.#debugLog(`pullOnce got ${messages.length} offline frame(s) from ${node.host}:${node.port}`);
62
- }
63
- let requestCount = 0;
64
- let messageCount = 0;
65
- for (const encrypted of messages) {
66
- const plain = decrypt(node.sharedKey, encrypted);
67
- if (!plain) {
68
- continue;
54
+ // Pull from EVERY configured relay, not just the first reachable one. A
55
+ // message lives only on the relay its sender posted it to; with a mixed
56
+ // fleet (some peers prefer relay A, others B — e.g. during a rollout that
57
+ // adds a dedicated relay) the recipient must check all relays or it
58
+ // silently misses anything posted elsewhere. Each relay re-encrypts with
59
+ // its own per-recipient key, so we decrypt/ack each one independently.
60
+ for (const node of this.#nodes) {
61
+ let body;
62
+ try {
63
+ body = await this.#http(node, "GET", encodeURIComponent(this.#selfUserId));
64
+ }
65
+ catch {
66
+ continue; // relay unreachable right now — try the next
69
67
  }
70
- const msg = decodePullMessage(plain);
71
- if (!msg) {
68
+ const messages = parseExpressResponseFrames(body);
69
+ if (messages.length === 0) {
72
70
  continue;
73
71
  }
74
- lastTimestamp = Math.max(lastTimestamp, msg.timestamp);
75
- if (msg.type === "R") {
76
- if (!msg.address || msg.address !== this.#selfAddress) {
77
- this.#debugLog(`drop offline request with unmatched address from ${msg.from}`);
72
+ this.#debugLog(`pullOnce got ${messages.length} offline frame(s) from ${node.host}:${node.port}`);
73
+ let lastTimestamp = 0;
74
+ let requestCount = 0;
75
+ let messageCount = 0;
76
+ for (const encrypted of messages) {
77
+ const plain = decrypt(node.sharedKey, encrypted);
78
+ if (!plain) {
78
79
  continue;
79
80
  }
80
- this.#callbacks.onOfflineFriendRequest(msg.from, msg.payload, msg.timestamp);
81
- requestCount += 1;
82
- this.#debugLog(`offline friend request from ${msg.from} ts=${msg.timestamp}`);
83
- }
84
- else if (msg.type === "M") {
85
- try {
86
- const friendPk = base58ToBytes(msg.from);
87
- if (friendPk.length !== 32) {
88
- continue;
89
- }
90
- const friendSharedKey = nacl.box.before(friendPk, this.#selfKeyPair.secretKey);
91
- const packet = decrypt(friendSharedKey, msg.payload);
92
- if (!packet) {
81
+ const msg = decodePullMessage(plain);
82
+ if (!msg) {
83
+ continue;
84
+ }
85
+ lastTimestamp = Math.max(lastTimestamp, msg.timestamp);
86
+ if (msg.type === "R") {
87
+ if (!msg.address || msg.address !== this.#selfAddress) {
88
+ this.#debugLog(`drop offline request with unmatched address from ${msg.from}`);
93
89
  continue;
94
90
  }
95
- this.#callbacks.onOfflineFriendMessage(msg.from, packet, msg.timestamp);
96
- messageCount += 1;
97
- this.#debugLog(`offline message from ${msg.from} ts=${msg.timestamp}`);
91
+ this.#callbacks.onOfflineFriendRequest(msg.from, msg.payload, msg.timestamp);
92
+ requestCount += 1;
93
+ this.#debugLog(`offline friend request from ${msg.from} ts=${msg.timestamp}`);
98
94
  }
99
- catch {
100
- // Skip invalid sender ids.
95
+ else if (msg.type === "M") {
96
+ try {
97
+ const friendPk = base58ToBytes(msg.from);
98
+ if (friendPk.length !== 32) {
99
+ continue;
100
+ }
101
+ const friendSharedKey = nacl.box.before(friendPk, this.#selfKeyPair.secretKey);
102
+ const packet = decrypt(friendSharedKey, msg.payload);
103
+ if (!packet) {
104
+ continue;
105
+ }
106
+ this.#callbacks.onOfflineFriendMessage(msg.from, packet, msg.timestamp);
107
+ messageCount += 1;
108
+ this.#debugLog(`offline message from ${msg.from} ts=${msg.timestamp}`);
109
+ }
110
+ catch {
111
+ // Skip invalid sender ids.
112
+ }
101
113
  }
102
114
  }
103
- }
104
- if (lastTimestamp > 0) {
105
- this.#debugLog(`pull processed: requests=${requestCount} messages=${messageCount}; ack until ts=${lastTimestamp}`);
106
- await this.#deleteUntil(lastTimestamp).catch(() => { });
115
+ if (lastTimestamp > 0) {
116
+ this.#debugLog(`pull processed from ${node.host}: requests=${requestCount} messages=${messageCount}; ack until ts=${lastTimestamp}`);
117
+ await this.#deleteUntilOn(node, lastTimestamp).catch(() => { });
118
+ }
107
119
  }
108
120
  }
109
121
  async #postEncrypted(to, plainData) {
@@ -113,14 +125,12 @@ export class LegacyExpressClient {
113
125
  await this.#http(node, "POST", path, encrypted);
114
126
  });
115
127
  }
116
- async #deleteUntil(timestamp) {
128
+ async #deleteUntilOn(node, timestamp) {
117
129
  const tsBytes = new TextEncoder().encode(String(timestamp));
118
- await this.#withAnyNode(async (node) => {
119
- const encrypted = encrypt(node.sharedKey, tsBytes);
120
- const encoded = bytesToBase58(encrypted);
121
- const path = `${encodeURIComponent(this.#selfUserId)}?until=${encodeURIComponent(encoded)}`;
122
- await this.#http(node, "DELETE", path);
123
- });
130
+ const encrypted = encrypt(node.sharedKey, tsBytes);
131
+ const encoded = bytesToBase58(encrypted);
132
+ const path = `${encodeURIComponent(this.#selfUserId)}?until=${encodeURIComponent(encoded)}`;
133
+ await this.#http(node, "DELETE", path);
124
134
  }
125
135
  async #withAnyNode(fn) {
126
136
  if (!this.#nodes.length) {
package/dist/peer.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { EventEmitter } from "node:events";
2
- import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { mkdir, readFile, writeFile, rename } from "node:fs/promises";
3
3
  import { networkInterfaces } from "node:os";
4
4
  import { dirname } from "node:path";
5
5
  import nacl from "tweetnacl";
@@ -168,6 +168,7 @@ export class Peer {
168
168
  #pendingFriendRequests = new Map();
169
169
  #friends = new Map();
170
170
  #friendStoreFile;
171
+ #persistSeq = 0; // makes atomic friend-store temp filenames unique per write
171
172
  #cookieSymmetricKey;
172
173
  #friendSessions = new Map();
173
174
  #express;
@@ -3892,17 +3893,55 @@ export class Peer {
3892
3893
  }
3893
3894
  this.#debugLog(`loaded ${this.#friends.size} persisted friends`);
3894
3895
  }
3895
- catch {
3896
- // Ignore missing/invalid friend store on startup.
3896
+ catch (error) {
3897
+ // A missing file is normal (fresh node). A PARSE error is not — it
3898
+ // means the store is corrupt (historically: concatenated JSON from a
3899
+ // write race, now prevented by atomic persistFriends). Swallowing it
3900
+ // silently left the node with zero friends and unable to register, with
3901
+ // no clue why — so surface it loudly. Try to recover the first valid
3902
+ // JSON array so a corrupt store self-heals instead of stranding the node.
3903
+ const isParse = error instanceof SyntaxError;
3904
+ if (isParse) {
3905
+ try {
3906
+ const raw = await readFile(this.#friendStoreFile, "utf8");
3907
+ const end = raw.indexOf("]");
3908
+ if (end !== -1) {
3909
+ const recovered = JSON.parse(raw.slice(0, end + 1));
3910
+ if (Array.isArray(recovered)) {
3911
+ for (const record of recovered) {
3912
+ if (record && typeof record.pubkey === "string" && record.pubkey) {
3913
+ this.#friends.set(record.pubkey, record);
3914
+ }
3915
+ }
3916
+ // Rewrite cleanly (atomic) so the corruption is gone for good.
3917
+ this.#persistFriends();
3918
+ }
3919
+ }
3920
+ }
3921
+ catch { /* unrecoverable — fall through with whatever we have */ }
3922
+ // eslint-disable-next-line no-console
3923
+ console.error(`[peer] friend store ${this.#friendStoreFile} was corrupt (${error.message}); ` +
3924
+ `recovered ${this.#friends.size} friend(s).`);
3925
+ }
3897
3926
  }
3898
3927
  }
3899
3928
  #persistFriends() {
3900
3929
  if (!this.#friendStoreFile) {
3901
3930
  return;
3902
3931
  }
3932
+ const file = this.#friendStoreFile;
3903
3933
  const payload = JSON.stringify([...this.#friends.values()], null, 2);
3904
- void mkdir(dirname(this.#friendStoreFile), { recursive: true })
3905
- .then(() => writeFile(this.#friendStoreFile, payload, "utf8"))
3934
+ // Write to a unique temp file then atomically rename over the target.
3935
+ // A plain writeFile can interleave when two writers race — observed: the
3936
+ // `agentnet init` friend-request flow (a short-lived standalone peer) and
3937
+ // the daemon both persisting produced a file with two CONCATENATED JSON
3938
+ // arrays, so JSON.parse threw, the loader's catch swallowed it, the node
3939
+ // loaded ZERO friends, and could never sendText/register again. rename()
3940
+ // is atomic on POSIX, so a reader always sees a complete old-or-new file.
3941
+ const tmp = `${file}.tmp.${process.pid}.${this.#persistSeq++}`;
3942
+ void mkdir(dirname(file), { recursive: true })
3943
+ .then(() => writeFile(tmp, payload, "utf8"))
3944
+ .then(() => rename(tmp, file))
3906
3945
  .catch((error) => {
3907
3946
  this.#debugLog(`persist friends failed: ${error.message}`);
3908
3947
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/peer",
3
- "version": "0.1.24",
3
+ "version": "0.1.26",
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",