@peerbit/libp2p-test-utils 2.2.0-bbf27fa → 2.2.0-cb91e7b
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.
|
@@ -0,0 +1,1170 @@
|
|
|
1
|
+
import {
|
|
2
|
+
UnsupportedProtocolError,
|
|
3
|
+
type AbortOptions,
|
|
4
|
+
type Connection,
|
|
5
|
+
type Libp2pEvents,
|
|
6
|
+
type PeerId,
|
|
7
|
+
type PrivateKey,
|
|
8
|
+
type Stream,
|
|
9
|
+
type TypedEventTarget,
|
|
10
|
+
} from "@libp2p/interface";
|
|
11
|
+
import { type Multiaddr, multiaddr } from "@multiformats/multiaddr";
|
|
12
|
+
import { getPublicKeyFromPeerId } from "@peerbit/crypto";
|
|
13
|
+
import { pushable, type Pushable } from "it-pushable";
|
|
14
|
+
import sodium from "libsodium-wrappers";
|
|
15
|
+
|
|
16
|
+
type ProtocolHandler = (stream: Stream, connection: Connection) => Promise<void>;
|
|
17
|
+
|
|
18
|
+
type Topology = {
|
|
19
|
+
onConnect: (peerId: PeerId, connection: Connection) => Promise<void> | void;
|
|
20
|
+
onDisconnect: (peerId: PeerId, connection: Connection) => Promise<void> | void;
|
|
21
|
+
notifyOnLimitedConnection?: boolean;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type InMemoryNetworkMetrics = {
|
|
25
|
+
dials: number;
|
|
26
|
+
connectionsOpened: number;
|
|
27
|
+
connectionsClosed: number;
|
|
28
|
+
streamsOpened: number;
|
|
29
|
+
framesSent: number;
|
|
30
|
+
bytesSent: number;
|
|
31
|
+
dataFramesSent: number;
|
|
32
|
+
ackFramesSent: number;
|
|
33
|
+
goodbyeFramesSent: number;
|
|
34
|
+
otherFramesSent: number;
|
|
35
|
+
framesDropped: number;
|
|
36
|
+
bytesDropped: number;
|
|
37
|
+
dataFramesDropped: number;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type InMemoryNetworkPeerMetrics = {
|
|
41
|
+
framesSent: number;
|
|
42
|
+
bytesSent: number;
|
|
43
|
+
dataFramesSent: number;
|
|
44
|
+
ackFramesSent: number;
|
|
45
|
+
goodbyeFramesSent: number;
|
|
46
|
+
otherFramesSent: number;
|
|
47
|
+
framesDropped: number;
|
|
48
|
+
bytesDropped: number;
|
|
49
|
+
dataFramesDropped: number;
|
|
50
|
+
maxBytesPerSecond: number;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
type InMemoryNetworkPeerWindow = InMemoryNetworkPeerMetrics & {
|
|
54
|
+
_currentSecond: number;
|
|
55
|
+
_bytesThisSecond: number;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const parseTcpPort = (addr: Multiaddr): number | undefined => {
|
|
59
|
+
const str = addr.toString();
|
|
60
|
+
const m = str.match(/\/tcp\/(\d+)/);
|
|
61
|
+
if (!m) return undefined;
|
|
62
|
+
return Number(m[1]);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const isPeerId = (value: unknown): value is PeerId => {
|
|
66
|
+
return (
|
|
67
|
+
typeof value === "object" &&
|
|
68
|
+
value != null &&
|
|
69
|
+
typeof (value as any).toString === "function" &&
|
|
70
|
+
(value as any).type != null &&
|
|
71
|
+
(value as any).publicKey != null
|
|
72
|
+
);
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const decodeUVarint = (buf: Uint8Array): { value: number; bytes: number } => {
|
|
76
|
+
let x = 0;
|
|
77
|
+
let s = 0;
|
|
78
|
+
for (let i = 0; i < buf.length; i++) {
|
|
79
|
+
const b = buf[i]!;
|
|
80
|
+
if (b < 0x80) {
|
|
81
|
+
if (i > 9 || (i === 9 && b > 1)) throw new Error("varint overflow");
|
|
82
|
+
return { value: x | (b << s), bytes: i + 1 };
|
|
83
|
+
}
|
|
84
|
+
x |= (b & 0x7f) << s;
|
|
85
|
+
s += 7;
|
|
86
|
+
}
|
|
87
|
+
throw new Error("unexpected eof decoding varint");
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const readU32LE = (buf: Uint8Array, offset: number) =>
|
|
91
|
+
(buf[offset + 0]! |
|
|
92
|
+
(buf[offset + 1]! << 8) |
|
|
93
|
+
(buf[offset + 2]! << 16) |
|
|
94
|
+
(buf[offset + 3]! << 24)) >>>
|
|
95
|
+
0;
|
|
96
|
+
|
|
97
|
+
const shouldDropByMessageIdPrefix = (encodedFrame: Uint8Array, base: number) => {
|
|
98
|
+
// MessageHeader.id starts at base+2 (after DataMessage + MessageHeader variants).
|
|
99
|
+
const idOffset = base + 2;
|
|
100
|
+
const b0 = encodedFrame[idOffset + 0];
|
|
101
|
+
const b1 = encodedFrame[idOffset + 1];
|
|
102
|
+
const b2 = encodedFrame[idOffset + 2];
|
|
103
|
+
const b3 = encodedFrame[idOffset + 3];
|
|
104
|
+
|
|
105
|
+
// Allow loss injection without breaking stream-level control plane:
|
|
106
|
+
// - "FOUT": FanoutTree data plane
|
|
107
|
+
return (
|
|
108
|
+
b0 === 0x46 && b1 === 0x4f && b2 === 0x55 && b3 === 0x54 // "FOUT"
|
|
109
|
+
);
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const mulberry32 = (seed: number) => {
|
|
113
|
+
let t = seed >>> 0;
|
|
114
|
+
return () => {
|
|
115
|
+
t += 0x6d2b79f5;
|
|
116
|
+
let x = t;
|
|
117
|
+
x = Math.imul(x ^ (x >>> 15), x | 1);
|
|
118
|
+
x ^= x + Math.imul(x ^ (x >>> 7), x | 61);
|
|
119
|
+
return ((x ^ (x >>> 14)) >>> 0) / 4294967296;
|
|
120
|
+
};
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
export class InMemoryRegistrar {
|
|
124
|
+
private handlers = new Map<string, ProtocolHandler>();
|
|
125
|
+
private topologies = new Map<string, { protocol: string; topology: Topology }>();
|
|
126
|
+
private topologySeq = 0;
|
|
127
|
+
|
|
128
|
+
async handle(
|
|
129
|
+
protocol: string,
|
|
130
|
+
handler: ProtocolHandler,
|
|
131
|
+
_opts?: unknown,
|
|
132
|
+
): Promise<void> {
|
|
133
|
+
this.handlers.set(protocol, handler);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async unhandle(protocol: string): Promise<void> {
|
|
137
|
+
this.handlers.delete(protocol);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async register(protocol: string, topology: Topology): Promise<string> {
|
|
141
|
+
const id = `topology-${++this.topologySeq}`;
|
|
142
|
+
this.topologies.set(id, { protocol, topology });
|
|
143
|
+
return id;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async unregister(id: string): Promise<void> {
|
|
147
|
+
this.topologies.delete(id);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
getHandler(protocol: string): ProtocolHandler | undefined {
|
|
151
|
+
return this.handlers.get(protocol);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
getTopologies(): Iterable<{ protocol: string; topology: Topology }> {
|
|
155
|
+
return this.topologies.values();
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
class InMemoryStream extends EventTarget {
|
|
160
|
+
public protocol?: string;
|
|
161
|
+
public direction: "inbound" | "outbound";
|
|
162
|
+
public id: string;
|
|
163
|
+
|
|
164
|
+
private readonly inbound: Pushable<Uint8Array>;
|
|
165
|
+
private readonly readable: AsyncIterable<Uint8Array>;
|
|
166
|
+
private readonly rxDelayMs: number;
|
|
167
|
+
|
|
168
|
+
private closed = false;
|
|
169
|
+
|
|
170
|
+
private bufferedBytes = 0;
|
|
171
|
+
private readonly highWaterMark: number;
|
|
172
|
+
private readonly lowWaterMark: number;
|
|
173
|
+
private backpressured = false;
|
|
174
|
+
|
|
175
|
+
private pendingLengthPrefix?: Uint8Array;
|
|
176
|
+
|
|
177
|
+
peer?: InMemoryStream;
|
|
178
|
+
private readonly recordSend?: (encodedFrame: Uint8Array) => void;
|
|
179
|
+
private readonly shouldDrop?: (encodedFrame: Uint8Array) => boolean;
|
|
180
|
+
private readonly recordDrop?: (encodedFrame: Uint8Array) => void;
|
|
181
|
+
|
|
182
|
+
constructor(opts: {
|
|
183
|
+
id: string;
|
|
184
|
+
protocol: string;
|
|
185
|
+
direction: "inbound" | "outbound";
|
|
186
|
+
highWaterMarkBytes?: number;
|
|
187
|
+
recordSend?: (encodedFrame: Uint8Array) => void;
|
|
188
|
+
shouldDrop?: (encodedFrame: Uint8Array) => boolean;
|
|
189
|
+
recordDrop?: (encodedFrame: Uint8Array) => void;
|
|
190
|
+
rxDelayMs?: number;
|
|
191
|
+
}) {
|
|
192
|
+
super();
|
|
193
|
+
this.id = opts.id;
|
|
194
|
+
this.protocol = opts.protocol;
|
|
195
|
+
this.direction = opts.direction;
|
|
196
|
+
this.recordSend = opts.recordSend;
|
|
197
|
+
this.shouldDrop = opts.shouldDrop;
|
|
198
|
+
this.recordDrop = opts.recordDrop;
|
|
199
|
+
this.rxDelayMs = opts.rxDelayMs ?? 0;
|
|
200
|
+
this.highWaterMark = opts.highWaterMarkBytes ?? 256 * 1024;
|
|
201
|
+
this.lowWaterMark = Math.floor(this.highWaterMark / 2);
|
|
202
|
+
|
|
203
|
+
this.inbound = pushable<Uint8Array>({ objectMode: true });
|
|
204
|
+
|
|
205
|
+
const self = this;
|
|
206
|
+
this.readable = (async function* () {
|
|
207
|
+
for await (const chunk of self.inbound) {
|
|
208
|
+
if (self.rxDelayMs > 0) {
|
|
209
|
+
await new Promise<void>((resolve) =>
|
|
210
|
+
setTimeout(resolve, self.rxDelayMs),
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
self.bufferedBytes = Math.max(0, self.bufferedBytes - chunk.byteLength);
|
|
214
|
+
if (self.backpressured && self.bufferedBytes <= self.lowWaterMark) {
|
|
215
|
+
self.backpressured = false;
|
|
216
|
+
self.peer?.dispatchEvent(new Event("drain"));
|
|
217
|
+
}
|
|
218
|
+
yield chunk;
|
|
219
|
+
}
|
|
220
|
+
})();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
[Symbol.asyncIterator](): AsyncIterator<any> {
|
|
224
|
+
return this.readable[Symbol.asyncIterator]();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
send(data: Uint8Array): boolean {
|
|
228
|
+
if (this.closed) {
|
|
229
|
+
throw new Error("Cannot send on closed stream");
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// `it-length-prefixed` may yield the length prefix and the message body as
|
|
233
|
+
// separate chunks. Dropping only one of them would corrupt framing and can
|
|
234
|
+
// deadlock decoders. Buffer the prefix chunk so loss injection operates on
|
|
235
|
+
// whole messages.
|
|
236
|
+
if (this.pendingLengthPrefix) {
|
|
237
|
+
const prefix = this.pendingLengthPrefix;
|
|
238
|
+
this.pendingLengthPrefix = undefined;
|
|
239
|
+
const frame = new Uint8Array(prefix.byteLength + data.byteLength);
|
|
240
|
+
frame.set(prefix, 0);
|
|
241
|
+
frame.set(data, prefix.byteLength);
|
|
242
|
+
|
|
243
|
+
this.recordSend?.(frame);
|
|
244
|
+
if (this.shouldDrop?.(frame)) {
|
|
245
|
+
this.recordDrop?.(frame);
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const remote = this.peer;
|
|
250
|
+
if (!remote) throw new Error("Missing remote stream endpoint");
|
|
251
|
+
remote.bufferedBytes += frame.byteLength;
|
|
252
|
+
remote.inbound.push(frame);
|
|
253
|
+
if (remote.bufferedBytes > remote.highWaterMark) {
|
|
254
|
+
remote.backpressured = true;
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
return true;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Prefix-only chunk? Buffer it and wait for the body chunk.
|
|
261
|
+
try {
|
|
262
|
+
const { bytes } = decodeUVarint(data);
|
|
263
|
+
if (bytes === data.byteLength && data.byteLength <= 10) {
|
|
264
|
+
this.pendingLengthPrefix = data;
|
|
265
|
+
return true;
|
|
266
|
+
}
|
|
267
|
+
} catch {
|
|
268
|
+
// not a varint prefix
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Full frame already (prefix+body).
|
|
272
|
+
this.recordSend?.(data);
|
|
273
|
+
if (this.shouldDrop?.(data)) {
|
|
274
|
+
this.recordDrop?.(data);
|
|
275
|
+
return true;
|
|
276
|
+
}
|
|
277
|
+
const remote = this.peer;
|
|
278
|
+
if (!remote) {
|
|
279
|
+
throw new Error("Missing remote stream endpoint");
|
|
280
|
+
}
|
|
281
|
+
remote.bufferedBytes += data.byteLength;
|
|
282
|
+
remote.inbound.push(data);
|
|
283
|
+
if (remote.bufferedBytes > remote.highWaterMark) {
|
|
284
|
+
remote.backpressured = true;
|
|
285
|
+
return false;
|
|
286
|
+
}
|
|
287
|
+
return true;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
abort(_err?: any): void {
|
|
291
|
+
if (this.closed) return;
|
|
292
|
+
this.closed = true;
|
|
293
|
+
try {
|
|
294
|
+
this.inbound.end();
|
|
295
|
+
} catch {}
|
|
296
|
+
this.dispatchEvent(new Event("close"));
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
close(_opts?: AbortOptions): Promise<void> {
|
|
300
|
+
this.abort();
|
|
301
|
+
return Promise.resolve();
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
class InMemoryConnection {
|
|
306
|
+
public readonly id: string;
|
|
307
|
+
public readonly remotePeer: PeerId;
|
|
308
|
+
public readonly remoteAddr?: Multiaddr;
|
|
309
|
+
public readonly streams: Stream[] = [];
|
|
310
|
+
|
|
311
|
+
public status: "open" | "closed" = "open";
|
|
312
|
+
public limits: any = undefined;
|
|
313
|
+
public timeline: any = { open: Date.now(), close: undefined };
|
|
314
|
+
|
|
315
|
+
private readonly getLocalRegistrar: () => InMemoryRegistrar;
|
|
316
|
+
private readonly getRemoteRegistrar: () => InMemoryRegistrar;
|
|
317
|
+
private readonly getRemoteConnection: () => InMemoryConnection;
|
|
318
|
+
private readonly recordSend: (encodedFrame: Uint8Array) => void;
|
|
319
|
+
private readonly shouldDrop: (encodedFrame: Uint8Array) => boolean;
|
|
320
|
+
private readonly recordDrop: (encodedFrame: Uint8Array) => void;
|
|
321
|
+
private readonly recordStreamOpen: () => void;
|
|
322
|
+
private readonly streamHighWaterMarkBytes: number;
|
|
323
|
+
private readonly streamRxDelayMs: number;
|
|
324
|
+
|
|
325
|
+
private streamSeq = 0;
|
|
326
|
+
private pair?: InMemoryConnectionPair;
|
|
327
|
+
|
|
328
|
+
constructor(opts: {
|
|
329
|
+
id: string;
|
|
330
|
+
remotePeer: PeerId;
|
|
331
|
+
remoteAddr: Multiaddr;
|
|
332
|
+
getLocalRegistrar: () => InMemoryRegistrar;
|
|
333
|
+
getRemoteRegistrar: () => InMemoryRegistrar;
|
|
334
|
+
getRemoteConnection: () => InMemoryConnection;
|
|
335
|
+
recordSend: (encodedFrame: Uint8Array) => void;
|
|
336
|
+
shouldDrop: (encodedFrame: Uint8Array) => boolean;
|
|
337
|
+
recordDrop: (encodedFrame: Uint8Array) => void;
|
|
338
|
+
recordStreamOpen: () => void;
|
|
339
|
+
streamHighWaterMarkBytes: number;
|
|
340
|
+
streamRxDelayMs: number;
|
|
341
|
+
}) {
|
|
342
|
+
this.id = opts.id;
|
|
343
|
+
this.remotePeer = opts.remotePeer;
|
|
344
|
+
this.remoteAddr = opts.remoteAddr;
|
|
345
|
+
this.getLocalRegistrar = opts.getLocalRegistrar;
|
|
346
|
+
this.getRemoteRegistrar = opts.getRemoteRegistrar;
|
|
347
|
+
this.getRemoteConnection = opts.getRemoteConnection;
|
|
348
|
+
this.recordSend = opts.recordSend;
|
|
349
|
+
this.shouldDrop = opts.shouldDrop;
|
|
350
|
+
this.recordDrop = opts.recordDrop;
|
|
351
|
+
this.recordStreamOpen = opts.recordStreamOpen;
|
|
352
|
+
this.streamHighWaterMarkBytes = opts.streamHighWaterMarkBytes;
|
|
353
|
+
this.streamRxDelayMs = opts.streamRxDelayMs;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
_setPair(pair: InMemoryConnectionPair) {
|
|
357
|
+
this.pair = pair;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async newStream(
|
|
361
|
+
protocols: string | string[],
|
|
362
|
+
opts?: { signal?: AbortSignal; negotiateFully?: boolean },
|
|
363
|
+
): Promise<Stream> {
|
|
364
|
+
if (this.status !== "open") {
|
|
365
|
+
throw new Error("Connection is not open");
|
|
366
|
+
}
|
|
367
|
+
if (opts?.signal?.aborted) {
|
|
368
|
+
throw opts.signal.reason ?? new Error("Stream open aborted");
|
|
369
|
+
}
|
|
370
|
+
const list = Array.isArray(protocols) ? protocols : [protocols];
|
|
371
|
+
const protocol = list[0];
|
|
372
|
+
if (!protocol) {
|
|
373
|
+
throw new Error("Missing protocol");
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const handler = this.getRemoteRegistrar().getHandler(protocol);
|
|
377
|
+
if (!handler) {
|
|
378
|
+
const err = new UnsupportedProtocolError(
|
|
379
|
+
`No handler registered for protocol ${protocol}`,
|
|
380
|
+
) as any;
|
|
381
|
+
err.code = "ERR_UNSUPPORTED_PROTOCOL";
|
|
382
|
+
throw err;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
this.recordStreamOpen();
|
|
386
|
+
|
|
387
|
+
const remoteConn = this.getRemoteConnection();
|
|
388
|
+
const localStreamNo = ++this.streamSeq;
|
|
389
|
+
const remoteStreamNo = ++remoteConn.streamSeq;
|
|
390
|
+
const localStreamId = `${this.id}:out:${localStreamNo}`;
|
|
391
|
+
const remoteStreamId = `${remoteConn.id}:in:${remoteStreamNo}`;
|
|
392
|
+
|
|
393
|
+
const outbound = new InMemoryStream({
|
|
394
|
+
id: localStreamId,
|
|
395
|
+
protocol,
|
|
396
|
+
direction: "outbound",
|
|
397
|
+
recordSend: this.recordSend,
|
|
398
|
+
shouldDrop: this.shouldDrop,
|
|
399
|
+
recordDrop: this.recordDrop,
|
|
400
|
+
highWaterMarkBytes: this.streamHighWaterMarkBytes,
|
|
401
|
+
});
|
|
402
|
+
const inbound = new InMemoryStream({
|
|
403
|
+
id: remoteStreamId,
|
|
404
|
+
protocol,
|
|
405
|
+
direction: "inbound",
|
|
406
|
+
highWaterMarkBytes: this.streamHighWaterMarkBytes,
|
|
407
|
+
rxDelayMs: this.streamRxDelayMs,
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
outbound.peer = inbound;
|
|
411
|
+
inbound.peer = outbound;
|
|
412
|
+
|
|
413
|
+
this.streams.push(outbound as any);
|
|
414
|
+
remoteConn.streams.push(inbound as any);
|
|
415
|
+
|
|
416
|
+
const invokeHandler = () =>
|
|
417
|
+
handler(inbound as any, remoteConn as any).catch(() => {
|
|
418
|
+
// ignore handler errors in the transport shim
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
if (opts?.negotiateFully === false) {
|
|
422
|
+
// Fire handler async to better match real libp2p behavior.
|
|
423
|
+
queueMicrotask(() => {
|
|
424
|
+
invokeHandler();
|
|
425
|
+
});
|
|
426
|
+
} else {
|
|
427
|
+
// When negotiateFully=true, invoke handler synchronously so the remote has
|
|
428
|
+
// a chance to attach inbound handlers before the caller starts sending.
|
|
429
|
+
invokeHandler();
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
return outbound as any;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
async _closeLocal(): Promise<void> {
|
|
436
|
+
if (this.status !== "open") return;
|
|
437
|
+
this.status = "closed";
|
|
438
|
+
this.timeline.close = Date.now();
|
|
439
|
+
for (const s of this.streams) {
|
|
440
|
+
try {
|
|
441
|
+
s.close?.();
|
|
442
|
+
} catch {}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
async close(_opts?: AbortOptions): Promise<void> {
|
|
447
|
+
if (this.pair) {
|
|
448
|
+
await this.pair.close(this);
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
await this._closeLocal();
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
class InMemoryConnectionPair {
|
|
456
|
+
private closePromise?: Promise<void>;
|
|
457
|
+
|
|
458
|
+
constructor(
|
|
459
|
+
private readonly opts: {
|
|
460
|
+
network: InMemoryNetwork;
|
|
461
|
+
aOwner: PeerRuntime;
|
|
462
|
+
bOwner: PeerRuntime;
|
|
463
|
+
aManager: InMemoryConnectionManager;
|
|
464
|
+
bManager: InMemoryConnectionManager;
|
|
465
|
+
aConn: InMemoryConnection;
|
|
466
|
+
bConn: InMemoryConnection;
|
|
467
|
+
},
|
|
468
|
+
) {}
|
|
469
|
+
|
|
470
|
+
async close(_initiator: InMemoryConnection): Promise<void> {
|
|
471
|
+
if (this.closePromise) return this.closePromise;
|
|
472
|
+
this.closePromise = (async () => {
|
|
473
|
+
const { aConn, bConn, aOwner, bOwner, aManager, bManager, network } =
|
|
474
|
+
this.opts;
|
|
475
|
+
|
|
476
|
+
await Promise.all([aConn._closeLocal(), bConn._closeLocal()]);
|
|
477
|
+
|
|
478
|
+
aManager._removeConnectionInstance(bOwner.peerId.toString(), aConn);
|
|
479
|
+
bManager._removeConnectionInstance(aOwner.peerId.toString(), bConn);
|
|
480
|
+
|
|
481
|
+
network.metrics.connectionsClosed += 1;
|
|
482
|
+
network.notifyDisconnect(aOwner, bOwner.peerId, [aConn]);
|
|
483
|
+
network.notifyDisconnect(bOwner, aOwner.peerId, [bConn]);
|
|
484
|
+
})();
|
|
485
|
+
return this.closePromise;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
export class InMemoryAddressManager {
|
|
490
|
+
constructor(private readonly addr: Multiaddr) {}
|
|
491
|
+
getAddresses(): Multiaddr[] {
|
|
492
|
+
return [this.addr];
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
export class InMemoryPeerStore {
|
|
497
|
+
constructor(
|
|
498
|
+
private readonly getAddressesForPeerId: (peerId: PeerId) => Multiaddr[],
|
|
499
|
+
) {}
|
|
500
|
+
|
|
501
|
+
async get(peerId: PeerId): Promise<{ addresses: Array<{ multiaddr: Multiaddr }> }> {
|
|
502
|
+
const addrs = this.getAddressesForPeerId(peerId) ?? [];
|
|
503
|
+
return { addresses: addrs.map((multiaddr) => ({ multiaddr })) };
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
async delete(_peerId: PeerId): Promise<void> {
|
|
507
|
+
// noop
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
type PeerRuntime = {
|
|
512
|
+
peerId: PeerId;
|
|
513
|
+
privateKey: PrivateKey;
|
|
514
|
+
registrar: InMemoryRegistrar;
|
|
515
|
+
addressManager: InMemoryAddressManager;
|
|
516
|
+
peerStore: InMemoryPeerStore;
|
|
517
|
+
events: TypedEventTarget<Libp2pEvents>;
|
|
518
|
+
connectionManager: InMemoryConnectionManager;
|
|
519
|
+
};
|
|
520
|
+
|
|
521
|
+
export class InMemoryConnectionManager {
|
|
522
|
+
private readonly connectionsByRemote = new Map<string, InMemoryConnection[]>();
|
|
523
|
+
private readonly dialQueue: Array<{ peerId?: PeerId }> = [];
|
|
524
|
+
private readonly connSeqBase: string;
|
|
525
|
+
|
|
526
|
+
constructor(
|
|
527
|
+
private readonly network: InMemoryNetwork,
|
|
528
|
+
readonly owner: PeerRuntime,
|
|
529
|
+
opts?: { connSeqBase?: string },
|
|
530
|
+
) {
|
|
531
|
+
this.connSeqBase = opts?.connSeqBase ?? owner.peerId.toString();
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
getConnections(peerId?: PeerId): Connection[] {
|
|
535
|
+
if (!peerId) {
|
|
536
|
+
return [...this.connectionsByRemote.values()].flat() as any;
|
|
537
|
+
}
|
|
538
|
+
return (this.connectionsByRemote.get(peerId.toString()) ?? []) as any;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
getConnectionsMap(): { get(peer: PeerId): Connection[] | undefined } {
|
|
542
|
+
return {
|
|
543
|
+
get: (peer: PeerId) => this.getConnections(peer),
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
getDialQueue(): Array<{ peerId?: PeerId }> {
|
|
548
|
+
return this.dialQueue;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
async isDialable(addr: Multiaddr | Multiaddr[]): Promise<boolean> {
|
|
552
|
+
if (this.network.isPeerOffline(this.owner.peerId)) return false;
|
|
553
|
+
const list = Array.isArray(addr) ? addr : [addr];
|
|
554
|
+
for (const a of list) {
|
|
555
|
+
const port = parseTcpPort(a);
|
|
556
|
+
if (port == null) continue;
|
|
557
|
+
const remote = this.network.getPeerByPort(port);
|
|
558
|
+
if (!remote) continue;
|
|
559
|
+
if (this.network.isPeerOffline(remote.peerId)) continue;
|
|
560
|
+
if (remote.peerId.toString() === this.owner.peerId.toString()) continue;
|
|
561
|
+
return true;
|
|
562
|
+
}
|
|
563
|
+
return false;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
async openConnection(peer: PeerId | Multiaddr | Multiaddr[]): Promise<Connection> {
|
|
567
|
+
if (this.network.isPeerOffline(this.owner.peerId)) {
|
|
568
|
+
throw new Error("Peer is offline");
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const remote = (() => {
|
|
572
|
+
if (isPeerId(peer)) {
|
|
573
|
+
return this.network.getPeerById(peer.toString());
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
const list = Array.isArray(peer) ? peer : [peer];
|
|
577
|
+
for (const addr of list) {
|
|
578
|
+
const port = parseTcpPort(addr);
|
|
579
|
+
if (port == null) continue;
|
|
580
|
+
const found = this.network.getPeerByPort(port);
|
|
581
|
+
if (!found) continue;
|
|
582
|
+
if (found.peerId.toString() === this.owner.peerId.toString()) continue;
|
|
583
|
+
return found;
|
|
584
|
+
}
|
|
585
|
+
return undefined;
|
|
586
|
+
})();
|
|
587
|
+
|
|
588
|
+
if (!remote) {
|
|
589
|
+
throw new Error("No dialable address");
|
|
590
|
+
}
|
|
591
|
+
if (this.network.isPeerOffline(remote.peerId)) {
|
|
592
|
+
throw new Error("Peer is offline");
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
const remoteKey = remote.peerId.toString();
|
|
596
|
+
const existing = this.connectionsByRemote.get(remoteKey)?.find(
|
|
597
|
+
(c) => c.status === "open",
|
|
598
|
+
);
|
|
599
|
+
if (existing) return existing as any;
|
|
600
|
+
|
|
601
|
+
const dialEntry = { peerId: remote.peerId };
|
|
602
|
+
this.dialQueue.push(dialEntry);
|
|
603
|
+
try {
|
|
604
|
+
if (this.network.dialDelayMs > 0) {
|
|
605
|
+
await new Promise<void>((resolve) =>
|
|
606
|
+
setTimeout(resolve, this.network.dialDelayMs),
|
|
607
|
+
);
|
|
608
|
+
}
|
|
609
|
+
return this._connectTo(remote);
|
|
610
|
+
} finally {
|
|
611
|
+
const idx = this.dialQueue.indexOf(dialEntry);
|
|
612
|
+
if (idx !== -1) this.dialQueue.splice(idx, 1);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
async closeConnections(peer: PeerId, _options?: AbortOptions): Promise<void> {
|
|
617
|
+
const remoteId = peer.toString();
|
|
618
|
+
const conns = this.connectionsByRemote.get(remoteId) ?? [];
|
|
619
|
+
if (conns.length === 0) return;
|
|
620
|
+
// Closing one side will close the paired remote connection and notify both sides.
|
|
621
|
+
await Promise.all([...conns].map((c) => c.close()));
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
_removeConnectionInstance(remoteId: string, conn: InMemoryConnection) {
|
|
625
|
+
const conns = this.connectionsByRemote.get(remoteId);
|
|
626
|
+
if (!conns) return;
|
|
627
|
+
const idx = conns.indexOf(conn);
|
|
628
|
+
if (idx === -1) return;
|
|
629
|
+
conns.splice(idx, 1);
|
|
630
|
+
if (conns.length === 0) this.connectionsByRemote.delete(remoteId);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
_connectTo(remote: PeerRuntime): Connection {
|
|
634
|
+
const remoteKey = remote.peerId.toString();
|
|
635
|
+
const existing = this.connectionsByRemote.get(remoteKey)?.find(
|
|
636
|
+
(c) => c.status === "open",
|
|
637
|
+
);
|
|
638
|
+
if (existing) return existing as any;
|
|
639
|
+
|
|
640
|
+
const addr = remote.addressManager.getAddresses()[0]!;
|
|
641
|
+
const connIdA = `${this.connSeqBase}->${remoteKey}:${Date.now()}:${Math.random()
|
|
642
|
+
.toString(16)
|
|
643
|
+
.slice(2)}`;
|
|
644
|
+
const connIdB = `${remoteKey}->${this.connSeqBase}:${Date.now()}:${Math.random()
|
|
645
|
+
.toString(16)
|
|
646
|
+
.slice(2)}`;
|
|
647
|
+
|
|
648
|
+
let connA!: InMemoryConnection;
|
|
649
|
+
let connB!: InMemoryConnection;
|
|
650
|
+
|
|
651
|
+
connA = new InMemoryConnection({
|
|
652
|
+
id: connIdA,
|
|
653
|
+
remotePeer: remote.peerId,
|
|
654
|
+
remoteAddr: addr,
|
|
655
|
+
getLocalRegistrar: () => this.owner.registrar,
|
|
656
|
+
getRemoteRegistrar: () => remote.registrar,
|
|
657
|
+
getRemoteConnection: () => connB,
|
|
658
|
+
recordSend: (encoded) => this.network.recordSend(this.owner.peerId, encoded),
|
|
659
|
+
shouldDrop: (encoded) => this.network.shouldDrop(encoded),
|
|
660
|
+
recordDrop: (encoded) => this.network.recordDrop(this.owner.peerId, encoded),
|
|
661
|
+
recordStreamOpen: () => {
|
|
662
|
+
this.network.metrics.streamsOpened += 1;
|
|
663
|
+
},
|
|
664
|
+
streamHighWaterMarkBytes: this.network.streamHighWaterMarkBytes,
|
|
665
|
+
streamRxDelayMs: this.network.streamRxDelayMs,
|
|
666
|
+
});
|
|
667
|
+
connB = new InMemoryConnection({
|
|
668
|
+
id: connIdB,
|
|
669
|
+
remotePeer: this.owner.peerId,
|
|
670
|
+
remoteAddr: this.owner.addressManager.getAddresses()[0]!,
|
|
671
|
+
getLocalRegistrar: () => remote.registrar,
|
|
672
|
+
getRemoteRegistrar: () => this.owner.registrar,
|
|
673
|
+
getRemoteConnection: () => connA,
|
|
674
|
+
recordSend: (encoded) => this.network.recordSend(remote.peerId, encoded),
|
|
675
|
+
shouldDrop: (encoded) => this.network.shouldDrop(encoded),
|
|
676
|
+
recordDrop: (encoded) => this.network.recordDrop(remote.peerId, encoded),
|
|
677
|
+
recordStreamOpen: () => {
|
|
678
|
+
this.network.metrics.streamsOpened += 1;
|
|
679
|
+
},
|
|
680
|
+
streamHighWaterMarkBytes: this.network.streamHighWaterMarkBytes,
|
|
681
|
+
streamRxDelayMs: this.network.streamRxDelayMs,
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
const pair = new InMemoryConnectionPair({
|
|
685
|
+
network: this.network,
|
|
686
|
+
aOwner: this.owner,
|
|
687
|
+
bOwner: remote,
|
|
688
|
+
aManager: this,
|
|
689
|
+
bManager: remote.connectionManager,
|
|
690
|
+
aConn: connA,
|
|
691
|
+
bConn: connB,
|
|
692
|
+
});
|
|
693
|
+
connA._setPair(pair);
|
|
694
|
+
connB._setPair(pair);
|
|
695
|
+
|
|
696
|
+
{
|
|
697
|
+
const arr = this.connectionsByRemote.get(remoteKey) ?? [];
|
|
698
|
+
arr.push(connA);
|
|
699
|
+
this.connectionsByRemote.set(remoteKey, arr);
|
|
700
|
+
}
|
|
701
|
+
{
|
|
702
|
+
const arr = remote.connectionManager.connectionsByRemote.get(
|
|
703
|
+
this.owner.peerId.toString(),
|
|
704
|
+
) ?? [];
|
|
705
|
+
arr.push(connB);
|
|
706
|
+
remote.connectionManager.connectionsByRemote.set(
|
|
707
|
+
this.owner.peerId.toString(),
|
|
708
|
+
arr,
|
|
709
|
+
);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
this.network.notifyConnect(this.owner, remote.peerId, connA as any);
|
|
713
|
+
this.network.notifyConnect(remote, this.owner.peerId, connB as any);
|
|
714
|
+
|
|
715
|
+
this.network.metrics.dials += 1;
|
|
716
|
+
this.network.metrics.connectionsOpened += 1;
|
|
717
|
+
|
|
718
|
+
return connA as any;
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
export class InMemoryNetwork {
|
|
723
|
+
private peersById = new Map<string, PeerRuntime>();
|
|
724
|
+
private peersByPort = new Map<number, PeerRuntime>();
|
|
725
|
+
private offlineUntilByPeerId = new Map<string, number>();
|
|
726
|
+
public readonly metrics: InMemoryNetworkMetrics = {
|
|
727
|
+
dials: 0,
|
|
728
|
+
connectionsOpened: 0,
|
|
729
|
+
connectionsClosed: 0,
|
|
730
|
+
streamsOpened: 0,
|
|
731
|
+
framesSent: 0,
|
|
732
|
+
bytesSent: 0,
|
|
733
|
+
dataFramesSent: 0,
|
|
734
|
+
ackFramesSent: 0,
|
|
735
|
+
goodbyeFramesSent: 0,
|
|
736
|
+
otherFramesSent: 0,
|
|
737
|
+
framesDropped: 0,
|
|
738
|
+
bytesDropped: 0,
|
|
739
|
+
dataFramesDropped: 0,
|
|
740
|
+
};
|
|
741
|
+
public readonly peerMetricsByHash = new Map<string, InMemoryNetworkPeerWindow>();
|
|
742
|
+
public readonly streamHighWaterMarkBytes: number;
|
|
743
|
+
public readonly streamRxDelayMs: number;
|
|
744
|
+
public readonly dialDelayMs: number;
|
|
745
|
+
public readonly dropDataFrameRate: number;
|
|
746
|
+
private readonly dropRng: () => number;
|
|
747
|
+
|
|
748
|
+
constructor(opts?: {
|
|
749
|
+
streamHighWaterMarkBytes?: number;
|
|
750
|
+
streamRxDelayMs?: number;
|
|
751
|
+
dialDelayMs?: number;
|
|
752
|
+
dropDataFrameRate?: number;
|
|
753
|
+
dropSeed?: number;
|
|
754
|
+
}) {
|
|
755
|
+
this.streamHighWaterMarkBytes = opts?.streamHighWaterMarkBytes ?? 256 * 1024;
|
|
756
|
+
this.streamRxDelayMs = opts?.streamRxDelayMs ?? 0;
|
|
757
|
+
this.dialDelayMs = opts?.dialDelayMs ?? 0;
|
|
758
|
+
this.dropDataFrameRate = Math.max(
|
|
759
|
+
0,
|
|
760
|
+
Math.min(1, Number(opts?.dropDataFrameRate ?? 0)),
|
|
761
|
+
);
|
|
762
|
+
this.dropRng = mulberry32(Number(opts?.dropSeed ?? 1));
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
isPeerOffline(peer: PeerId | string, now = Date.now()): boolean {
|
|
766
|
+
const key = typeof peer === "string" ? peer : peer.toString();
|
|
767
|
+
const until = this.offlineUntilByPeerId.get(key);
|
|
768
|
+
if (until == null) return false;
|
|
769
|
+
if (until <= now) {
|
|
770
|
+
this.offlineUntilByPeerId.delete(key);
|
|
771
|
+
return false;
|
|
772
|
+
}
|
|
773
|
+
return true;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
setPeerOffline(peer: PeerId | string, downMs: number, now = Date.now()): void {
|
|
777
|
+
const key = typeof peer === "string" ? peer : peer.toString();
|
|
778
|
+
const ms = Math.max(0, Math.floor(downMs));
|
|
779
|
+
if (ms <= 0) {
|
|
780
|
+
this.offlineUntilByPeerId.delete(key);
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
this.offlineUntilByPeerId.set(key, now + ms);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
setPeerOnline(peer: PeerId | string): void {
|
|
787
|
+
const key = typeof peer === "string" ? peer : peer.toString();
|
|
788
|
+
this.offlineUntilByPeerId.delete(key);
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
async disconnectPeer(peer: PeerId | string): Promise<void> {
|
|
792
|
+
const key = typeof peer === "string" ? peer : peer.toString();
|
|
793
|
+
const runtime = this.getPeerById(key);
|
|
794
|
+
if (!runtime) return;
|
|
795
|
+
const conns = runtime.connectionManager.getConnections();
|
|
796
|
+
await Promise.all(conns.map((c) => c.close()));
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
registerPeer(peer: PeerRuntime, port: number) {
|
|
800
|
+
this.peersById.set(peer.peerId.toString(), peer);
|
|
801
|
+
this.peersByPort.set(port, peer);
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
getPeerById(id: string): PeerRuntime | undefined {
|
|
805
|
+
return this.peersById.get(id);
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
getPeerByPort(port: number): PeerRuntime | undefined {
|
|
809
|
+
return this.peersByPort.get(port);
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
shouldDrop(encodedFrame: Uint8Array): boolean {
|
|
813
|
+
if (this.dropDataFrameRate <= 0) return false;
|
|
814
|
+
try {
|
|
815
|
+
const { bytes } = decodeUVarint(encodedFrame);
|
|
816
|
+
const base = bytes;
|
|
817
|
+
const variant = encodedFrame[base];
|
|
818
|
+
// Only drop stream "data" frames; ACK/HELLO/etc stay reliable.
|
|
819
|
+
if (variant !== 0) return false;
|
|
820
|
+
|
|
821
|
+
// Drop only FanoutTree payload frames (identified by message id prefix) so
|
|
822
|
+
// join/repair control traffic remains reliable under loss.
|
|
823
|
+
if (!shouldDropByMessageIdPrefix(encodedFrame, base)) return false;
|
|
824
|
+
|
|
825
|
+
// If the message has a priority, only drop low-priority payload frames.
|
|
826
|
+
// This keeps control-plane traffic (join/repair) reliable so protocols can
|
|
827
|
+
// make progress under data loss.
|
|
828
|
+
//
|
|
829
|
+
// Layout (borsh, with length prefix stripped):
|
|
830
|
+
// [0]=DataMessage variant
|
|
831
|
+
// [1]=MessageHeader variant
|
|
832
|
+
// [2..33]=id (32 bytes)
|
|
833
|
+
// [34..41]=timestamp (u64)
|
|
834
|
+
// [42..49]=session (u64)
|
|
835
|
+
// [50..57]=expires (u64)
|
|
836
|
+
// [58]=priority option flag (u8)
|
|
837
|
+
// [59..62]=priority (u32 LE) if flag=1
|
|
838
|
+
const flagOffset = base + 58;
|
|
839
|
+
const hasPriority = encodedFrame[flagOffset];
|
|
840
|
+
if (hasPriority === 1) {
|
|
841
|
+
const prio = readU32LE(encodedFrame, flagOffset + 1);
|
|
842
|
+
if (prio > 1) return false;
|
|
843
|
+
}
|
|
844
|
+
} catch {
|
|
845
|
+
return false;
|
|
846
|
+
}
|
|
847
|
+
return this.dropRng() < this.dropDataFrameRate;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
recordDrop(fromPeerId: PeerId, encodedFrame: Uint8Array) {
|
|
851
|
+
this.metrics.framesDropped += 1;
|
|
852
|
+
this.metrics.bytesDropped += encodedFrame.byteLength;
|
|
853
|
+
const fromHash = getPublicKeyFromPeerId(fromPeerId).hashcode();
|
|
854
|
+
let pm = this.peerMetricsByHash.get(fromHash);
|
|
855
|
+
if (!pm) {
|
|
856
|
+
pm = {
|
|
857
|
+
framesSent: 0,
|
|
858
|
+
bytesSent: 0,
|
|
859
|
+
dataFramesSent: 0,
|
|
860
|
+
ackFramesSent: 0,
|
|
861
|
+
goodbyeFramesSent: 0,
|
|
862
|
+
otherFramesSent: 0,
|
|
863
|
+
framesDropped: 0,
|
|
864
|
+
bytesDropped: 0,
|
|
865
|
+
dataFramesDropped: 0,
|
|
866
|
+
maxBytesPerSecond: 0,
|
|
867
|
+
_currentSecond: -1,
|
|
868
|
+
_bytesThisSecond: 0,
|
|
869
|
+
};
|
|
870
|
+
this.peerMetricsByHash.set(fromHash, pm);
|
|
871
|
+
}
|
|
872
|
+
pm.framesDropped += 1;
|
|
873
|
+
pm.bytesDropped += encodedFrame.byteLength;
|
|
874
|
+
this.metrics.dataFramesDropped += 1;
|
|
875
|
+
pm.dataFramesDropped += 1;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
recordSend(fromPeerId: PeerId, encodedFrame: Uint8Array) {
|
|
879
|
+
this.metrics.framesSent += 1;
|
|
880
|
+
this.metrics.bytesSent += encodedFrame.byteLength;
|
|
881
|
+
const fromHash = getPublicKeyFromPeerId(fromPeerId).hashcode();
|
|
882
|
+
let pm = this.peerMetricsByHash.get(fromHash);
|
|
883
|
+
if (!pm) {
|
|
884
|
+
pm = {
|
|
885
|
+
framesSent: 0,
|
|
886
|
+
bytesSent: 0,
|
|
887
|
+
dataFramesSent: 0,
|
|
888
|
+
ackFramesSent: 0,
|
|
889
|
+
goodbyeFramesSent: 0,
|
|
890
|
+
otherFramesSent: 0,
|
|
891
|
+
framesDropped: 0,
|
|
892
|
+
bytesDropped: 0,
|
|
893
|
+
dataFramesDropped: 0,
|
|
894
|
+
maxBytesPerSecond: 0,
|
|
895
|
+
_currentSecond: -1,
|
|
896
|
+
_bytesThisSecond: 0,
|
|
897
|
+
};
|
|
898
|
+
this.peerMetricsByHash.set(fromHash, pm);
|
|
899
|
+
}
|
|
900
|
+
pm.framesSent += 1;
|
|
901
|
+
pm.bytesSent += encodedFrame.byteLength;
|
|
902
|
+
const sec = Math.floor(Date.now() / 1000);
|
|
903
|
+
if (pm._currentSecond !== sec) {
|
|
904
|
+
pm._currentSecond = sec;
|
|
905
|
+
pm._bytesThisSecond = 0;
|
|
906
|
+
}
|
|
907
|
+
pm._bytesThisSecond += encodedFrame.byteLength;
|
|
908
|
+
if (pm._bytesThisSecond > pm.maxBytesPerSecond) {
|
|
909
|
+
pm.maxBytesPerSecond = pm._bytesThisSecond;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
try {
|
|
913
|
+
const { bytes } = decodeUVarint(encodedFrame);
|
|
914
|
+
const variant = encodedFrame[bytes];
|
|
915
|
+
if (variant === 0) this.metrics.dataFramesSent += 1;
|
|
916
|
+
else if (variant === 1) this.metrics.ackFramesSent += 1;
|
|
917
|
+
else if (variant === 3) this.metrics.goodbyeFramesSent += 1;
|
|
918
|
+
else this.metrics.otherFramesSent += 1;
|
|
919
|
+
|
|
920
|
+
if (variant === 0) pm.dataFramesSent += 1;
|
|
921
|
+
else if (variant === 1) pm.ackFramesSent += 1;
|
|
922
|
+
else if (variant === 3) pm.goodbyeFramesSent += 1;
|
|
923
|
+
else pm.otherFramesSent += 1;
|
|
924
|
+
} catch {
|
|
925
|
+
this.metrics.otherFramesSent += 1;
|
|
926
|
+
pm.otherFramesSent += 1;
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
notifyConnect(owner: PeerRuntime, peerId: PeerId, connection: Connection) {
|
|
931
|
+
const remote = this.getPeerById(peerId.toString());
|
|
932
|
+
for (const { protocol, topology } of owner.registrar.getTopologies()) {
|
|
933
|
+
if (remote && !remote.registrar.getHandler(protocol)) continue;
|
|
934
|
+
topology.onConnect(peerId, connection);
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
notifyDisconnect(
|
|
939
|
+
owner: PeerRuntime,
|
|
940
|
+
peerId: PeerId,
|
|
941
|
+
connections: InMemoryConnection[],
|
|
942
|
+
) {
|
|
943
|
+
const remote = this.getPeerById(peerId.toString());
|
|
944
|
+
for (const conn of connections) {
|
|
945
|
+
for (const { protocol, topology } of owner.registrar.getTopologies()) {
|
|
946
|
+
if (remote && !remote.registrar.getHandler(protocol)) continue;
|
|
947
|
+
topology.onDisconnect(peerId, conn as any);
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
static createPeer(opts: {
|
|
953
|
+
index: number;
|
|
954
|
+
port: number;
|
|
955
|
+
network?: InMemoryNetwork;
|
|
956
|
+
mockKeyBytes?: { publicKey: Uint8Array; privateKey: Uint8Array };
|
|
957
|
+
}): {
|
|
958
|
+
runtime: PeerRuntime;
|
|
959
|
+
port: number;
|
|
960
|
+
} {
|
|
961
|
+
// Use deterministic-but-valid ed25519 keys (derived from index seed).
|
|
962
|
+
// This keeps sims reproducible while still allowing Peerbit's keychain
|
|
963
|
+
// to derive x25519 keys (libsodium rejects invalid points).
|
|
964
|
+
const seed = (() => {
|
|
965
|
+
const out = new Uint8Array(32);
|
|
966
|
+
let x = Math.imul((opts.index >>> 0) + 1, 0x9e3779b1) >>> 0;
|
|
967
|
+
for (let i = 0; i < out.length; i++) {
|
|
968
|
+
x ^= x >>> 16;
|
|
969
|
+
x = Math.imul(x, 0x7feb352d) >>> 0;
|
|
970
|
+
x ^= x >>> 15;
|
|
971
|
+
x = Math.imul(x, 0x846ca68b) >>> 0;
|
|
972
|
+
x ^= x >>> 16;
|
|
973
|
+
out[i] = x & 0xff;
|
|
974
|
+
}
|
|
975
|
+
return out;
|
|
976
|
+
})();
|
|
977
|
+
|
|
978
|
+
const generated = opts.mockKeyBytes
|
|
979
|
+
? { publicKey: opts.mockKeyBytes.publicKey, privateKey: opts.mockKeyBytes.privateKey }
|
|
980
|
+
: sodium.crypto_sign_seed_keypair(seed);
|
|
981
|
+
const pub = generated.publicKey;
|
|
982
|
+
const priv = generated.privateKey;
|
|
983
|
+
|
|
984
|
+
const peerId = {
|
|
985
|
+
type: "Ed25519",
|
|
986
|
+
publicKey: { raw: pub },
|
|
987
|
+
toString: () => `sim-${opts.index}`,
|
|
988
|
+
equals: (other: any) => other?.toString?.() === `sim-${opts.index}`,
|
|
989
|
+
} as any as PeerId;
|
|
990
|
+
|
|
991
|
+
const privateKey = {
|
|
992
|
+
type: "Ed25519",
|
|
993
|
+
raw: priv,
|
|
994
|
+
publicKey: { raw: pub },
|
|
995
|
+
} as any as PrivateKey;
|
|
996
|
+
|
|
997
|
+
const addr = multiaddr(`/ip4/127.0.0.1/tcp/${opts.port}`);
|
|
998
|
+
const registrar = new InMemoryRegistrar();
|
|
999
|
+
const addressManager = new InMemoryAddressManager(addr);
|
|
1000
|
+
const peerStore = new InMemoryPeerStore((peerId) => {
|
|
1001
|
+
const network = opts.network;
|
|
1002
|
+
const runtime = network?.getPeerById(peerId.toString());
|
|
1003
|
+
return runtime?.addressManager.getAddresses() ?? [];
|
|
1004
|
+
});
|
|
1005
|
+
const events = new EventTarget() as any as TypedEventTarget<Libp2pEvents>;
|
|
1006
|
+
|
|
1007
|
+
// placeholder connectionManager; caller wires it after creation
|
|
1008
|
+
const runtime = {
|
|
1009
|
+
peerId,
|
|
1010
|
+
privateKey,
|
|
1011
|
+
registrar,
|
|
1012
|
+
addressManager,
|
|
1013
|
+
peerStore,
|
|
1014
|
+
events,
|
|
1015
|
+
connectionManager: undefined as any,
|
|
1016
|
+
} as PeerRuntime;
|
|
1017
|
+
|
|
1018
|
+
return { runtime: runtime as any, port: opts.port };
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
export const publicKeyHash = (peerId: PeerId) =>
|
|
1023
|
+
getPublicKeyFromPeerId(peerId).hashcode();
|
|
1024
|
+
|
|
1025
|
+
export type InMemoryServiceFactories<T extends Record<string, unknown>> = {
|
|
1026
|
+
[K in keyof T]: (components: any) => T[K];
|
|
1027
|
+
};
|
|
1028
|
+
|
|
1029
|
+
export class InMemoryLibp2p<TServices extends Record<string, unknown> = {}> {
|
|
1030
|
+
public status: "started" | "stopped" = "stopped";
|
|
1031
|
+
public readonly peerId: PeerId;
|
|
1032
|
+
public readonly services: TServices;
|
|
1033
|
+
public readonly components: {
|
|
1034
|
+
peerId: PeerId;
|
|
1035
|
+
privateKey: PrivateKey;
|
|
1036
|
+
addressManager: InMemoryAddressManager;
|
|
1037
|
+
registrar: InMemoryRegistrar;
|
|
1038
|
+
connectionManager: InMemoryConnectionManager;
|
|
1039
|
+
peerStore: InMemoryPeerStore;
|
|
1040
|
+
events: TypedEventTarget<Libp2pEvents>;
|
|
1041
|
+
};
|
|
1042
|
+
|
|
1043
|
+
constructor(
|
|
1044
|
+
public readonly runtime: PeerRuntime,
|
|
1045
|
+
services?: InMemoryServiceFactories<TServices>,
|
|
1046
|
+
) {
|
|
1047
|
+
this.peerId = runtime.peerId;
|
|
1048
|
+
this.components = {
|
|
1049
|
+
peerId: runtime.peerId,
|
|
1050
|
+
privateKey: runtime.privateKey,
|
|
1051
|
+
addressManager: runtime.addressManager as any,
|
|
1052
|
+
registrar: runtime.registrar as any,
|
|
1053
|
+
connectionManager: runtime.connectionManager as any,
|
|
1054
|
+
peerStore: runtime.peerStore as any,
|
|
1055
|
+
events: runtime.events,
|
|
1056
|
+
};
|
|
1057
|
+
|
|
1058
|
+
const out: Record<string, unknown> = {};
|
|
1059
|
+
if (services) {
|
|
1060
|
+
for (const [name, factory] of Object.entries(services)) {
|
|
1061
|
+
out[name] = (factory as any)(this.components);
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
this.services = out as any as TServices;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
getMultiaddrs(): Multiaddr[] {
|
|
1068
|
+
return this.runtime.addressManager.getAddresses();
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
dial(addresses: Multiaddr | Multiaddr[]): Promise<Connection> {
|
|
1072
|
+
return this.runtime.connectionManager.openConnection(addresses as any);
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
hangUp(peerId: PeerId, options?: AbortOptions): Promise<void> {
|
|
1076
|
+
return this.runtime.connectionManager.closeConnections(peerId, options);
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
getConnections(peerId?: PeerId): Connection[] {
|
|
1080
|
+
return this.runtime.connectionManager.getConnections(peerId);
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
async start(): Promise<void> {
|
|
1084
|
+
if (this.status === "started") return;
|
|
1085
|
+
this.status = "started";
|
|
1086
|
+
await Promise.all(
|
|
1087
|
+
Object.values(this.services as any).map((svc: any) => svc?.start?.()),
|
|
1088
|
+
);
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
async stop(): Promise<void> {
|
|
1092
|
+
if (this.status === "stopped") return;
|
|
1093
|
+
this.status = "stopped";
|
|
1094
|
+
|
|
1095
|
+
await Promise.all(
|
|
1096
|
+
Object.values(this.services as any).map((svc: any) => svc?.stop?.()),
|
|
1097
|
+
);
|
|
1098
|
+
|
|
1099
|
+
const conns = this.runtime.connectionManager.getConnections();
|
|
1100
|
+
await Promise.all(conns.map((c) => c.close()));
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
export class InMemorySession<TServices extends Record<string, unknown> = {}> {
|
|
1105
|
+
public readonly peers: Array<InMemoryLibp2p<TServices>>;
|
|
1106
|
+
public readonly network: InMemoryNetwork;
|
|
1107
|
+
|
|
1108
|
+
constructor(opts: {
|
|
1109
|
+
peers: Array<InMemoryLibp2p<TServices>>;
|
|
1110
|
+
network: InMemoryNetwork;
|
|
1111
|
+
}) {
|
|
1112
|
+
this.peers = opts.peers;
|
|
1113
|
+
this.network = opts.network;
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
static async disconnected<TServices extends Record<string, unknown>>(
|
|
1117
|
+
n: number,
|
|
1118
|
+
opts?: {
|
|
1119
|
+
basePort?: number;
|
|
1120
|
+
start?: boolean;
|
|
1121
|
+
network?: InMemoryNetwork;
|
|
1122
|
+
networkOpts?: ConstructorParameters<typeof InMemoryNetwork>[0];
|
|
1123
|
+
services?: InMemoryServiceFactories<TServices>;
|
|
1124
|
+
},
|
|
1125
|
+
): Promise<InMemorySession<TServices>> {
|
|
1126
|
+
await sodium.ready;
|
|
1127
|
+
const network = opts?.network ?? new InMemoryNetwork(opts?.networkOpts);
|
|
1128
|
+
const basePort = opts?.basePort ?? 30_000;
|
|
1129
|
+
|
|
1130
|
+
const peers: Array<InMemoryLibp2p<TServices>> = [];
|
|
1131
|
+
for (let i = 0; i < n; i++) {
|
|
1132
|
+
const port = basePort + i;
|
|
1133
|
+
const { runtime } = InMemoryNetwork.createPeer({ index: i, port, network });
|
|
1134
|
+
runtime.connectionManager = new InMemoryConnectionManager(network, runtime);
|
|
1135
|
+
network.registerPeer(runtime, port);
|
|
1136
|
+
peers.push(new InMemoryLibp2p(runtime, opts?.services));
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
const session = new InMemorySession<TServices>({ peers, network });
|
|
1140
|
+
if (opts?.start !== false) {
|
|
1141
|
+
await Promise.all(session.peers.map((p) => p.start()));
|
|
1142
|
+
}
|
|
1143
|
+
return session;
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
async connectFully(
|
|
1147
|
+
groups?: Array<
|
|
1148
|
+
Array<{
|
|
1149
|
+
getMultiaddrs(): Multiaddr[];
|
|
1150
|
+
dial(addresses: Multiaddr[]): Promise<any>;
|
|
1151
|
+
}>
|
|
1152
|
+
>,
|
|
1153
|
+
): Promise<this> {
|
|
1154
|
+
const peers = groups ?? [this.peers];
|
|
1155
|
+
const connectPromises: Promise<any>[] = [];
|
|
1156
|
+
for (const group of peers) {
|
|
1157
|
+
for (let i = 0; i < group.length - 1; i++) {
|
|
1158
|
+
for (let j = i + 1; j < group.length; j++) {
|
|
1159
|
+
connectPromises.push(group[i]!.dial(group[j]!.getMultiaddrs()));
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
await Promise.all(connectPromises);
|
|
1164
|
+
return this;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
async stop(): Promise<void> {
|
|
1168
|
+
await Promise.all(this.peers.map((p) => p.stop()));
|
|
1169
|
+
}
|
|
1170
|
+
}
|