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.
- package/dist/src/index.d.ts +35 -0
- package/dist/src/index.js +4 -0
- package/dist/src/rpc/leave.js +17 -22
- package/dist/src/rpc/maybe-act.d.ts +4 -4
- package/dist/src/rpc/maybe-act.js +4 -24
- package/dist/src/rpc/neighbors.d.ts +3 -3
- package/dist/src/rpc/neighbors.js +9 -26
- package/dist/src/rpc/ping.d.ts +3 -2
- package/dist/src/rpc/ping.js +17 -25
- package/dist/src/rpc/protocols.d.ts +7 -0
- package/dist/src/rpc/protocols.js +45 -0
- package/dist/src/selector/next-hop.d.ts +21 -1
- package/dist/src/selector/next-hop.js +107 -4
- package/dist/src/service/dedup-cache.d.ts +12 -0
- package/dist/src/service/dedup-cache.js +43 -0
- package/dist/src/service/fret-service.d.ts +40 -1
- package/dist/src/service/fret-service.js +490 -75
- package/dist/src/service/libp2p-fret-service.d.ts +8 -3
- package/dist/src/service/libp2p-fret-service.js +23 -8
- package/dist/src/service/payload-heuristic.d.ts +28 -0
- package/dist/src/service/payload-heuristic.js +63 -0
- package/dist/src/service/peer-discovery.d.ts +35 -0
- package/dist/src/service/peer-discovery.js +81 -0
- package/package.json +12 -8
- package/src/index.ts +44 -0
- package/src/rpc/leave.ts +68 -75
- package/src/rpc/maybe-act.ts +8 -25
- package/src/rpc/neighbors.ts +12 -25
- package/src/rpc/ping.ts +20 -26
- package/src/rpc/protocols.ts +53 -0
- package/src/selector/next-hop.ts +168 -3
- package/src/service/dedup-cache.ts +44 -0
- package/src/service/fret-service.ts +1394 -952
- package/src/service/libp2p-fret-service.ts +28 -11
- package/src/service/payload-heuristic.ts +77 -0
- package/src/service/peer-discovery.ts +94 -0
- package/dist/index.min.js +0 -28
- package/dist/index.min.js.map +0 -7
package/dist/src/index.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/src/rpc/leave.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
43
|
-
|
|
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
|
-
}
|
package/dist/src/rpc/ping.d.ts
CHANGED
|
@@ -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;
|
package/dist/src/rpc/ping.js
CHANGED
|
@@ -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(
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|