@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.
- package/package.json +7 -16
- package/src/CharnelTransport.ts +7 -7
- package/src/WasmTransport.ts +23 -22
- package/src/permissions.ts +16 -29
- package/src/pollingJobEvents.test.ts +192 -0
- package/src/test/permissions.ts +185 -0
- package/src/test.ts +5 -14
- package/src/transport.ts +10 -3
- package/vitest.config.ts +8 -0
- package/src/automerge/IrohNetworkAdapter.ts +0 -759
- package/src/automerge/index.ts +0 -14
- package/src/storage/blobs.ts +0 -272
- package/src/storage/identity.ts +0 -190
- package/src/storage/index.ts +0 -5
- package/src/storage/webLocks.ts +0 -92
- package/src/test/automerge.ts +0 -796
- package/src/test/storage.ts +0 -443
|
@@ -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
|
-
}
|