p2p-fret 0.3.0 → 0.4.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 (38) hide show
  1. package/dist/src/index.d.ts +35 -0
  2. package/dist/src/index.js +4 -0
  3. package/dist/src/rpc/leave.js +17 -22
  4. package/dist/src/rpc/maybe-act.d.ts +4 -4
  5. package/dist/src/rpc/maybe-act.js +4 -24
  6. package/dist/src/rpc/neighbors.d.ts +3 -3
  7. package/dist/src/rpc/neighbors.js +9 -26
  8. package/dist/src/rpc/ping.d.ts +3 -2
  9. package/dist/src/rpc/ping.js +17 -25
  10. package/dist/src/rpc/protocols.d.ts +7 -0
  11. package/dist/src/rpc/protocols.js +45 -0
  12. package/dist/src/selector/next-hop.d.ts +21 -1
  13. package/dist/src/selector/next-hop.js +107 -4
  14. package/dist/src/service/dedup-cache.d.ts +12 -0
  15. package/dist/src/service/dedup-cache.js +43 -0
  16. package/dist/src/service/fret-service.d.ts +40 -1
  17. package/dist/src/service/fret-service.js +490 -75
  18. package/dist/src/service/libp2p-fret-service.d.ts +8 -3
  19. package/dist/src/service/libp2p-fret-service.js +23 -8
  20. package/dist/src/service/payload-heuristic.d.ts +28 -0
  21. package/dist/src/service/payload-heuristic.js +63 -0
  22. package/dist/src/service/peer-discovery.d.ts +35 -0
  23. package/dist/src/service/peer-discovery.js +81 -0
  24. package/package.json +12 -8
  25. package/src/index.ts +44 -0
  26. package/src/rpc/leave.ts +68 -75
  27. package/src/rpc/maybe-act.ts +8 -25
  28. package/src/rpc/neighbors.ts +12 -25
  29. package/src/rpc/ping.ts +20 -26
  30. package/src/rpc/protocols.ts +53 -0
  31. package/src/selector/next-hop.ts +168 -3
  32. package/src/service/dedup-cache.ts +44 -0
  33. package/src/service/fret-service.ts +1394 -952
  34. package/src/service/libp2p-fret-service.ts +28 -11
  35. package/src/service/payload-heuristic.ts +77 -0
  36. package/src/service/peer-discovery.ts +94 -0
  37. package/dist/index.min.js +0 -28
  38. package/dist/index.min.js.map +0 -7
@@ -45,11 +45,40 @@ export interface NearAnchorV1 {
45
45
  estimated_cluster_size: number;
46
46
  confidence: number;
47
47
  }
48
+ export interface BusyResponseV1 {
49
+ v: 1;
50
+ busy: true;
51
+ retry_after_ms: number;
52
+ }
48
53
  export interface ReportEvent {
49
54
  peerId: string;
50
55
  type: 'good' | 'bad';
51
56
  reason?: string;
52
57
  }
58
+ /** Callback for performing an activity (pend/commit) when in-cluster. */
59
+ export type ActivityHandler = (activity: string, cohort: string[], minSigs: number, correlationId: string) => Promise<{
60
+ commitCertificate: string;
61
+ }>;
62
+ /** Progressive result events emitted by iterative lookup. */
63
+ export interface RouteProgress {
64
+ type: 'probing' | 'forwarding' | 'near_anchor' | 'activity_sent' | 'complete' | 'exhausted';
65
+ hop?: number;
66
+ peerId?: string;
67
+ nearAnchor?: NearAnchorV1;
68
+ result?: {
69
+ commitCertificate: string;
70
+ };
71
+ ttlRemaining?: number;
72
+ }
73
+ /** Options for initiating an iterative lookup. */
74
+ export interface LookupOptions {
75
+ wantK: number;
76
+ minSigs: number;
77
+ activity?: string;
78
+ digest?: string;
79
+ ttl?: number;
80
+ maxAttempts?: number;
81
+ }
53
82
  export interface FretService {
54
83
  start(): Promise<void>;
55
84
  stop(): Promise<void>;
@@ -77,12 +106,18 @@ export interface FretService {
77
106
  };
78
107
  getNetworkChurn(): number;
79
108
  detectPartition(): boolean;
109
+ setActivityHandler(handler: ActivityHandler): void;
110
+ iterativeLookup(key: Uint8Array, options: LookupOptions): AsyncGenerator<RouteProgress>;
80
111
  exportTable(): SerializedTable;
81
112
  importTable(table: SerializedTable): number;
82
113
  }
83
114
  export type { SerializedPeerEntry, SerializedTable };
84
115
  export { FretService as FretServiceImpl } from './service/fret-service.js';
85
116
  export { seedDiscovery } from './service/discovery.js';
117
+ export { FretPeerDiscovery, type FretPeerDiscoveryConfig } from './service/peer-discovery.js';
86
118
  export { Libp2pFretService, fretService } from './service/libp2p-fret-service.js';
87
119
  export { hashKey, hashPeerId } from './ring/hash.js';
120
+ export { shouldIncludePayload, computeNearRadius } from './service/payload-heuristic.js';
121
+ export { DedupCache } from './service/dedup-cache.js';
122
+ export { validateTimestamp, readAllBounded } from './rpc/protocols.js';
88
123
  export declare function createFret(node: any, cfg?: Partial<FretConfig>): FretService;
package/dist/src/index.js CHANGED
@@ -1,8 +1,12 @@
1
1
  export { FretService as FretServiceImpl } from './service/fret-service.js';
2
2
  import { FretService as FretServiceClass } from './service/fret-service.js';
3
3
  export { seedDiscovery } from './service/discovery.js';
4
+ export { FretPeerDiscovery } from './service/peer-discovery.js';
4
5
  export { Libp2pFretService, fretService } from './service/libp2p-fret-service.js';
5
6
  export { hashKey, hashPeerId } from './ring/hash.js';
7
+ export { shouldIncludePayload, computeNearRadius } from './service/payload-heuristic.js';
8
+ export { DedupCache } from './service/dedup-cache.js';
9
+ export { validateTimestamp, readAllBounded } from './rpc/protocols.js';
6
10
  export function createFret(node, cfg) {
7
11
  return new FretServiceClass(node, cfg);
8
12
  }
@@ -1,12 +1,27 @@
1
1
  import { peerIdFromString } from '@libp2p/peer-id';
2
- import { PROTOCOL_LEAVE, encodeJson, decodeJson } from './protocols.js';
2
+ import { PROTOCOL_LEAVE, encodeJson, decodeJson, readAllBounded } from './protocols.js';
3
3
  import { createLogger } from '../logger.js';
4
4
  const log = createLogger('rpc:leave');
5
+ const MAX_REPLACEMENTS = 12;
6
+ function sanitizeReplacements(ids) {
7
+ if (!ids || ids.length === 0)
8
+ return undefined;
9
+ const valid = [];
10
+ for (const id of ids.slice(0, MAX_REPLACEMENTS)) {
11
+ try {
12
+ peerIdFromString(id);
13
+ valid.push(id);
14
+ }
15
+ catch { /* drop unparseable */ }
16
+ }
17
+ return valid.length > 0 ? valid : undefined;
18
+ }
5
19
  export function registerLeave(node, onLeave, protocol = PROTOCOL_LEAVE) {
6
20
  void node.handle(protocol, async (stream) => {
7
21
  try {
8
- const bytes = await readAll(stream);
22
+ const bytes = await readAllBounded(stream, 4096);
9
23
  const msg = await decodeJson(bytes);
24
+ msg.replacements = sanitizeReplacements(msg.replacements);
10
25
  await onLeave(msg);
11
26
  stream.send(await encodeJson({ ok: true }));
12
27
  await stream.close();
@@ -39,23 +54,3 @@ export async function sendLeave(node, peerIdStr, notice, protocol = PROTOCOL_LEA
39
54
  }
40
55
  }
41
56
  }
42
- function toBytes(chunk) {
43
- if (chunk instanceof Uint8Array)
44
- return chunk;
45
- return chunk.subarray();
46
- }
47
- async function readAll(stream) {
48
- const parts = [];
49
- for await (const chunk of stream)
50
- parts.push(toBytes(chunk));
51
- let len = 0;
52
- for (const p of parts)
53
- len += p.length;
54
- const out = new Uint8Array(len);
55
- let o = 0;
56
- for (const p of parts) {
57
- out.set(p, o);
58
- o += p.length;
59
- }
60
- return out;
61
- }
@@ -1,8 +1,8 @@
1
1
  import type { Libp2p } from 'libp2p';
2
- import type { RouteAndMaybeActV1, NearAnchorV1 } from '../index.js';
3
- export declare function registerMaybeAct(node: Libp2p, handle: (msg: RouteAndMaybeActV1) => Promise<NearAnchorV1 | {
2
+ import type { RouteAndMaybeActV1, NearAnchorV1, BusyResponseV1 } from '../index.js';
3
+ export declare function registerMaybeAct(node: Libp2p, handle: (msg: RouteAndMaybeActV1) => Promise<NearAnchorV1 | BusyResponseV1 | {
4
4
  commitCertificate: string;
5
- }>, protocol?: string): void;
6
- export declare function sendMaybeAct(node: Libp2p, peerIdStr: string, msg: RouteAndMaybeActV1, protocol?: string): Promise<NearAnchorV1 | {
5
+ }>, protocol?: string, maxBytes?: number): void;
6
+ export declare function sendMaybeAct(node: Libp2p, peerIdStr: string, msg: RouteAndMaybeActV1, protocol?: string): Promise<NearAnchorV1 | BusyResponseV1 | {
7
7
  commitCertificate: string;
8
8
  }>;
@@ -1,11 +1,11 @@
1
1
  import { peerIdFromString } from '@libp2p/peer-id';
2
- import { PROTOCOL_MAYBE_ACT, encodeJson, decodeJson } from './protocols.js';
2
+ import { PROTOCOL_MAYBE_ACT, encodeJson, decodeJson, readAllBounded } from './protocols.js';
3
3
  import { createLogger } from '../logger.js';
4
4
  const log = createLogger('rpc:maybeAct');
5
- export function registerMaybeAct(node, handle, protocol = PROTOCOL_MAYBE_ACT) {
5
+ export function registerMaybeAct(node, handle, protocol = PROTOCOL_MAYBE_ACT, maxBytes = 512 * 1024) {
6
6
  void node.handle(protocol, async (stream) => {
7
7
  try {
8
- const bytes = await readAll(stream);
8
+ const bytes = await readAllBounded(stream, maxBytes);
9
9
  const msg = await decodeJson(bytes);
10
10
  const res = await handle(msg);
11
11
  stream.send(await encodeJson(res));
@@ -29,7 +29,7 @@ export async function sendMaybeAct(node, peerIdStr, msg, protocol = PROTOCOL_MAY
29
29
  }
30
30
  stream.send(await encodeJson(msg));
31
31
  await stream.close();
32
- const bytes = await readAll(stream);
32
+ const bytes = await readAllBounded(stream, 512 * 1024);
33
33
  return await decodeJson(bytes);
34
34
  }
35
35
  finally {
@@ -41,23 +41,3 @@ export async function sendMaybeAct(node, peerIdStr, msg, protocol = PROTOCOL_MAY
41
41
  }
42
42
  }
43
43
  }
44
- function toBytes(chunk) {
45
- if (chunk instanceof Uint8Array)
46
- return chunk;
47
- return chunk.subarray();
48
- }
49
- async function readAll(stream) {
50
- const parts = [];
51
- for await (const chunk of stream)
52
- parts.push(toBytes(chunk));
53
- let len = 0;
54
- for (const p of parts)
55
- len += p.length;
56
- const out = new Uint8Array(len);
57
- let o = 0;
58
- for (const p of parts) {
59
- out.set(p, o);
60
- o += p.length;
61
- }
62
- return out;
63
- }
@@ -1,8 +1,8 @@
1
1
  import type { Libp2p } from 'libp2p';
2
- import type { NeighborSnapshotV1 } from '../index.js';
3
- export declare function registerNeighbors(node: Libp2p, getSnapshot: () => NeighborSnapshotV1 | Promise<NeighborSnapshotV1>, onAnnounce?: (from: string, snapshot: NeighborSnapshotV1) => void, protocols?: {
2
+ import type { NeighborSnapshotV1, BusyResponseV1 } from '../index.js';
3
+ export declare function registerNeighbors(node: Libp2p, getSnapshot: () => NeighborSnapshotV1 | BusyResponseV1 | Promise<NeighborSnapshotV1 | BusyResponseV1>, onAnnounce?: (from: string, snapshot: NeighborSnapshotV1) => void, protocols?: {
4
4
  PROTOCOL_NEIGHBORS: string;
5
5
  PROTOCOL_NEIGHBORS_ANNOUNCE: string;
6
- }): void;
6
+ }, maxBytes?: number): void;
7
7
  export declare function fetchNeighbors(node: Libp2p, peerIdOrStr: string, protocol?: string): Promise<NeighborSnapshotV1>;
8
8
  export declare function announceNeighbors(node: Libp2p, peerIdOrStr: string, snapshot: NeighborSnapshotV1, protocol?: string): Promise<void>;
@@ -1,8 +1,8 @@
1
1
  import { peerIdFromString } from '@libp2p/peer-id';
2
- import { PROTOCOL_NEIGHBORS, PROTOCOL_NEIGHBORS_ANNOUNCE, encodeJson, decodeJson, } from './protocols.js';
2
+ import { PROTOCOL_NEIGHBORS, PROTOCOL_NEIGHBORS_ANNOUNCE, encodeJson, decodeJson, readAllBounded, } from './protocols.js';
3
3
  import { createLogger } from '../logger.js';
4
4
  const log = createLogger('rpc:neighbors');
5
- export function registerNeighbors(node, getSnapshot, onAnnounce, protocols = { PROTOCOL_NEIGHBORS, PROTOCOL_NEIGHBORS_ANNOUNCE }) {
5
+ export function registerNeighbors(node, getSnapshot, onAnnounce, protocols = { PROTOCOL_NEIGHBORS, PROTOCOL_NEIGHBORS_ANNOUNCE }, maxBytes = 128 * 1024) {
6
6
  void node.handle(protocols.PROTOCOL_NEIGHBORS, async (stream) => {
7
7
  try {
8
8
  const snap = await getSnapshot();
@@ -13,11 +13,10 @@ export function registerNeighbors(node, getSnapshot, onAnnounce, protocols = { P
13
13
  log.error('neighbors handler error - %e', err);
14
14
  }
15
15
  });
16
- // Optional: accept pushed announcements of neighbor snapshots
17
16
  if (onAnnounce) {
18
17
  void node.handle(protocols.PROTOCOL_NEIGHBORS_ANNOUNCE, async (stream) => {
19
18
  try {
20
- const bytes = await readAll(stream);
19
+ const bytes = await readAllBounded(stream, maxBytes);
21
20
  const snap = await decodeJson(bytes);
22
21
  onAnnounce(snap.from, snap);
23
22
  stream.send(await encodeJson({ ok: true }));
@@ -39,8 +38,12 @@ export async function fetchNeighbors(node, peerIdOrStr, protocol = PROTOCOL_NEIG
39
38
  let stream;
40
39
  try {
41
40
  stream = await conns[0].newStream([protocol]);
42
- const bytes = await readAll(stream);
43
- return await decodeJson(bytes);
41
+ const bytes = await readAllBounded(stream, 128 * 1024);
42
+ const res = await decodeJson(bytes);
43
+ if ('busy' in res && res.busy) {
44
+ return { v: 1, from: peerIdOrStr, timestamp: Date.now(), successors: [], predecessors: [], sig: '' };
45
+ }
46
+ return res;
44
47
  }
45
48
  catch (err) {
46
49
  log.error('fetchNeighbors decode failed for %s - %e', peerIdOrStr, err);
@@ -79,23 +82,3 @@ export async function announceNeighbors(node, peerIdOrStr, snapshot, protocol =
79
82
  }
80
83
  }
81
84
  }
82
- function toBytes(chunk) {
83
- if (chunk instanceof Uint8Array)
84
- return chunk;
85
- return chunk.subarray();
86
- }
87
- async function readAll(stream) {
88
- const parts = [];
89
- for await (const chunk of stream)
90
- parts.push(toBytes(chunk));
91
- let len = 0;
92
- for (const p of parts)
93
- len += p.length;
94
- const out = new Uint8Array(len);
95
- let o = 0;
96
- for (const p of parts) {
97
- out.set(p, o);
98
- o += p.length;
99
- }
100
- return out;
101
- }
@@ -1,4 +1,5 @@
1
1
  import type { Libp2p } from 'libp2p';
2
+ import type { BusyResponseV1 } from '../index.js';
2
3
  export interface PingResponseV1 {
3
4
  ok: boolean;
4
5
  ts: number;
@@ -8,10 +9,10 @@ export interface PingResponseV1 {
8
9
  export type SizeEstimateProvider = () => {
9
10
  size_estimate?: number;
10
11
  confidence?: number;
11
- } | Promise<{
12
+ } | BusyResponseV1 | Promise<{
12
13
  size_estimate?: number;
13
14
  confidence?: number;
14
- }>;
15
+ } | BusyResponseV1>;
15
16
  export declare function registerPing(node: Libp2p, protocol?: string, getSizeEstimate?: SizeEstimateProvider): void;
16
17
  export declare function sendPing(node: Libp2p, peer: string, protocol?: string): Promise<{
17
18
  ok: boolean;
@@ -1,24 +1,34 @@
1
1
  import { peerIdFromString } from '@libp2p/peer-id';
2
- import { PROTOCOL_PING, encodeJson, decodeJson } from './protocols.js';
2
+ import { PROTOCOL_PING, encodeJson, decodeJson, readAllBounded } from './protocols.js';
3
3
  import { createLogger } from '../logger.js';
4
4
  const log = createLogger('rpc:ping');
5
+ function isBusy(res) {
6
+ return typeof res === 'object' && res !== null && 'busy' in res && res.busy === true;
7
+ }
5
8
  export function registerPing(node, protocol = PROTOCOL_PING, getSizeEstimate) {
6
9
  void node.handle(protocol, async (stream) => {
7
- const response = { ok: true, ts: Date.now() };
8
- // Add network size hint if provider available
9
10
  if (getSizeEstimate) {
10
11
  try {
11
12
  const sizeInfo = await getSizeEstimate();
13
+ if (isBusy(sizeInfo)) {
14
+ stream.send(await encodeJson(sizeInfo));
15
+ await stream.close();
16
+ return;
17
+ }
18
+ const response = { ok: true, ts: Date.now() };
12
19
  if (sizeInfo.size_estimate !== undefined) {
13
20
  response.size_estimate = sizeInfo.size_estimate;
14
21
  response.confidence = sizeInfo.confidence;
15
22
  }
23
+ stream.send(await encodeJson(response));
24
+ await stream.close();
25
+ return;
16
26
  }
17
27
  catch (err) {
18
28
  log.error('getSizeEstimate failed - %e', err);
19
29
  }
20
30
  }
21
- stream.send(await encodeJson(response));
31
+ stream.send(await encodeJson({ ok: true, ts: Date.now() }));
22
32
  await stream.close();
23
33
  });
24
34
  }
@@ -34,12 +44,14 @@ export async function sendPing(node, peer, protocol = PROTOCOL_PING) {
34
44
  else {
35
45
  stream = await node.dialProtocol(pid, [protocol]);
36
46
  }
37
- const bytes = await readAll(stream);
47
+ const bytes = await readAllBounded(stream, 1024);
38
48
  const rttMs = Math.max(0, Date.now() - start);
39
49
  if (bytes.length === 0)
40
50
  return { ok: false, rttMs };
41
51
  try {
42
52
  const res = await decodeJson(bytes);
53
+ if (isBusy(res))
54
+ return { ok: false, rttMs };
43
55
  return {
44
56
  ok: Boolean(res.ok),
45
57
  rttMs,
@@ -61,23 +73,3 @@ export async function sendPing(node, peer, protocol = PROTOCOL_PING) {
61
73
  }
62
74
  }
63
75
  }
64
- function toBytes(chunk) {
65
- if (chunk instanceof Uint8Array)
66
- return chunk;
67
- return chunk.subarray();
68
- }
69
- async function readAll(stream) {
70
- const parts = [];
71
- for await (const chunk of stream)
72
- parts.push(toBytes(chunk));
73
- let len = 0;
74
- for (const p of parts)
75
- len += p.length;
76
- const out = new Uint8Array(len);
77
- let o = 0;
78
- for (const p of parts) {
79
- out.set(p, o);
80
- o += p.length;
81
- }
82
- return out;
83
- }
@@ -12,3 +12,10 @@ export declare const PROTOCOL_LEAVE = "/optimystic/default/fret/1.0.0/leave";
12
12
  export declare const PROTOCOL_PING = "/optimystic/default/fret/1.0.0/ping";
13
13
  export declare function encodeJson(obj: unknown): Promise<Uint8Array>;
14
14
  export declare function decodeJson<T = unknown>(bytes: Uint8Array): Promise<T>;
15
+ export declare function toBytes(chunk: Uint8Array | {
16
+ subarray(): Uint8Array;
17
+ }): Uint8Array;
18
+ export declare function readAllBounded(stream: AsyncIterable<Uint8Array | {
19
+ subarray(): Uint8Array;
20
+ }>, maxBytes: number, timeoutMs?: number): Promise<Uint8Array>;
21
+ export declare function validateTimestamp(ts: number, maxDriftMs?: number): boolean;
@@ -34,3 +34,48 @@ export async function decodeJson(bytes) {
34
34
  const text = new TextDecoder().decode(bytes.subarray(start, end));
35
35
  return JSON.parse(text);
36
36
  }
37
+ export function toBytes(chunk) {
38
+ if (chunk instanceof Uint8Array)
39
+ return chunk;
40
+ return chunk.subarray();
41
+ }
42
+ export async function readAllBounded(stream, maxBytes, timeoutMs = 5000) {
43
+ const parts = [];
44
+ let len = 0;
45
+ const iter = stream[Symbol.asyncIterator]();
46
+ const deadline = Date.now() + timeoutMs;
47
+ // Short idle timeout after first data arrives — works around muxer
48
+ // implementations that fail to propagate remote-close EOF.
49
+ const idleMs = 100;
50
+ while (true) {
51
+ const remaining = deadline - Date.now();
52
+ if (remaining <= 0)
53
+ break;
54
+ const chunkTimeout = len > 0 ? Math.min(remaining, idleMs) : remaining;
55
+ let timer;
56
+ const timeout = new Promise(r => {
57
+ timer = setTimeout(() => r({ done: true, value: undefined }), chunkTimeout);
58
+ });
59
+ const next = iter.next();
60
+ next.catch(() => { }); // Prevent unhandled rejection if timeout wins
61
+ const result = await Promise.race([next, timeout]);
62
+ clearTimeout(timer);
63
+ if (result.done)
64
+ break;
65
+ const bytes = toBytes(result.value);
66
+ len += bytes.length;
67
+ if (len > maxBytes)
68
+ throw new Error(`payload too large: ${len} exceeds ${maxBytes} byte limit`);
69
+ parts.push(bytes);
70
+ }
71
+ const out = new Uint8Array(len);
72
+ let o = 0;
73
+ for (const p of parts) {
74
+ out.set(p, o);
75
+ o += p.length;
76
+ }
77
+ return out;
78
+ }
79
+ export function validateTimestamp(ts, maxDriftMs = 300_000) {
80
+ return Math.abs(Date.now() - ts) <= maxDriftMs;
81
+ }
@@ -1,4 +1,24 @@
1
1
  import type { DigitreeStore } from '../store/digitree-store.js';
2
2
  export type LinkQuality = (id: string) => number;
3
3
  export type IsConnected = (id: string) => boolean;
4
- export declare function chooseNextHop(store: DigitreeStore, targetCoord: Uint8Array, candidates: string[], isConnected: IsConnected, linkQ: LinkQuality, connectedToleranceBytes?: number): string | undefined;
4
+ export type BackoffPenalty = (id: string) => number;
5
+ export interface NextHopOptions {
6
+ /** Near-radius threshold; distances ≤ this trigger strict mode. */
7
+ nearRadius?: Uint8Array;
8
+ /** Confidence in network size estimate [0,1]; adjusts weight balance. */
9
+ confidence?: number;
10
+ /** Per-peer backoff penalty [0,1]; penalizes recently-failed peers. */
11
+ backoffPenalty?: BackoffPenalty;
12
+ /**
13
+ * Legacy tolerance: connected peers within this many leading-zero-byte
14
+ * difference are preferred (default 1). Ignored when nearRadius is set.
15
+ */
16
+ connectedToleranceBytes?: number;
17
+ }
18
+ /**
19
+ * Select the best next hop from candidates.
20
+ *
21
+ * Backwards compatible: when called without options (or with just
22
+ * connectedToleranceBytes), falls back to the original leading-byte heuristic.
23
+ */
24
+ export declare function chooseNextHop(store: DigitreeStore, targetCoord: Uint8Array, candidates: string[], isConnected: IsConnected, linkQ: LinkQuality, optionsOrTolerance?: NextHopOptions | number): string | undefined;
@@ -18,11 +18,115 @@ function betterByDist(idA, distA, idB, distB) {
18
18
  return true;
19
19
  if (lexLess(distB, distA))
20
20
  return false;
21
- // equal distance: tie-break by peer id lex order
22
21
  return idA < idB;
23
22
  }
24
- export function chooseNextHop(store, targetCoord, candidates, isConnected, linkQ, connectedToleranceBytes = 1 // connected peers within this many leading bytes are preferred
25
- ) {
23
+ function isNear(dist, nearRadius) {
24
+ return !lexLess(nearRadius, dist); // dist <= nearRadius
25
+ }
26
+ function weightsForContext(near, confidence) {
27
+ // Base weights (far)
28
+ let wD = 0.4;
29
+ let wConn = 0.4;
30
+ const wQ = 0.1;
31
+ const wB = 0.1;
32
+ if (near) {
33
+ // Strict distance when close to target
34
+ wD = 0.7;
35
+ wConn = 0.1;
36
+ }
37
+ // Confidence adjustment: low confidence → rely on connections, not distance
38
+ const cAdj = (confidence - 0.5) * 0.2; // range [-0.1, 0.1]
39
+ wD = Math.max(0.1, wD + cAdj);
40
+ wConn = Math.max(0.05, wConn - cAdj);
41
+ return { wD, wConn, wQ, wB };
42
+ }
43
+ function normalizeDistance(dist) {
44
+ // Count leading zero bits for fine-grained log-distance [0,1]
45
+ // 0 = identical (distance zero), 1 = maximally far
46
+ let lzBits = 0;
47
+ for (let i = 0; i < dist.length; i++) {
48
+ const v = dist[i];
49
+ if (v === 0) {
50
+ lzBits += 8;
51
+ continue;
52
+ }
53
+ lzBits += Math.clz32(v) - 24; // clz32 counts for 32-bit; subtract 24 for byte
54
+ break;
55
+ }
56
+ const totalBits = dist.length * 8;
57
+ if (lzBits >= totalBits)
58
+ return 0;
59
+ return Math.max(0, Math.min(1, 1 - lzBits / totalBits));
60
+ }
61
+ function cost(normDist, connected, linkQ, backoff, w) {
62
+ return (w.wD * normDist
63
+ - w.wConn * (connected ? 1 : 0)
64
+ - w.wQ * linkQ
65
+ + w.wB * backoff);
66
+ }
67
+ // ── Public API ───────────────────────────────────────────────────────
68
+ /**
69
+ * Select the best next hop from candidates.
70
+ *
71
+ * Backwards compatible: when called without options (or with just
72
+ * connectedToleranceBytes), falls back to the original leading-byte heuristic.
73
+ */
74
+ export function chooseNextHop(store, targetCoord, candidates, isConnected, linkQ, optionsOrTolerance) {
75
+ const opts = typeof optionsOrTolerance === 'number'
76
+ ? { connectedToleranceBytes: optionsOrTolerance }
77
+ : optionsOrTolerance ?? {};
78
+ // If nearRadius is provided, use cost-function path
79
+ if (opts.nearRadius) {
80
+ return chooseNextHopCost(store, targetCoord, candidates, isConnected, linkQ, opts);
81
+ }
82
+ // Legacy connected-first heuristic (tolerance-bytes based)
83
+ return chooseNextHopLegacy(store, targetCoord, candidates, isConnected, linkQ, opts.connectedToleranceBytes ?? 1);
84
+ }
85
+ function chooseNextHopCost(store, targetCoord, candidates, isConnected, linkQ, opts) {
86
+ const confidence = opts.confidence ?? 0.5;
87
+ const backoff = opts.backoffPenalty ?? (() => 0);
88
+ const nearRadius = opts.nearRadius;
89
+ const scored = [];
90
+ for (const id of candidates) {
91
+ const entry = store.getById(id);
92
+ if (!entry)
93
+ continue;
94
+ const dist = xorDistance(entry.coord, targetCoord);
95
+ const near = isNear(dist, nearRadius);
96
+ const connected = isConnected(id);
97
+ const w = weightsForContext(near, confidence);
98
+ const costVal = cost(normalizeDistance(dist), connected, linkQ(id), backoff(id), w);
99
+ scored.push({ id, dist, near, connected, costVal });
100
+ }
101
+ if (scored.length === 0)
102
+ return undefined;
103
+ // Partition: near-mode candidates use strict distance ordering;
104
+ // far-mode candidates use cost function.
105
+ const nearCandidates = scored.filter(s => s.near);
106
+ const farCandidates = scored.filter(s => !s.near);
107
+ // Near mode: strict distance improvement (ε ≈ 0); connection only breaks ties
108
+ if (nearCandidates.length > 0) {
109
+ nearCandidates.sort((a, b) => {
110
+ if (betterByDist(a.id, a.dist, b.id, b.dist))
111
+ return -1;
112
+ if (betterByDist(b.id, b.dist, a.id, a.dist))
113
+ return 1;
114
+ // Equal distance: prefer connected, then lower cost
115
+ if (a.connected !== b.connected)
116
+ return a.connected ? -1 : 1;
117
+ return a.costVal - b.costVal;
118
+ });
119
+ return nearCandidates[0].id;
120
+ }
121
+ // Far mode: use cost function
122
+ farCandidates.sort((a, b) => {
123
+ if (a.costVal !== b.costVal)
124
+ return a.costVal - b.costVal;
125
+ return betterByDist(a.id, a.dist, b.id, b.dist) ? -1 : 1;
126
+ });
127
+ return farCandidates[0].id;
128
+ }
129
+ function chooseNextHopLegacy(store, targetCoord, candidates, isConnected, linkQ, connectedToleranceBytes) {
26
130
  let bestByDist;
27
131
  const scored = [];
28
132
  for (const id of candidates) {
@@ -49,7 +153,6 @@ export function chooseNextHop(store, targetCoord, candidates, isConnected, linkQ
49
153
  bestConnected = { id: s.id, dist: s.dist, score: s.score };
50
154
  continue;
51
155
  }
52
- // Prefer strictly better distance; tie-break by higher score; then by id
53
156
  if (betterByDist(s.id, s.dist, bestConnected.id, bestConnected.dist)) {
54
157
  bestConnected = { id: s.id, dist: s.dist, score: s.score };
55
158
  }
@@ -0,0 +1,12 @@
1
+ /** Bounded TTL cache for correlation-ID deduplication. */
2
+ export declare class DedupCache<T> {
3
+ private readonly entries;
4
+ private readonly ttlMs;
5
+ private readonly maxSize;
6
+ constructor(ttlMs?: number, maxSize?: number);
7
+ get(key: string): T | undefined;
8
+ has(key: string): boolean;
9
+ set(key: string, result: T): void;
10
+ private evictExpired;
11
+ private evictOldest;
12
+ }
@@ -0,0 +1,43 @@
1
+ /** Bounded TTL cache for correlation-ID deduplication. */
2
+ export class DedupCache {
3
+ entries = new Map();
4
+ ttlMs;
5
+ maxSize;
6
+ constructor(ttlMs = 30_000, maxSize = 1024) {
7
+ this.ttlMs = ttlMs;
8
+ this.maxSize = maxSize;
9
+ }
10
+ get(key) {
11
+ const e = this.entries.get(key);
12
+ if (!e)
13
+ return undefined;
14
+ if (e.expires < Date.now()) {
15
+ this.entries.delete(key);
16
+ return undefined;
17
+ }
18
+ return e.result;
19
+ }
20
+ has(key) {
21
+ return this.get(key) !== undefined;
22
+ }
23
+ set(key, result) {
24
+ if (this.entries.size >= this.maxSize)
25
+ this.evictExpired();
26
+ if (this.entries.size >= this.maxSize)
27
+ this.evictOldest();
28
+ this.entries.set(key, { result, expires: Date.now() + this.ttlMs });
29
+ }
30
+ evictExpired() {
31
+ const now = Date.now();
32
+ for (const [k, v] of this.entries) {
33
+ if (v.expires < now)
34
+ this.entries.delete(k);
35
+ }
36
+ }
37
+ evictOldest() {
38
+ // Delete the first (oldest-inserted) entry
39
+ const first = this.entries.keys().next();
40
+ if (!first.done)
41
+ this.entries.delete(first.value);
42
+ }
43
+ }