@meshwhisper/sdk 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 (163) hide show
  1. package/README.md +138 -0
  2. package/dist/browser/index.d.ts +4 -0
  3. package/dist/browser/index.d.ts.map +1 -0
  4. package/dist/browser/index.js +19 -0
  5. package/dist/browser/index.js.map +1 -0
  6. package/dist/chaff/index.d.ts +91 -0
  7. package/dist/chaff/index.d.ts.map +1 -0
  8. package/dist/chaff/index.js +268 -0
  9. package/dist/chaff/index.js.map +1 -0
  10. package/dist/cluster/index.d.ts +159 -0
  11. package/dist/cluster/index.d.ts.map +1 -0
  12. package/dist/cluster/index.js +393 -0
  13. package/dist/cluster/index.js.map +1 -0
  14. package/dist/compliance/index.d.ts +129 -0
  15. package/dist/compliance/index.d.ts.map +1 -0
  16. package/dist/compliance/index.js +315 -0
  17. package/dist/compliance/index.js.map +1 -0
  18. package/dist/crypto/index.d.ts +65 -0
  19. package/dist/crypto/index.d.ts.map +1 -0
  20. package/dist/crypto/index.js +146 -0
  21. package/dist/crypto/index.js.map +1 -0
  22. package/dist/group/index.d.ts +155 -0
  23. package/dist/group/index.d.ts.map +1 -0
  24. package/dist/group/index.js +560 -0
  25. package/dist/group/index.js.map +1 -0
  26. package/dist/index.d.ts +7 -0
  27. package/dist/index.d.ts.map +1 -0
  28. package/dist/index.js +11 -0
  29. package/dist/index.js.map +1 -0
  30. package/dist/namespace/index.d.ts +155 -0
  31. package/dist/namespace/index.d.ts.map +1 -0
  32. package/dist/namespace/index.js +278 -0
  33. package/dist/namespace/index.js.map +1 -0
  34. package/dist/node/index.d.ts +4 -0
  35. package/dist/node/index.d.ts.map +1 -0
  36. package/dist/node/index.js +19 -0
  37. package/dist/node/index.js.map +1 -0
  38. package/dist/packet/index.d.ts +63 -0
  39. package/dist/packet/index.d.ts.map +1 -0
  40. package/dist/packet/index.js +244 -0
  41. package/dist/packet/index.js.map +1 -0
  42. package/dist/permissions/index.d.ts +107 -0
  43. package/dist/permissions/index.d.ts.map +1 -0
  44. package/dist/permissions/index.js +282 -0
  45. package/dist/permissions/index.js.map +1 -0
  46. package/dist/persistence/idb-storage.d.ts +27 -0
  47. package/dist/persistence/idb-storage.d.ts.map +1 -0
  48. package/dist/persistence/idb-storage.js +75 -0
  49. package/dist/persistence/idb-storage.js.map +1 -0
  50. package/dist/persistence/index.d.ts +4 -0
  51. package/dist/persistence/index.d.ts.map +1 -0
  52. package/dist/persistence/index.js +3 -0
  53. package/dist/persistence/index.js.map +1 -0
  54. package/dist/persistence/node-storage.d.ts +33 -0
  55. package/dist/persistence/node-storage.d.ts.map +1 -0
  56. package/dist/persistence/node-storage.js +90 -0
  57. package/dist/persistence/node-storage.js.map +1 -0
  58. package/dist/persistence/serialization.d.ts +4 -0
  59. package/dist/persistence/serialization.d.ts.map +1 -0
  60. package/dist/persistence/serialization.js +49 -0
  61. package/dist/persistence/serialization.js.map +1 -0
  62. package/dist/persistence/types.d.ts +29 -0
  63. package/dist/persistence/types.d.ts.map +1 -0
  64. package/dist/persistence/types.js +5 -0
  65. package/dist/persistence/types.js.map +1 -0
  66. package/dist/ratchet/index.d.ts +80 -0
  67. package/dist/ratchet/index.d.ts.map +1 -0
  68. package/dist/ratchet/index.js +259 -0
  69. package/dist/ratchet/index.js.map +1 -0
  70. package/dist/reciprocity/index.d.ts +109 -0
  71. package/dist/reciprocity/index.d.ts.map +1 -0
  72. package/dist/reciprocity/index.js +311 -0
  73. package/dist/reciprocity/index.js.map +1 -0
  74. package/dist/relay/index.d.ts +87 -0
  75. package/dist/relay/index.d.ts.map +1 -0
  76. package/dist/relay/index.js +286 -0
  77. package/dist/relay/index.js.map +1 -0
  78. package/dist/routing/index.d.ts +136 -0
  79. package/dist/routing/index.d.ts.map +1 -0
  80. package/dist/routing/index.js +478 -0
  81. package/dist/routing/index.js.map +1 -0
  82. package/dist/sdk/index.d.ts +322 -0
  83. package/dist/sdk/index.d.ts.map +1 -0
  84. package/dist/sdk/index.js +1530 -0
  85. package/dist/sdk/index.js.map +1 -0
  86. package/dist/sybil/index.d.ts +123 -0
  87. package/dist/sybil/index.d.ts.map +1 -0
  88. package/dist/sybil/index.js +491 -0
  89. package/dist/sybil/index.js.map +1 -0
  90. package/dist/transport/browser/index.d.ts +34 -0
  91. package/dist/transport/browser/index.d.ts.map +1 -0
  92. package/dist/transport/browser/index.js +176 -0
  93. package/dist/transport/browser/index.js.map +1 -0
  94. package/dist/transport/local/index.d.ts +57 -0
  95. package/dist/transport/local/index.d.ts.map +1 -0
  96. package/dist/transport/local/index.js +442 -0
  97. package/dist/transport/local/index.js.map +1 -0
  98. package/dist/transport/negotiator/index.d.ts +79 -0
  99. package/dist/transport/negotiator/index.d.ts.map +1 -0
  100. package/dist/transport/negotiator/index.js +289 -0
  101. package/dist/transport/negotiator/index.js.map +1 -0
  102. package/dist/transport/node/index.d.ts +56 -0
  103. package/dist/transport/node/index.d.ts.map +1 -0
  104. package/dist/transport/node/index.js +209 -0
  105. package/dist/transport/node/index.js.map +1 -0
  106. package/dist/transport/noop/index.d.ts +11 -0
  107. package/dist/transport/noop/index.d.ts.map +1 -0
  108. package/dist/transport/noop/index.js +20 -0
  109. package/dist/transport/noop/index.js.map +1 -0
  110. package/dist/transport/p2p/index.d.ts +109 -0
  111. package/dist/transport/p2p/index.d.ts.map +1 -0
  112. package/dist/transport/p2p/index.js +237 -0
  113. package/dist/transport/p2p/index.js.map +1 -0
  114. package/dist/transport/websocket/index.d.ts +89 -0
  115. package/dist/transport/websocket/index.d.ts.map +1 -0
  116. package/dist/transport/websocket/index.js +498 -0
  117. package/dist/transport/websocket/index.js.map +1 -0
  118. package/dist/transport/websocket/serialize.d.ts +5 -0
  119. package/dist/transport/websocket/serialize.d.ts.map +1 -0
  120. package/dist/transport/websocket/serialize.js +55 -0
  121. package/dist/transport/websocket/serialize.js.map +1 -0
  122. package/dist/types.d.ts +215 -0
  123. package/dist/types.d.ts.map +1 -0
  124. package/dist/types.js +15 -0
  125. package/dist/types.js.map +1 -0
  126. package/dist/x3dh/index.d.ts +120 -0
  127. package/dist/x3dh/index.d.ts.map +1 -0
  128. package/dist/x3dh/index.js +290 -0
  129. package/dist/x3dh/index.js.map +1 -0
  130. package/package.json +59 -0
  131. package/src/browser/index.ts +19 -0
  132. package/src/chaff/index.ts +340 -0
  133. package/src/cluster/index.ts +482 -0
  134. package/src/compliance/index.ts +407 -0
  135. package/src/crypto/index.ts +193 -0
  136. package/src/group/index.ts +719 -0
  137. package/src/index.ts +87 -0
  138. package/src/lz4js.d.ts +58 -0
  139. package/src/namespace/index.ts +336 -0
  140. package/src/node/index.ts +19 -0
  141. package/src/packet/index.ts +326 -0
  142. package/src/permissions/index.ts +405 -0
  143. package/src/persistence/idb-storage.ts +83 -0
  144. package/src/persistence/index.ts +3 -0
  145. package/src/persistence/node-storage.ts +96 -0
  146. package/src/persistence/serialization.ts +75 -0
  147. package/src/persistence/types.ts +33 -0
  148. package/src/ratchet/index.ts +363 -0
  149. package/src/reciprocity/index.ts +371 -0
  150. package/src/relay/index.ts +382 -0
  151. package/src/routing/index.ts +577 -0
  152. package/src/sdk/index.ts +1994 -0
  153. package/src/sybil/index.ts +661 -0
  154. package/src/transport/browser/index.ts +201 -0
  155. package/src/transport/local/index.ts +540 -0
  156. package/src/transport/negotiator/index.ts +397 -0
  157. package/src/transport/node/index.ts +234 -0
  158. package/src/transport/noop/index.ts +22 -0
  159. package/src/transport/p2p/index.ts +345 -0
  160. package/src/transport/websocket/index.ts +660 -0
  161. package/src/transport/websocket/serialize.ts +68 -0
  162. package/src/types.ts +275 -0
  163. package/src/x3dh/index.ts +388 -0
@@ -0,0 +1,577 @@
1
+ // ============================================================
2
+ // MeshWhisper SDK — Social Graph Routing
3
+ // Uses social proximity (not geographic or DHT) as routing
4
+ // topology. Implements Bubble-Rap-inspired discovery with
5
+ // small-world properties (~6 hop average path length).
6
+ // See PRD section 7.1.
7
+ // ============================================================
8
+
9
+ import type { Packet, PeerProximityEntry, RouteRequest, RouteOffer } from '../types.js';
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Constants
13
+ // ---------------------------------------------------------------------------
14
+
15
+ /** Default TTL for proximity table entries (1 hour). */
16
+ const DEFAULT_PEER_TTL_MS = 60 * 60 * 1000;
17
+
18
+ /** Default TTL for cached routes (5 minutes). */
19
+ const DEFAULT_ROUTE_TTL_MS = 5 * 60 * 1000;
20
+
21
+ /** Default timeout for pending route requests (5 seconds). */
22
+ const DEFAULT_REQUEST_TIMEOUT_MS = 5 * 1000;
23
+
24
+ /** Maximum number of recent packet hashes kept for deduplication. */
25
+ const SEEN_PACKET_CAPACITY = 10_000;
26
+
27
+ /** Number of socially-closest peers to gossip route requests to. */
28
+ const GOSSIP_FANOUT = 5;
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Route type
32
+ // ---------------------------------------------------------------------------
33
+
34
+ export interface Route {
35
+ destHash: Uint8Array;
36
+ nextHop: string;
37
+ hopCount: number;
38
+ estimatedLatency: number;
39
+ discoveredAt: number;
40
+ expiresAt: number;
41
+ }
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Helpers
45
+ // ---------------------------------------------------------------------------
46
+
47
+ /** Convert a Uint8Array to a hex string for use as a Map key. */
48
+ function hashToHex(hash: Uint8Array): string {
49
+ let out = '';
50
+ for (let i = 0; i < hash.length; i++) {
51
+ out += hash[i].toString(16).padStart(2, '0');
52
+ }
53
+ return out;
54
+ }
55
+
56
+ /** Constant-time-ish byte array equality. */
57
+ function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
58
+ if (a.length !== b.length) return false;
59
+ let diff = 0;
60
+ for (let i = 0; i < a.length; i++) {
61
+ diff |= a[i] ^ b[i];
62
+ }
63
+ return diff === 0;
64
+ }
65
+
66
+ /**
67
+ * XOR distance between two destination hashes.
68
+ * Returns a number usable for comparison (lower = closer).
69
+ * Works on up to 8-byte hashes.
70
+ */
71
+ function xorDistance(a: Uint8Array, b: Uint8Array): number {
72
+ const len = Math.min(a.length, b.length);
73
+ let distance = 0;
74
+ for (let i = 0; i < len; i++) {
75
+ distance = distance * 256 + (a[i] ^ b[i]);
76
+ }
77
+ return distance;
78
+ }
79
+
80
+ /**
81
+ * Compute a simple hash of a packet for deduplication.
82
+ * Uses destHash + senderEphemeralId + first 8 bytes of payload.
83
+ */
84
+ function packetFingerprint(packet: Packet): string {
85
+ const parts: number[] = [];
86
+ for (let i = 0; i < packet.destHash.length; i++) {
87
+ parts.push(packet.destHash[i]);
88
+ }
89
+ for (let i = 0; i < packet.senderEphemeralId.length; i++) {
90
+ parts.push(packet.senderEphemeralId[i]);
91
+ }
92
+ const payloadSample = Math.min(8, packet.encryptedPayload.length);
93
+ for (let i = 0; i < payloadSample; i++) {
94
+ parts.push(packet.encryptedPayload[i]);
95
+ }
96
+ return parts.map(b => b.toString(16).padStart(2, '0')).join('');
97
+ }
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // PeerProximityTable
101
+ // ---------------------------------------------------------------------------
102
+
103
+ /**
104
+ * Maintains a table of known peers indexed by social proximity.
105
+ * Entries are keyed by peerId and can also be looked up by destHash.
106
+ */
107
+ export class PeerProximityTable {
108
+ private readonly entries: Map<string, PeerProximityEntry> = new Map();
109
+ private readonly destHashIndex: Map<string, string> = new Map(); // hex(destHash) -> peerId
110
+ private readonly ttlMs: number;
111
+
112
+ constructor(ttlMs: number = DEFAULT_PEER_TTL_MS) {
113
+ this.ttlMs = ttlMs;
114
+ }
115
+
116
+ /** Add or replace a peer entry. */
117
+ addPeer(entry: PeerProximityEntry): void {
118
+ this.entries.set(entry.peerId, entry);
119
+ this.destHashIndex.set(hashToHex(entry.destHash), entry.peerId);
120
+ }
121
+
122
+ /** Remove a peer by ID. */
123
+ removePeer(peerId: string): void {
124
+ const existing = this.entries.get(peerId);
125
+ if (existing) {
126
+ this.destHashIndex.delete(hashToHex(existing.destHash));
127
+ this.entries.delete(peerId);
128
+ }
129
+ }
130
+
131
+ /** Partially update an existing peer entry. */
132
+ updatePeer(peerId: string, updates: Partial<PeerProximityEntry>): void {
133
+ const existing = this.entries.get(peerId);
134
+ if (!existing) return;
135
+
136
+ // If destHash changes, update the index.
137
+ if (updates.destHash && !bytesEqual(updates.destHash, existing.destHash)) {
138
+ this.destHashIndex.delete(hashToHex(existing.destHash));
139
+ this.destHashIndex.set(hashToHex(updates.destHash), peerId);
140
+ }
141
+
142
+ const updated: PeerProximityEntry = { ...existing, ...updates };
143
+ this.entries.set(peerId, updated);
144
+ }
145
+
146
+ /** Find a peer that owns the given destination hash. */
147
+ findPeer(destHash: Uint8Array): PeerProximityEntry | null {
148
+ const hex = hashToHex(destHash);
149
+ const peerId = this.destHashIndex.get(hex);
150
+ if (peerId === undefined) return null;
151
+
152
+ const entry = this.entries.get(peerId);
153
+ if (!entry) {
154
+ // Stale index entry — clean up.
155
+ this.destHashIndex.delete(hex);
156
+ return null;
157
+ }
158
+
159
+ return entry;
160
+ }
161
+
162
+ /**
163
+ * Return the N peers whose known destHash is closest (by XOR distance) to
164
+ * the target destination hash. Results are sorted closest-first.
165
+ */
166
+ getClosestPeers(destHash: Uint8Array, count: number): PeerProximityEntry[] {
167
+ const scored: Array<{ entry: PeerProximityEntry; distance: number }> = [];
168
+
169
+ for (const entry of this.entries.values()) {
170
+ scored.push({
171
+ entry,
172
+ distance: xorDistance(entry.destHash, destHash),
173
+ });
174
+ }
175
+
176
+ scored.sort((a, b) => a.distance - b.distance);
177
+ return scored.slice(0, count).map(s => s.entry);
178
+ }
179
+
180
+ /** Remove entries whose lastSeen is older than maxAgeMs. */
181
+ pruneStale(maxAgeMs: number = this.ttlMs): void {
182
+ const cutoff = Date.now() - maxAgeMs;
183
+ for (const [peerId, entry] of this.entries) {
184
+ if (entry.lastSeen < cutoff) {
185
+ this.removePeer(peerId);
186
+ }
187
+ }
188
+ }
189
+
190
+ /** Number of entries currently in the table. */
191
+ get size(): number {
192
+ return this.entries.size;
193
+ }
194
+
195
+ /** Iterate all entries (snapshot). */
196
+ allEntries(): PeerProximityEntry[] {
197
+ return Array.from(this.entries.values());
198
+ }
199
+ }
200
+
201
+ // ---------------------------------------------------------------------------
202
+ // Seen-packet LRU set (bounded ring buffer)
203
+ // ---------------------------------------------------------------------------
204
+
205
+ class SeenPacketSet {
206
+ private readonly capacity: number;
207
+ private readonly set: Set<string> = new Set();
208
+ private readonly ring: string[];
209
+ private cursor = 0;
210
+
211
+ constructor(capacity: number = SEEN_PACKET_CAPACITY) {
212
+ this.capacity = capacity;
213
+ this.ring = new Array<string>(capacity).fill('');
214
+ }
215
+
216
+ /** Returns true if the fingerprint was already in the set. */
217
+ testAndAdd(fingerprint: string): boolean {
218
+ if (this.set.has(fingerprint)) return true;
219
+
220
+ // Evict oldest if at capacity.
221
+ if (this.set.size >= this.capacity) {
222
+ const evict = this.ring[this.cursor];
223
+ if (evict) this.set.delete(evict);
224
+ }
225
+
226
+ this.ring[this.cursor] = fingerprint;
227
+ this.set.add(fingerprint);
228
+ this.cursor = (this.cursor + 1) % this.capacity;
229
+ return false;
230
+ }
231
+
232
+ has(fingerprint: string): boolean {
233
+ return this.set.has(fingerprint);
234
+ }
235
+ }
236
+
237
+ // ---------------------------------------------------------------------------
238
+ // Pending request tracker
239
+ // ---------------------------------------------------------------------------
240
+
241
+ interface PendingRequest {
242
+ request: RouteRequest;
243
+ createdAt: number;
244
+ timeoutMs: number;
245
+ offers: RouteOffer[];
246
+ resolve: (route: Route | null) => void;
247
+ }
248
+
249
+ // ---------------------------------------------------------------------------
250
+ // Route cache
251
+ // ---------------------------------------------------------------------------
252
+
253
+ class RouteCache {
254
+ private readonly routes: Map<string, Route> = new Map();
255
+
256
+ getCachedRoute(destHash: Uint8Array): Route | null {
257
+ const key = hashToHex(destHash);
258
+ const route = this.routes.get(key);
259
+ if (!route) return null;
260
+ if (Date.now() > route.expiresAt) {
261
+ this.routes.delete(key);
262
+ return null;
263
+ }
264
+ return route;
265
+ }
266
+
267
+ cacheRoute(route: Route): void {
268
+ this.routes.set(hashToHex(route.destHash), route);
269
+ }
270
+
271
+ invalidateRoute(destHash: Uint8Array): void {
272
+ this.routes.delete(hashToHex(destHash));
273
+ }
274
+ }
275
+
276
+ // ---------------------------------------------------------------------------
277
+ // SocialGraphRouter
278
+ // ---------------------------------------------------------------------------
279
+
280
+ /**
281
+ * Core social graph router.
282
+ *
283
+ * Uses the peer proximity table as the routing topology.
284
+ * Route discovery gossips route requests to socially proximate peers
285
+ * and selects the shortest/fastest offered route.
286
+ */
287
+ export class SocialGraphRouter {
288
+ readonly localPeerId: string;
289
+ readonly proximityTable: PeerProximityTable;
290
+
291
+ private readonly routeCache: RouteCache = new RouteCache();
292
+ private readonly seenPackets: SeenPacketSet = new SeenPacketSet();
293
+ private readonly pendingRequests: Map<string, PendingRequest> = new Map();
294
+ private readonly routeTtlMs: number;
295
+ private readonly requestTimeoutMs: number;
296
+
297
+ constructor(
298
+ localPeerId: string,
299
+ proximityTable: PeerProximityTable,
300
+ options?: {
301
+ routeTtlMs?: number;
302
+ requestTimeoutMs?: number;
303
+ },
304
+ ) {
305
+ this.localPeerId = localPeerId;
306
+ this.proximityTable = proximityTable;
307
+ this.routeTtlMs = options?.routeTtlMs ?? DEFAULT_ROUTE_TTL_MS;
308
+ this.requestTimeoutMs = options?.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
309
+ }
310
+
311
+ // -----------------------------------------------------------------------
312
+ // Route cache public API
313
+ // -----------------------------------------------------------------------
314
+
315
+ /** Retrieve a cached route if it exists and has not expired. */
316
+ getCachedRoute(destHash: Uint8Array): Route | null {
317
+ return this.routeCache.getCachedRoute(destHash);
318
+ }
319
+
320
+ /** Store a route in the cache. */
321
+ cacheRoute(route: Route): void {
322
+ this.routeCache.cacheRoute(route);
323
+ }
324
+
325
+ /** Remove a cached route. */
326
+ invalidateRoute(destHash: Uint8Array): void {
327
+ this.routeCache.invalidateRoute(destHash);
328
+ }
329
+
330
+ // -----------------------------------------------------------------------
331
+ // Route discovery (PRD section 7.1)
332
+ // -----------------------------------------------------------------------
333
+
334
+ /**
335
+ * Discover a route to the given destination hash.
336
+ *
337
+ * Algorithm:
338
+ * 1. Check the route cache for a non-expired entry.
339
+ * 2. Check if any connected peer in the proximity table has a recent
340
+ * relay path to the destination.
341
+ * 3. If not, create a RouteRequest (containing only dest_hash, not
342
+ * sender identity) and gossip it to socially proximate peers.
343
+ * 4. Collect RouteOffer responses within the request timeout.
344
+ * 5. Select the shortest/fastest offered route.
345
+ *
346
+ * Returns the route, or null if no route could be discovered within the
347
+ * timeout window.
348
+ */
349
+ findRoute(destHash: Uint8Array): Promise<Route | null> {
350
+ // 1. Check cache.
351
+ const cached = this.routeCache.getCachedRoute(destHash);
352
+ if (cached) return Promise.resolve(cached);
353
+
354
+ // 2. Check proximity table for a direct or relay path.
355
+ const directPeer = this.proximityTable.findPeer(destHash);
356
+ if (directPeer && this.isRecent(directPeer.lastSeen)) {
357
+ const route = this.peerToRoute(directPeer, destHash);
358
+ this.routeCache.cacheRoute(route);
359
+ return Promise.resolve(route);
360
+ }
361
+
362
+ // 3. Create a RouteRequest and gossip to close peers.
363
+ const requestId = this.generateRequestId();
364
+ const request: RouteRequest = {
365
+ destHash,
366
+ requestId,
367
+ ttl: 6, // small-world average path length
368
+ timestamp: Date.now(),
369
+ };
370
+
371
+ return new Promise<Route | null>((resolve) => {
372
+ const pending: PendingRequest = {
373
+ request,
374
+ createdAt: Date.now(),
375
+ timeoutMs: this.requestTimeoutMs,
376
+ offers: [],
377
+ resolve,
378
+ };
379
+
380
+ const key = hashToHex(requestId);
381
+ this.pendingRequests.set(key, pending);
382
+
383
+ // 4-5. Timeout: after the window closes, pick the best offer.
384
+ setTimeout(() => {
385
+ this.resolvePendingRequest(key);
386
+ }, this.requestTimeoutMs);
387
+ });
388
+ }
389
+
390
+ /**
391
+ * Handle an incoming route request from a neighbouring peer.
392
+ *
393
+ * If this node knows the destination (via its proximity table), it
394
+ * returns a RouteOffer. Otherwise returns null (the caller should
395
+ * decide whether to forward the request further).
396
+ */
397
+ handleRouteRequest(request: RouteRequest, fromPeer: string): RouteOffer | null {
398
+ const peer = this.proximityTable.findPeer(request.destHash);
399
+ if (!peer) return null;
400
+ if (!this.isRecent(peer.lastSeen)) return null;
401
+
402
+ return {
403
+ requestId: request.requestId,
404
+ hopCount: peer.hopCount + 1, // +1 for this node in the path
405
+ estimatedLatency: peer.latency,
406
+ offeredBy: this.localPeerId,
407
+ };
408
+ }
409
+
410
+ /**
411
+ * Register a received route offer against the matching pending request.
412
+ * If no pending request exists for the offer's requestId, the offer is
413
+ * silently dropped.
414
+ */
415
+ handleRouteOffer(offer: RouteOffer): void {
416
+ const key = hashToHex(offer.requestId);
417
+ const pending = this.pendingRequests.get(key);
418
+ if (!pending) return;
419
+
420
+ pending.offers.push(offer);
421
+ }
422
+
423
+ /**
424
+ * Return a list of the RouteRequests that are still awaiting responses.
425
+ * Expired requests are pruned as a side-effect.
426
+ */
427
+ getPendingRequests(): RouteRequest[] {
428
+ this.pruneExpiredRequests();
429
+ const result: RouteRequest[] = [];
430
+ for (const pending of this.pendingRequests.values()) {
431
+ result.push(pending.request);
432
+ }
433
+ return result;
434
+ }
435
+
436
+ /**
437
+ * Return the list of socially proximate peers that a route request
438
+ * should be gossiped to. Useful for the transport layer to know where
439
+ * to send route requests produced by findRoute.
440
+ */
441
+ getGossipTargets(destHash: Uint8Array): PeerProximityEntry[] {
442
+ return this.proximityTable.getClosestPeers(destHash, GOSSIP_FANOUT);
443
+ }
444
+
445
+ // -----------------------------------------------------------------------
446
+ // Message forwarding
447
+ // -----------------------------------------------------------------------
448
+
449
+ /**
450
+ * Determine the next-hop peer to forward a message to for the given
451
+ * destination hash.
452
+ *
453
+ * Returns null if no route is known.
454
+ */
455
+ getNextHop(destHash: Uint8Array): string | null {
456
+ // Try cache first.
457
+ const cached = this.routeCache.getCachedRoute(destHash);
458
+ if (cached) return cached.nextHop;
459
+
460
+ // Try direct peer from proximity table.
461
+ const peer = this.proximityTable.findPeer(destHash);
462
+ if (peer && this.isRecent(peer.lastSeen)) {
463
+ return peer.relayPath.length > 0 ? peer.relayPath[0] : peer.peerId;
464
+ }
465
+
466
+ // Try closest peer as a heuristic relay.
467
+ const closest = this.proximityTable.getClosestPeers(destHash, 1);
468
+ if (closest.length > 0 && this.isRecent(closest[0].lastSeen)) {
469
+ return closest[0].peerId;
470
+ }
471
+
472
+ return null;
473
+ }
474
+
475
+ /**
476
+ * Determine whether this device should relay a given packet.
477
+ *
478
+ * A packet is relayed when:
479
+ * - Its TTL is greater than 0
480
+ * - It has not been seen before (deduplication)
481
+ */
482
+ shouldRelay(packet: Packet): boolean {
483
+ if (packet.ttl <= 0) return false;
484
+
485
+ const fp = packetFingerprint(packet);
486
+ const alreadySeen = this.seenPackets.testAndAdd(fp);
487
+ return !alreadySeen;
488
+ }
489
+
490
+ /**
491
+ * Return a copy of the packet with TTL decremented by 1.
492
+ * Callers must ensure TTL > 0 before calling (see shouldRelay).
493
+ */
494
+ decrementTTL(packet: Packet): Packet {
495
+ if (packet.ttl <= 0) {
496
+ throw new RangeError('Cannot decrement TTL below 0');
497
+ }
498
+ return {
499
+ ...packet,
500
+ ttl: packet.ttl - 1,
501
+ };
502
+ }
503
+
504
+ // -----------------------------------------------------------------------
505
+ // Internal helpers
506
+ // -----------------------------------------------------------------------
507
+
508
+ /** Check if a timestamp is recent enough to trust. */
509
+ private isRecent(timestamp: number): boolean {
510
+ return (Date.now() - timestamp) < DEFAULT_PEER_TTL_MS;
511
+ }
512
+
513
+ /** Convert a proximity entry to a Route. */
514
+ private peerToRoute(peer: PeerProximityEntry, destHash: Uint8Array): Route {
515
+ const now = Date.now();
516
+ return {
517
+ destHash,
518
+ nextHop: peer.relayPath.length > 0 ? peer.relayPath[0] : peer.peerId,
519
+ hopCount: peer.hopCount,
520
+ estimatedLatency: peer.latency,
521
+ discoveredAt: now,
522
+ expiresAt: now + this.routeTtlMs,
523
+ };
524
+ }
525
+
526
+ /** Resolve a pending request by selecting the best offer. */
527
+ private resolvePendingRequest(key: string): void {
528
+ const pending = this.pendingRequests.get(key);
529
+ if (!pending) return;
530
+
531
+ this.pendingRequests.delete(key);
532
+
533
+ if (pending.offers.length === 0) {
534
+ pending.resolve(null);
535
+ return;
536
+ }
537
+
538
+ // Select best offer: prefer fewest hops, then lowest latency.
539
+ pending.offers.sort((a, b) => {
540
+ if (a.hopCount !== b.hopCount) return a.hopCount - b.hopCount;
541
+ return a.estimatedLatency - b.estimatedLatency;
542
+ });
543
+
544
+ const best = pending.offers[0];
545
+ const now = Date.now();
546
+ const route: Route = {
547
+ destHash: pending.request.destHash,
548
+ nextHop: best.offeredBy,
549
+ hopCount: best.hopCount,
550
+ estimatedLatency: best.estimatedLatency,
551
+ discoveredAt: now,
552
+ expiresAt: now + this.routeTtlMs,
553
+ };
554
+
555
+ this.routeCache.cacheRoute(route);
556
+ pending.resolve(route);
557
+ }
558
+
559
+ /** Remove pending requests that have exceeded their timeout. */
560
+ private pruneExpiredRequests(): void {
561
+ const now = Date.now();
562
+ for (const [key, pending] of this.pendingRequests) {
563
+ if (now - pending.createdAt > pending.timeoutMs) {
564
+ this.pendingRequests.delete(key);
565
+ pending.resolve(null);
566
+ }
567
+ }
568
+ }
569
+
570
+ /** Generate a random 16-byte request ID. */
571
+ private generateRequestId(): Uint8Array {
572
+ const id = new Uint8Array(16);
573
+ // globalThis.crypto is available in Node.js 19+ and all modern browsers.
574
+ globalThis.crypto.getRandomValues(id);
575
+ return id;
576
+ }
577
+ }