@freqhole/api-client 0.1.32 → 0.2.2

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.
@@ -1,759 +0,0 @@
1
- // ---------------------------------------------------------------------------
2
- // iroh network adapter for automerge-repo
3
- //
4
- // bridges iroh QUIC bidirectional streams (via midden WASM) with
5
- // automerge-repo's sync protocol. messages are CBOR-encoded and
6
- // length-delimited (4-byte BE u32 prefix), matching the framing used
7
- // by the iroh-automerge-repo example.
8
- //
9
- // this adapter requires midden to be built with the raw stream APIs
10
- // (BiStream, open_bi, accept). the adapter won't work until midden is
11
- // rebuilt with `make build` in the relevant midden directory.
12
- // ---------------------------------------------------------------------------
13
-
14
- import {
15
- cbor,
16
- NetworkAdapter,
17
- type Message,
18
- type PeerId,
19
- type PeerMetadata,
20
- } from "@automerge/automerge-repo";
21
-
22
- // ---------------------------------------------------------------------------
23
- // constants
24
- // ---------------------------------------------------------------------------
25
-
26
- /** base delay for reconnection backoff (ms). */
27
- const RECONNECT_BASE_DELAY_MS = 1000;
28
-
29
- /** maximum delay between reconnection attempts (ms). */
30
- const RECONNECT_MAX_DELAY_MS = 30_000;
31
-
32
- /** maximum number of reconnection attempts before giving up. */
33
- const RECONNECT_MAX_ATTEMPTS = 8;
34
-
35
- /** upper bound of random jitter added to each reconnect delay (ms). */
36
- const RECONNECT_JITTER_MS = 1000;
37
-
38
- // ---------------------------------------------------------------------------
39
- // types
40
- // ---------------------------------------------------------------------------
41
-
42
- /** ALPN protocol identifier for automerge-repo sync over iroh. */
43
- export const SYNC_ALPN = "iroh/automerge-repo/1";
44
-
45
- /** summary of the adapter's connection state for UI display. */
46
- export interface ConnectionSummary {
47
- /** number of peers we're actively connected to (stream is open). */
48
- connected: number;
49
- /** number of peers we're trying to reconnect to (in backoff). */
50
- reconnecting: number;
51
- /** number of peers where reconnection gave up (max attempts exceeded). */
52
- failed: number;
53
- }
54
-
55
- /** console log prefix. */
56
- const TAG = "[freqhole:iroh-adapter]";
57
-
58
- /**
59
- * minimal interface for a midden BiStream.
60
- * matches the WASM-exported BiStream struct.
61
- */
62
- export interface BiStreamLike {
63
- peer_node_id(): string;
64
- alpn(): string;
65
- write_message(data: Uint8Array): Promise<void>;
66
- read_message(): Promise<Uint8Array | null>; // null = stream closed
67
- close(): void;
68
- // raw framing (no length prefix) - used by additional protocols.
69
- // these methods are optional and only present on some BiStream implementations.
70
- read_to_end?(max_size: number): Promise<Uint8Array>;
71
- write_raw_and_finish?(data: Uint8Array): Promise<void>;
72
- }
73
-
74
- /**
75
- * midden stream node interface with bidirectional stream APIs.
76
- * provides open_bi for outbound connections and accept for inbound.
77
- */
78
- export interface MiddenStreamNode {
79
- node_id(): string;
80
- /** full serialized endpoint addr (node id + relay url) for deterministic dialing. */
81
- node_addr?(): string;
82
- open_bi(peer_addr: string, alpn: string): Promise<BiStreamLike>;
83
- accept(): Promise<BiStreamLike | null>;
84
- }
85
-
86
- // ---------------------------------------------------------------------------
87
- // constructor options
88
- // ---------------------------------------------------------------------------
89
-
90
- export interface IrohNetworkAdapterOptions {
91
- /**
92
- * factory that returns (or lazily creates) the midden stream node.
93
- * called at most once; the resolved node is cached.
94
- */
95
- getNode: () => Promise<MiddenStreamNode>;
96
-
97
- /**
98
- * returns the current identity (any truthy value = identity exists).
99
- * called once at connect() time to decide whether to start transport
100
- * immediately or defer until onIdentityChange fires.
101
- */
102
- getIdentity: () => Promise<unknown | null>;
103
-
104
- /**
105
- * optional subscription for identity changes. when provided and no
106
- * identity exists at connect() time, the adapter subscribes and starts
107
- * transport when the first truthy identity arrives.
108
- * must return an unsubscribe function.
109
- */
110
- onIdentityChange?: (cb: (identity: unknown | null) => void) => () => void;
111
-
112
- /**
113
- * ALPN string to use for automerge sync streams.
114
- * defaults to SYNC_ALPN ("iroh/automerge-repo/1").
115
- */
116
- syncAlpn?: string;
117
- }
118
-
119
- // ---------------------------------------------------------------------------
120
- // adapter
121
- // ---------------------------------------------------------------------------
122
-
123
- /**
124
- * automerge-repo NetworkAdapter that uses iroh QUIC streams for transport.
125
- *
126
- * usage:
127
- * const adapter = new IrohNetworkAdapter({
128
- * getNode: () => getMiddenNode(),
129
- * getIdentity: () => getStoredIdentity(),
130
- * onIdentityChange: (cb) => subscribeToIdentityChanges(cb),
131
- * });
132
- * const repo = new Repo({ network: [broadcastAdapter, adapter] });
133
- * // later, to connect to a peer:
134
- * await adapter.addPeer("abc123...def");
135
- */
136
- export class IrohNetworkAdapter extends NetworkAdapter {
137
- private readonly opts: IrohNetworkAdapterOptions;
138
- private readonly syncAlpn: string;
139
- private midden: MiddenStreamNode | null = null;
140
- private streams = new Map<string, BiStreamLike>();
141
- private readLoops = new Map<string, boolean>(); // peerId -> active flag
142
- private _ready = false;
143
- private _readyPromise: Promise<void>;
144
- private _resolveReady!: () => void;
145
- private _disconnected = false;
146
- private _acceptLoopRunning = false;
147
- private identityUnsub: (() => void) | null = null;
148
-
149
- /** peers explicitly added via addPeer() that we should stay connected to. */
150
- private intendedPeers = new Set<string>();
151
-
152
- /** per-peer reconnection state tracking attempt count and pending timer. */
153
- private reconnectState = new Map<
154
- string,
155
- { attempt: number; timer: ReturnType<typeof setTimeout> | null }
156
- >();
157
-
158
- /**
159
- * optional per-peer dial address hint keyed by node id. when present the
160
- * adapter dials this full endpoint addr instead of the bare node id, which
161
- * skips discovery (pkarr/dns) lookup. empty in normal operation; populated
162
- * by callers that already know a peer's reachable addr.
163
- */
164
- private peerDialAddr = new Map<string, string>();
165
-
166
- /** listeners for connection state changes. */
167
- private connectionStateListeners: Array<() => void> = [];
168
-
169
- /** peers that exceeded max reconnection attempts. */
170
- private failedPeers = new Set<string>();
171
-
172
- /** external handlers registered for non-sync ALPNs via registerAlpnHandler(). */
173
- private alpnHandlers = new Map<string, (stream: BiStreamLike) => void>();
174
-
175
- /** listeners notified when a peer stream is established. */
176
- private peerConnectListeners: Array<(peerId: string) => void> = [];
177
-
178
- /** listeners notified when a peer stream is removed. */
179
- private peerDisconnectListeners: Array<(peerId: string) => void> = [];
180
-
181
- constructor(opts: IrohNetworkAdapterOptions) {
182
- super();
183
- this.opts = opts;
184
- this.syncAlpn = opts.syncAlpn ?? SYNC_ALPN;
185
- this._readyPromise = new Promise((resolve) => {
186
- this._resolveReady = resolve;
187
- });
188
- }
189
-
190
- // --- NetworkAdapter interface ---
191
-
192
- isReady(): boolean {
193
- return this._ready;
194
- }
195
-
196
- whenReady(): Promise<void> {
197
- return this._readyPromise;
198
- }
199
-
200
- /**
201
- * called by the Repo to start the adapter.
202
- *
203
- * marks the adapter ready (so automerge-repo can use it immediately)
204
- * and begins transport setup if an identity already exists.
205
- */
206
- connect(peerId: PeerId, peerMetadata?: PeerMetadata): void {
207
- this.peerId = peerId;
208
- this.peerMetadata = peerMetadata;
209
-
210
- // the adapter is always "ready" from automerge-repo's perspective -
211
- // it can participate in the network subsystem even before midden starts.
212
- // actual P2P only begins when an identity exists.
213
- this._ready = true;
214
- this._resolveReady();
215
-
216
- // check if user already has a P2P identity, and if so, start midden
217
- this.checkIdentityAndStart().catch((err) => {
218
- console.error(TAG, "identity check failed:", err);
219
- });
220
- }
221
-
222
- /**
223
- * send a message to a peer.
224
- *
225
- * CBOR-encodes the message and writes it as a length-delimited frame
226
- * to the peer's QUIC stream.
227
- */
228
- send(message: Message): void {
229
- const targetId = message.targetId as string;
230
- const stream = this.streams.get(targetId);
231
-
232
- if (!stream) {
233
- console.warn(TAG, "no stream for peer:", targetId.slice(0, 16) + "...");
234
- return;
235
- }
236
-
237
- const encoded = cbor.encode(message);
238
- const bytes = new Uint8Array(encoded.buffer, encoded.byteOffset, encoded.byteLength);
239
-
240
- stream.write_message(bytes).catch((err) => {
241
- console.error(TAG, "write failed for peer:", targetId.slice(0, 16) + "...", err);
242
- this.removePeer(targetId);
243
- });
244
- }
245
-
246
- /**
247
- * disconnect from all peers and stop accepting connections.
248
- */
249
- disconnect(): void {
250
- this._disconnected = true;
251
-
252
- for (const [peerId, stream] of this.streams) {
253
- stream.close();
254
- this.emit("peer-disconnected", { peerId: peerId as PeerId });
255
- }
256
-
257
- this.streams.clear();
258
- this.readLoops.clear();
259
- this.intendedPeers.clear();
260
- this.failedPeers.clear();
261
- this.peerDialAddr.clear();
262
- this.alpnHandlers.clear();
263
-
264
- // cancel all pending reconnection timers
265
- for (const [, state] of this.reconnectState) {
266
- if (state.timer !== null) {
267
- clearTimeout(state.timer);
268
- }
269
- }
270
- this.reconnectState.clear();
271
- this.connectionStateListeners.length = 0;
272
- this.peerConnectListeners.length = 0;
273
- this.peerDisconnectListeners.length = 0;
274
-
275
- if (this.identityUnsub) {
276
- this.identityUnsub();
277
- this.identityUnsub = null;
278
- }
279
-
280
- this.emit("close");
281
- }
282
-
283
- // --- public API (beyond NetworkAdapter) ---
284
-
285
- /**
286
- * connect to a peer by node ID.
287
- *
288
- * opens a bidirectional QUIC stream to the peer on the automerge sync
289
- * ALPN, starts a read loop, and emits a peer-candidate event so
290
- * automerge-repo begins syncing with this peer.
291
- */
292
- async addPeer(nodeId: string, dialAddr?: string): Promise<void> {
293
- if (this._disconnected) {
294
- throw new Error("adapter is disconnected");
295
- }
296
-
297
- // track this peer as one we intend to stay connected to
298
- this.intendedPeers.add(nodeId);
299
- this.failedPeers.delete(nodeId);
300
- // remember an explicit dial addr hint so dials (and reconnects) can skip
301
- // discovery. keyed by node id; everything else stays keyed by node id too.
302
- if (dialAddr) {
303
- this.peerDialAddr.set(nodeId, dialAddr);
304
- }
305
- this.emitConnectionStateChange();
306
-
307
- // clear any pending reconnection state - this is a fresh attempt
308
- this.clearReconnectState(nodeId);
309
-
310
- // skip if already connected
311
- if (this.streams.has(nodeId)) {
312
- console.log(TAG, "already connected to:", nodeId.slice(0, 16) + "...");
313
- return;
314
- }
315
-
316
- const midden = await this.ensureMidden();
317
- const target = this.peerDialAddr.get(nodeId) ?? nodeId;
318
- try {
319
- const stream = await midden.open_bi(target, this.syncAlpn);
320
- this.registerStream(nodeId, stream);
321
- } catch (err) {
322
- // the initial dial can fail when the peer is not yet discoverable. hand
323
- // off to the background reconnect loop with backoff instead of forcing
324
- // every caller to implement its own retry.
325
- console.warn(
326
- TAG,
327
- "initial dial failed, scheduling reconnect:",
328
- nodeId.slice(0, 16) + "...",
329
- err
330
- );
331
- this.scheduleReconnect(nodeId);
332
- }
333
- }
334
-
335
- /**
336
- * stop maintaining a connection to a peer.
337
- *
338
- * removes the peer from intendedPeers, cancels any pending reconnection,
339
- * and closes any existing stream.
340
- */
341
- forgetPeer(nodeId: string): void {
342
- // remove from intended set first so removePeer() won't schedule a reconnect
343
- this.intendedPeers.delete(nodeId);
344
- this.failedPeers.delete(nodeId);
345
- this.peerDialAddr.delete(nodeId);
346
- this.clearReconnectState(nodeId);
347
- // delegate stream cleanup and peer-disconnected emission to removePeer
348
- this.removePeer(nodeId);
349
- this.emitConnectionStateChange();
350
- }
351
-
352
- /** check whether we have an active stream to a peer (transport-level). */
353
- isConnected(nodeId: string): boolean {
354
- return this.streams.has(nodeId);
355
- }
356
-
357
- /**
358
- * get a summary of the current connection state.
359
- * used by the UI to show stoplight-style indicators.
360
- */
361
- getConnectionSummary(): ConnectionSummary {
362
- let connected = 0;
363
- let reconnecting = 0;
364
-
365
- for (const peerId of this.intendedPeers) {
366
- if (this.streams.has(peerId)) {
367
- connected++;
368
- } else if (this.reconnectState.has(peerId)) {
369
- reconnecting++;
370
- }
371
- }
372
-
373
- return { connected, reconnecting, failed: this.failedPeers.size };
374
- }
375
-
376
- /**
377
- * subscribe to connection state changes.
378
- * fires whenever a peer connects, disconnects, starts reconnecting,
379
- * or gives up. returns an unsubscribe function.
380
- */
381
- onConnectionStateChange(handler: () => void): () => void {
382
- this.connectionStateListeners.push(handler);
383
- return () => {
384
- const idx = this.connectionStateListeners.indexOf(handler);
385
- if (idx >= 0) this.connectionStateListeners.splice(idx, 1);
386
- };
387
- }
388
-
389
- /**
390
- * subscribe to peer connect events. fires when a stream is
391
- * successfully established with a peer. returns an unsubscribe function.
392
- */
393
- onPeerConnect(handler: (peerId: string) => void): () => void {
394
- this.peerConnectListeners.push(handler);
395
- return () => {
396
- const idx = this.peerConnectListeners.indexOf(handler);
397
- if (idx >= 0) this.peerConnectListeners.splice(idx, 1);
398
- };
399
- }
400
-
401
- /**
402
- * subscribe to peer disconnect events. fires when a peer's stream
403
- * is removed (closed or errored). returns an unsubscribe function.
404
- */
405
- onPeerDisconnect(handler: (peerId: string) => void): () => void {
406
- this.peerDisconnectListeners.push(handler);
407
- return () => {
408
- const idx = this.peerDisconnectListeners.indexOf(handler);
409
- if (idx >= 0) this.peerDisconnectListeners.splice(idx, 1);
410
- };
411
- }
412
-
413
- /**
414
- * retry connection to all failed peers.
415
- * re-adds them to intendedPeers and starts fresh reconnection attempts.
416
- */
417
- retryFailedPeers(): void {
418
- if (this._disconnected) return;
419
- const failed = [...this.failedPeers];
420
- this.failedPeers.clear();
421
- for (const peerId of failed) {
422
- this.intendedPeers.add(peerId);
423
- this.scheduleReconnect(peerId);
424
- }
425
- this.emitConnectionStateChange();
426
- }
427
-
428
- /**
429
- * register a handler for incoming streams with a specific ALPN.
430
- * the accept loop will dispatch matching streams to this handler
431
- * instead of closing them. used to support additional protocols
432
- * alongside automerge sync.
433
- */
434
- registerAlpnHandler(alpn: string, handler: (stream: BiStreamLike) => void): void {
435
- this.alpnHandlers.set(alpn, handler);
436
- }
437
-
438
- /**
439
- * get the midden stream node, initializing it lazily if needed.
440
- * exposed for use by protocol handlers that need to open outbound
441
- * streams.
442
- */
443
- async getNode(): Promise<MiddenStreamNode> {
444
- return this.ensureMidden();
445
- }
446
-
447
- // --- internals ---
448
-
449
- private async checkIdentityAndStart(): Promise<void> {
450
- const identity = await this.opts.getIdentity();
451
-
452
- if (identity) {
453
- // user has an identity - start midden and begin accepting connections
454
- console.log(TAG, "identity found, starting P2P transport");
455
- await this.initialize();
456
- } else if (this.opts.onIdentityChange) {
457
- // no identity yet - subscribe to identity changes so we start
458
- // when the user generates one
459
- console.log(TAG, "no identity yet, deferring P2P transport");
460
- this.identityUnsub = this.opts.onIdentityChange((newIdentity) => {
461
- if (newIdentity && !this.midden && !this._disconnected) {
462
- console.log(TAG, "identity created, starting P2P transport");
463
- this.initialize().catch((err) => {
464
- console.error(TAG, "deferred initialization failed:", err);
465
- });
466
- }
467
- });
468
- } else {
469
- // no identity and no subscription - adapter stays passive
470
- console.log(TAG, "no identity and no onIdentityChange provided, adapter is passive");
471
- }
472
- }
473
-
474
- private async initialize(): Promise<void> {
475
- try {
476
- await this.ensureMidden();
477
- // start accepting incoming connections
478
- this.startAcceptLoop();
479
- } catch (err) {
480
- console.error(TAG, "failed to initialize midden:", err);
481
- throw err;
482
- }
483
- }
484
-
485
- private async ensureMidden(): Promise<MiddenStreamNode> {
486
- if (this.midden) return this.midden;
487
- this.midden = await this.opts.getNode();
488
- return this.midden;
489
- }
490
-
491
- private startAcceptLoop(): void {
492
- if (this._acceptLoopRunning) return;
493
- this._acceptLoopRunning = true;
494
-
495
- const loop_ = async () => {
496
- const midden = await this.ensureMidden();
497
-
498
- while (!this._disconnected) {
499
- try {
500
- const stream = await midden.accept();
501
-
502
- if (!stream) {
503
- // endpoint closed
504
- console.log(TAG, "accept loop: endpoint closed");
505
- break;
506
- }
507
-
508
- const alpn = stream.alpn();
509
- const peerId = stream.peer_node_id();
510
-
511
- if (alpn === this.syncAlpn) {
512
- // automerge-repo sync - handle internally
513
- console.log(TAG, "accepted sync connection from:", peerId.slice(0, 16) + "...");
514
- this.registerStream(peerId, stream);
515
- } else {
516
- const handler = this.alpnHandlers.get(alpn);
517
- if (handler) {
518
- console.log(TAG, "dispatching", alpn, "stream from:", peerId.slice(0, 16) + "...");
519
- handler(stream);
520
- } else {
521
- // no handler registered - close the stream
522
- console.warn(
523
- TAG,
524
- "dropping inbound stream - no handler registered for ALPN:",
525
- alpn,
526
- "registered handlers:",
527
- Array.from(this.alpnHandlers.keys())
528
- );
529
- stream.close();
530
- }
531
- }
532
- } catch (err) {
533
- if (this._disconnected) break;
534
- console.error(TAG, "accept loop error:", err);
535
- // brief pause before retrying to avoid tight error loops
536
- await new Promise((r) => setTimeout(r, 1000));
537
- }
538
- }
539
-
540
- this._acceptLoopRunning = false;
541
- };
542
-
543
- loop_().catch((err) => {
544
- console.error(TAG, "accept loop crashed:", err);
545
- this._acceptLoopRunning = false;
546
- });
547
- }
548
-
549
- private registerStream(peerId: string, stream: BiStreamLike): void {
550
- // if we already have a stream for this peer, close the old one.
551
- // the old read loop will detect the closed stream and exit, but
552
- // won't call removePeer() because the stream reference won't match.
553
- const existing = this.streams.get(peerId);
554
- if (existing) {
555
- console.log(TAG, "replacing existing stream for peer:", peerId.slice(0, 16) + "...");
556
- existing.close();
557
- }
558
-
559
- this.streams.set(peerId, stream);
560
-
561
- // connection established - clear any reconnection backoff state
562
- this.clearReconnectState(peerId);
563
- this.emitConnectionStateChange();
564
-
565
- // notify peer-connect listeners
566
- for (const h of this.peerConnectListeners) h(peerId);
567
-
568
- // emit peer-candidate so automerge-repo starts syncing
569
- this.emit("peer-candidate", {
570
- peerId: peerId as PeerId,
571
- peerMetadata: { isEphemeral: false },
572
- });
573
-
574
- // start read loop for this peer
575
- this.startReadLoop(peerId, stream);
576
- }
577
-
578
- private startReadLoop(peerId: string, stream: BiStreamLike): void {
579
- this.readLoops.set(peerId, true);
580
-
581
- const loop_ = async () => {
582
- while (this.readLoops.get(peerId) && !this._disconnected) {
583
- try {
584
- const data = await stream.read_message();
585
-
586
- if (!data) {
587
- // stream closed cleanly
588
- console.log(TAG, "stream closed by peer:", peerId.slice(0, 16) + "...");
589
- break;
590
- }
591
-
592
- // CBOR-decode the message
593
- const message = cbor.decode(data) as Message;
594
-
595
- // ensure the senderId is set to the peer's node ID
596
- message.senderId = peerId as PeerId;
597
-
598
- this.emit("message", message);
599
- } catch (err) {
600
- if (this._disconnected) break;
601
- console.error(TAG, "read error from peer:", peerId.slice(0, 16) + "...", err);
602
- break;
603
- }
604
- }
605
-
606
- // only clean up if this stream is still the active one for this peer.
607
- // if registerStream() replaced our stream with a newer one, the replacement
608
- // already closed us and started its own read loop - calling removePeer here
609
- // would incorrectly kill the new stream.
610
- if (this.streams.get(peerId) === stream) {
611
- this.removePeer(peerId);
612
- }
613
- };
614
-
615
- loop_().catch((err) => {
616
- console.error(TAG, "read loop crashed for peer:", peerId.slice(0, 16) + "...", err);
617
- if (this.streams.get(peerId) === stream) {
618
- this.removePeer(peerId);
619
- }
620
- });
621
- }
622
-
623
- private removePeer(peerId: string): void {
624
- const stream = this.streams.get(peerId);
625
- if (stream) {
626
- stream.close();
627
- this.streams.delete(peerId);
628
- this.emitConnectionStateChange();
629
-
630
- // notify peer-disconnect listeners
631
- for (const h of this.peerDisconnectListeners) h(peerId);
632
- }
633
-
634
- this.readLoops.delete(peerId);
635
-
636
- if (!this._disconnected) {
637
- this.emit("peer-disconnected", { peerId: peerId as PeerId });
638
-
639
- // if this was an intended peer, schedule a reconnection attempt
640
- if (this.intendedPeers.has(peerId)) {
641
- this.scheduleReconnect(peerId);
642
- }
643
- }
644
- }
645
-
646
- // --- reconnection logic ---
647
-
648
- /**
649
- * schedule a reconnection attempt for a peer using exponential backoff
650
- * with random jitter.
651
- */
652
- private scheduleReconnect(peerId: string): void {
653
- if (this._disconnected) return;
654
-
655
- // already reconnected while we were setting up
656
- if (this.streams.has(peerId)) return;
657
-
658
- // get or create reconnect state for this peer
659
- let state = this.reconnectState.get(peerId);
660
- if (!state) {
661
- state = { attempt: 0, timer: null };
662
- this.reconnectState.set(peerId, state);
663
- }
664
-
665
- // give up after max attempts
666
- if (state.attempt >= RECONNECT_MAX_ATTEMPTS) {
667
- console.warn(
668
- TAG,
669
- "giving up reconnection to peer after",
670
- RECONNECT_MAX_ATTEMPTS,
671
- "attempts:",
672
- peerId.slice(0, 16) + "..."
673
- );
674
- this.intendedPeers.delete(peerId);
675
- this.failedPeers.add(peerId);
676
- this.peerDialAddr.delete(peerId);
677
- this.clearReconnectState(peerId);
678
- this.emitConnectionStateChange();
679
- return;
680
- }
681
-
682
- // exponential backoff: baseDelay * 2^attempt, capped at maxDelay
683
- const exponentialDelay = RECONNECT_BASE_DELAY_MS * Math.pow(2, state.attempt);
684
- const cappedDelay = Math.min(exponentialDelay, RECONNECT_MAX_DELAY_MS);
685
- // add random jitter to avoid simultaneous-open races
686
- const jitter = Math.floor(Math.random() * RECONNECT_JITTER_MS);
687
- const delay = cappedDelay + jitter;
688
-
689
- console.log(
690
- TAG,
691
- "scheduling reconnect to peer:",
692
- peerId.slice(0, 16) + "...",
693
- `(attempt ${state.attempt + 1}/${RECONNECT_MAX_ATTEMPTS}, delay ${delay}ms)`
694
- );
695
-
696
- // clear any existing timer (shouldn't happen, but be safe)
697
- if (state.timer !== null) {
698
- clearTimeout(state.timer);
699
- }
700
-
701
- state.timer = setTimeout(() => {
702
- state!.timer = null;
703
- this.attemptReconnect(peerId);
704
- }, delay);
705
- }
706
-
707
- /**
708
- * attempt to reconnect to a peer. called by the scheduled timer from
709
- * scheduleReconnect(). on failure, schedules the next attempt.
710
- */
711
- private async attemptReconnect(peerId: string): Promise<void> {
712
- if (this._disconnected) return;
713
-
714
- // already reconnected (e.g. peer connected to us via accept loop)
715
- if (this.streams.has(peerId)) {
716
- this.clearReconnectState(peerId);
717
- return;
718
- }
719
-
720
- // peer was removed from intended set while we were waiting
721
- if (!this.intendedPeers.has(peerId)) return;
722
-
723
- const state = this.reconnectState.get(peerId);
724
- if (state) {
725
- state.attempt += 1;
726
- }
727
-
728
- try {
729
- const midden = await this.ensureMidden();
730
- const target = this.peerDialAddr.get(peerId) ?? peerId;
731
- const stream = await midden.open_bi(target, this.syncAlpn);
732
- console.log(TAG, "reconnected to peer:", peerId.slice(0, 16) + "...");
733
- this.registerStream(peerId, stream);
734
- // registerStream calls clearReconnectState, so no need to do it here
735
- } catch (err) {
736
- console.warn(TAG, "reconnect attempt failed for peer:", peerId.slice(0, 16) + "...", err);
737
- // schedule next attempt with increased backoff
738
- this.scheduleReconnect(peerId);
739
- }
740
- }
741
-
742
- /** cancel and remove any pending reconnect timer for a peer. */
743
- private clearReconnectState(peerId: string): void {
744
- const state = this.reconnectState.get(peerId);
745
- if (state) {
746
- if (state.timer !== null) {
747
- clearTimeout(state.timer);
748
- }
749
- this.reconnectState.delete(peerId);
750
- }
751
- }
752
-
753
- /** notify all connection state listeners. */
754
- private emitConnectionStateChange(): void {
755
- for (const handler of this.connectionStateListeners) {
756
- handler();
757
- }
758
- }
759
- }