@freqhole/reliquary 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (226) hide show
  1. package/dist/automerge/acl-filtering.d.ts +87 -0
  2. package/dist/automerge/acl-filtering.d.ts.map +1 -0
  3. package/dist/automerge/acl-filtering.js +138 -0
  4. package/dist/automerge/acl-filtering.test.d.ts +2 -0
  5. package/dist/automerge/acl-filtering.test.d.ts.map +1 -0
  6. package/dist/automerge/acl-filtering.test.js +302 -0
  7. package/dist/automerge/index.d.ts +5 -0
  8. package/dist/automerge/index.d.ts.map +1 -0
  9. package/dist/automerge/index.js +7 -0
  10. package/dist/automerge/iroh-network-adapter.d.ts +178 -0
  11. package/dist/automerge/iroh-network-adapter.d.ts.map +1 -0
  12. package/dist/automerge/iroh-network-adapter.js +691 -0
  13. package/dist/automerge/iroh-network-adapter.test.d.ts +2 -0
  14. package/dist/automerge/iroh-network-adapter.test.d.ts.map +1 -0
  15. package/dist/automerge/iroh-network-adapter.test.js +818 -0
  16. package/dist/automerge/types.d.ts +66 -0
  17. package/dist/automerge/types.d.ts.map +1 -0
  18. package/dist/automerge/types.js +10 -0
  19. package/dist/blobs/bytes-backend.d.ts +41 -0
  20. package/dist/blobs/bytes-backend.d.ts.map +1 -0
  21. package/dist/blobs/bytes-backend.js +252 -0
  22. package/dist/blobs/bytes-backend.test.d.ts +2 -0
  23. package/dist/blobs/bytes-backend.test.d.ts.map +1 -0
  24. package/dist/blobs/bytes-backend.test.js +241 -0
  25. package/dist/blobs/db.d.ts +8 -0
  26. package/dist/blobs/db.d.ts.map +1 -0
  27. package/dist/blobs/db.js +141 -0
  28. package/dist/blobs/db.test.d.ts +2 -0
  29. package/dist/blobs/db.test.d.ts.map +1 -0
  30. package/dist/blobs/db.test.js +120 -0
  31. package/dist/blobs/index.d.ts +5 -0
  32. package/dist/blobs/index.d.ts.map +1 -0
  33. package/dist/blobs/index.js +2 -0
  34. package/dist/blobs/store.d.ts +47 -0
  35. package/dist/blobs/store.d.ts.map +1 -0
  36. package/dist/blobs/store.js +263 -0
  37. package/dist/blobs/store.test.d.ts +2 -0
  38. package/dist/blobs/store.test.d.ts.map +1 -0
  39. package/dist/blobs/store.test.js +377 -0
  40. package/dist/blobs/types.d.ts +55 -0
  41. package/dist/blobs/types.d.ts.map +1 -0
  42. package/dist/blobs/types.js +8 -0
  43. package/dist/ensure/client.d.ts +19 -0
  44. package/dist/ensure/client.d.ts.map +1 -0
  45. package/dist/ensure/client.js +74 -0
  46. package/dist/ensure/client.test.d.ts +2 -0
  47. package/dist/ensure/client.test.d.ts.map +1 -0
  48. package/dist/ensure/client.test.js +133 -0
  49. package/dist/ensure/index.d.ts +4 -0
  50. package/dist/ensure/index.d.ts.map +1 -0
  51. package/dist/ensure/index.js +4 -0
  52. package/dist/ensure/responder.d.ts +20 -0
  53. package/dist/ensure/responder.d.ts.map +1 -0
  54. package/dist/ensure/responder.js +100 -0
  55. package/dist/ensure/responder.test.d.ts +2 -0
  56. package/dist/ensure/responder.test.d.ts.map +1 -0
  57. package/dist/ensure/responder.test.js +191 -0
  58. package/dist/ensure/types.d.ts +19 -0
  59. package/dist/ensure/types.d.ts.map +1 -0
  60. package/dist/ensure/types.js +4 -0
  61. package/dist/ensure/types.test.d.ts +2 -0
  62. package/dist/ensure/types.test.d.ts.map +1 -0
  63. package/dist/ensure/types.test.js +81 -0
  64. package/dist/index.d.ts +2 -0
  65. package/dist/index.d.ts.map +1 -0
  66. package/dist/index.js +3 -0
  67. package/dist/index.test.d.ts +2 -0
  68. package/dist/index.test.d.ts.map +1 -0
  69. package/dist/index.test.js +7 -0
  70. package/dist/solid/blob-url.d.ts +10 -0
  71. package/dist/solid/blob-url.d.ts.map +1 -0
  72. package/dist/solid/blob-url.js +34 -0
  73. package/dist/solid/blob-url.test.d.ts +2 -0
  74. package/dist/solid/blob-url.test.d.ts.map +1 -0
  75. package/dist/solid/blob-url.test.js +62 -0
  76. package/dist/solid/connection-summary.d.ts +25 -0
  77. package/dist/solid/connection-summary.d.ts.map +1 -0
  78. package/dist/solid/connection-summary.js +29 -0
  79. package/dist/solid/connection-summary.test.d.ts +2 -0
  80. package/dist/solid/connection-summary.test.d.ts.map +1 -0
  81. package/dist/solid/connection-summary.test.js +57 -0
  82. package/dist/solid/doc-store.d.ts +29 -0
  83. package/dist/solid/doc-store.d.ts.map +1 -0
  84. package/dist/solid/doc-store.js +79 -0
  85. package/dist/solid/doc-store.test.d.ts +2 -0
  86. package/dist/solid/doc-store.test.d.ts.map +1 -0
  87. package/dist/solid/doc-store.test.js +234 -0
  88. package/dist/solid/index.d.ts +4 -0
  89. package/dist/solid/index.d.ts.map +1 -0
  90. package/dist/solid/index.js +9 -0
  91. package/dist/solid/transfer-progress.d.ts +21 -0
  92. package/dist/solid/transfer-progress.d.ts.map +1 -0
  93. package/dist/solid/transfer-progress.js +32 -0
  94. package/dist/solid/transfer-progress.test.d.ts +2 -0
  95. package/dist/solid/transfer-progress.test.d.ts.map +1 -0
  96. package/dist/solid/transfer-progress.test.js +76 -0
  97. package/dist/testing/blob-fixtures.d.ts +19 -0
  98. package/dist/testing/blob-fixtures.d.ts.map +1 -0
  99. package/dist/testing/blob-fixtures.js +46 -0
  100. package/dist/testing/blob-fixtures.test.d.ts +2 -0
  101. package/dist/testing/blob-fixtures.test.d.ts.map +1 -0
  102. package/dist/testing/blob-fixtures.test.js +46 -0
  103. package/dist/testing/idb-harness.d.ts +10 -0
  104. package/dist/testing/idb-harness.d.ts.map +1 -0
  105. package/dist/testing/idb-harness.js +17 -0
  106. package/dist/testing/idb-harness.test.d.ts +2 -0
  107. package/dist/testing/idb-harness.test.d.ts.map +1 -0
  108. package/dist/testing/idb-harness.test.js +48 -0
  109. package/dist/testing/index.d.ts +7 -0
  110. package/dist/testing/index.d.ts.map +1 -0
  111. package/dist/testing/index.js +11 -0
  112. package/dist/testing/media.d.ts +15 -0
  113. package/dist/testing/media.d.ts.map +1 -0
  114. package/dist/testing/media.js +37 -0
  115. package/dist/testing/media.test.d.ts +2 -0
  116. package/dist/testing/media.test.d.ts.map +1 -0
  117. package/dist/testing/media.test.js +47 -0
  118. package/dist/testing/mock-blob-fetch.d.ts +33 -0
  119. package/dist/testing/mock-blob-fetch.d.ts.map +1 -0
  120. package/dist/testing/mock-blob-fetch.js +39 -0
  121. package/dist/testing/mock-blob-fetch.test.d.ts +2 -0
  122. package/dist/testing/mock-blob-fetch.test.d.ts.map +1 -0
  123. package/dist/testing/mock-blob-fetch.test.js +65 -0
  124. package/dist/testing/mock-node.d.ts +36 -0
  125. package/dist/testing/mock-node.d.ts.map +1 -0
  126. package/dist/testing/mock-node.js +97 -0
  127. package/dist/testing/mock-node.test.d.ts +2 -0
  128. package/dist/testing/mock-node.test.d.ts.map +1 -0
  129. package/dist/testing/mock-node.test.js +66 -0
  130. package/dist/testing/streams.d.ts +68 -0
  131. package/dist/testing/streams.d.ts.map +1 -0
  132. package/dist/testing/streams.js +99 -0
  133. package/dist/testing/streams.test.d.ts +2 -0
  134. package/dist/testing/streams.test.d.ts.map +1 -0
  135. package/dist/testing/streams.test.js +82 -0
  136. package/dist/transfer/cancellation.d.ts +8 -0
  137. package/dist/transfer/cancellation.d.ts.map +1 -0
  138. package/dist/transfer/cancellation.js +19 -0
  139. package/dist/transfer/index.d.ts +8 -0
  140. package/dist/transfer/index.d.ts.map +1 -0
  141. package/dist/transfer/index.js +4 -0
  142. package/dist/transfer/prefetch.d.ts +38 -0
  143. package/dist/transfer/prefetch.d.ts.map +1 -0
  144. package/dist/transfer/prefetch.js +61 -0
  145. package/dist/transfer/prefetch.test.d.ts +2 -0
  146. package/dist/transfer/prefetch.test.d.ts.map +1 -0
  147. package/dist/transfer/prefetch.test.js +136 -0
  148. package/dist/transfer/serve.d.ts +66 -0
  149. package/dist/transfer/serve.d.ts.map +1 -0
  150. package/dist/transfer/serve.js +126 -0
  151. package/dist/transfer/serve.test.d.ts +2 -0
  152. package/dist/transfer/serve.test.d.ts.map +1 -0
  153. package/dist/transfer/serve.test.js +101 -0
  154. package/dist/transfer/snatch.d.ts +39 -0
  155. package/dist/transfer/snatch.d.ts.map +1 -0
  156. package/dist/transfer/snatch.js +289 -0
  157. package/dist/transfer/snatch.test.d.ts +2 -0
  158. package/dist/transfer/snatch.test.d.ts.map +1 -0
  159. package/dist/transfer/snatch.test.js +337 -0
  160. package/dist/transfer/types.d.ts +85 -0
  161. package/dist/transfer/types.d.ts.map +1 -0
  162. package/dist/transfer/types.js +15 -0
  163. package/dist/utils/hash.d.ts +13 -0
  164. package/dist/utils/hash.d.ts.map +1 -0
  165. package/dist/utils/hash.js +31 -0
  166. package/dist/utils/hash.test.d.ts +2 -0
  167. package/dist/utils/hash.test.d.ts.map +1 -0
  168. package/dist/utils/hash.test.js +56 -0
  169. package/dist/utils/image-utils.d.ts +28 -0
  170. package/dist/utils/image-utils.d.ts.map +1 -0
  171. package/dist/utils/image-utils.js +113 -0
  172. package/dist/utils/image-utils.test.d.ts +2 -0
  173. package/dist/utils/image-utils.test.d.ts.map +1 -0
  174. package/dist/utils/image-utils.test.js +181 -0
  175. package/dist/utils/index.d.ts +4 -0
  176. package/dist/utils/index.d.ts.map +1 -0
  177. package/dist/utils/index.js +3 -0
  178. package/dist/utils/log.d.ts +22 -0
  179. package/dist/utils/log.d.ts.map +1 -0
  180. package/dist/utils/log.js +100 -0
  181. package/dist/utils/log.test.d.ts +2 -0
  182. package/dist/utils/log.test.d.ts.map +1 -0
  183. package/dist/utils/log.test.js +113 -0
  184. package/dist/worker/blob-worker-client.d.ts +118 -0
  185. package/dist/worker/blob-worker-client.d.ts.map +1 -0
  186. package/dist/worker/blob-worker-client.js +361 -0
  187. package/dist/worker/blob-worker-client.test.d.ts +2 -0
  188. package/dist/worker/blob-worker-client.test.d.ts.map +1 -0
  189. package/dist/worker/blob-worker-client.test.js +137 -0
  190. package/dist/worker/blob-worker-logic.d.ts +120 -0
  191. package/dist/worker/blob-worker-logic.d.ts.map +1 -0
  192. package/dist/worker/blob-worker-logic.js +368 -0
  193. package/dist/worker/blob-worker-logic.test.d.ts +2 -0
  194. package/dist/worker/blob-worker-logic.test.d.ts.map +1 -0
  195. package/dist/worker/blob-worker-logic.test.js +191 -0
  196. package/dist/worker/blob-worker.d.ts +21 -0
  197. package/dist/worker/blob-worker.d.ts.map +1 -0
  198. package/dist/worker/blob-worker.js +44 -0
  199. package/dist/worker/index.d.ts +12 -0
  200. package/dist/worker/index.d.ts.map +1 -0
  201. package/dist/worker/index.js +6 -0
  202. package/dist/worker/midden-blake3.d.ts +23 -0
  203. package/dist/worker/midden-blake3.d.ts.map +1 -0
  204. package/dist/worker/midden-blake3.js +47 -0
  205. package/dist/worker/midden-blake3.test.d.ts +2 -0
  206. package/dist/worker/midden-blake3.test.d.ts.map +1 -0
  207. package/dist/worker/midden-blake3.test.js +25 -0
  208. package/dist/worker/midden-not-bundled.stub.d.ts +1 -0
  209. package/dist/worker/midden-not-bundled.stub.d.ts.map +1 -0
  210. package/dist/worker/midden-not-bundled.stub.js +10 -0
  211. package/dist/worker/midden-stub.d.ts +10 -0
  212. package/dist/worker/midden-stub.d.ts.map +1 -0
  213. package/dist/worker/midden-stub.js +31 -0
  214. package/dist/worker/midden-stub.test.d.ts +2 -0
  215. package/dist/worker/midden-stub.test.d.ts.map +1 -0
  216. package/dist/worker/midden-stub.test.js +25 -0
  217. package/dist/worker/midden-worker-client.d.ts +103 -0
  218. package/dist/worker/midden-worker-client.d.ts.map +1 -0
  219. package/dist/worker/midden-worker-client.js +246 -0
  220. package/dist/worker/midden-worker-client.test.d.ts +2 -0
  221. package/dist/worker/midden-worker-client.test.d.ts.map +1 -0
  222. package/dist/worker/midden-worker-client.test.js +151 -0
  223. package/dist/worker/midden-worker-contract.d.ts +87 -0
  224. package/dist/worker/midden-worker-contract.d.ts.map +1 -0
  225. package/dist/worker/midden-worker-contract.js +30 -0
  226. package/package.json +90 -0
@@ -0,0 +1,691 @@
1
+ // ---------------------------------------------------------------------------
2
+ // iroh network adapter for automerge-repo
3
+ //
4
+ // bridges iroh QUIC bidirectional streams (via a midden-shaped node) with
5
+ // automerge-repo's sync protocol. messages are CBOR-encoded and
6
+ // length-delimited (4-byte BE u32 prefix), matching the framing used by
7
+ // the iroh-automerge-repo example.
8
+ // ---------------------------------------------------------------------------
9
+ import { cbor, NetworkAdapter, } from "@automerge/automerge-repo";
10
+ import { log } from "../utils/log.js";
11
+ // ---------------------------------------------------------------------------
12
+ // constants
13
+ // ---------------------------------------------------------------------------
14
+ /** base delay for reconnection backoff (ms). */
15
+ const RECONNECT_BASE_DELAY_MS = 1000;
16
+ /** maximum delay between reconnection attempts (ms). */
17
+ const RECONNECT_MAX_DELAY_MS = 30_000;
18
+ /** maximum number of reconnection attempts before giving up. */
19
+ const RECONNECT_MAX_ATTEMPTS = 8;
20
+ /** upper bound of random jitter added to each reconnect delay (ms). */
21
+ const RECONNECT_JITTER_MS = 1000;
22
+ /** ALPN protocol identifier for automerge-repo sync over iroh. */
23
+ export const SYNC_ALPN = "iroh/automerge-repo/1";
24
+ /** console log prefix. */
25
+ const TAG = "automerge.iroh-adapter";
26
+ // ---------------------------------------------------------------------------
27
+ // adapter
28
+ // ---------------------------------------------------------------------------
29
+ /**
30
+ * automerge-repo NetworkAdapter that uses iroh QUIC streams for transport.
31
+ *
32
+ * usage:
33
+ * const adapter = new IrohNetworkAdapter({
34
+ * getNode: () => getMiddenNode(),
35
+ * getIdentity: () => getStoredIdentity(),
36
+ * onIdentityChange: (cb) => subscribeToIdentityChanges(cb),
37
+ * });
38
+ * const repo = new Repo({ network: [broadcastAdapter, adapter] });
39
+ * // later, to connect to a peer:
40
+ * await adapter.addPeer("abc123...def");
41
+ */
42
+ export class IrohNetworkAdapter extends NetworkAdapter {
43
+ opts;
44
+ syncAlpn;
45
+ midden = null;
46
+ streams = new Map();
47
+ readLoops = new Map(); // peerId -> active flag
48
+ _ready = false;
49
+ _readyPromise;
50
+ _resolveReady;
51
+ _disconnected = false;
52
+ _acceptLoopRunning = false;
53
+ identityUnsub = null;
54
+ /** peers explicitly added via addPeer() that we should stay connected to. */
55
+ intendedPeers = new Set();
56
+ /** per-peer reconnection state tracking attempt count and pending timer. */
57
+ reconnectState = new Map();
58
+ /** listeners for connection state changes. */
59
+ connectionStateListeners = [];
60
+ /** peers that exceeded max reconnection attempts. */
61
+ failedPeers = new Set();
62
+ /** external handlers registered for non-sync ALPNs via registerAlpnHandler(). */
63
+ alpnHandlers = new Map();
64
+ /** listeners notified when a peer stream is established. */
65
+ peerConnectListeners = [];
66
+ /** listeners notified when a peer stream is removed. */
67
+ peerDisconnectListeners = [];
68
+ _stopped = false;
69
+ _endpointState = "off";
70
+ endpointStateListeners = [];
71
+ constructor(opts) {
72
+ super();
73
+ this.opts = opts;
74
+ this.syncAlpn = opts.syncAlpn ?? SYNC_ALPN;
75
+ this._readyPromise = new Promise((resolve) => {
76
+ this._resolveReady = resolve;
77
+ });
78
+ }
79
+ // --- NetworkAdapter interface ---
80
+ isReady() {
81
+ return this._ready;
82
+ }
83
+ whenReady() {
84
+ return this._readyPromise;
85
+ }
86
+ /**
87
+ * called by the Repo to start the adapter.
88
+ *
89
+ * marks the adapter ready (so automerge-repo can use it immediately)
90
+ * and begins transport setup if an identity already exists.
91
+ */
92
+ connect(peerId, peerMetadata) {
93
+ this.peerId = peerId;
94
+ this.peerMetadata = peerMetadata;
95
+ // the adapter is always "ready" from automerge-repo's perspective -
96
+ // it can participate in the network subsystem even before the node
97
+ // starts. actual p2p only begins once an identity exists.
98
+ this._ready = true;
99
+ this._resolveReady();
100
+ this.checkIdentityAndStart().catch((err) => {
101
+ log.error(TAG, "identity check failed:", err);
102
+ });
103
+ }
104
+ /**
105
+ * send a message to a peer.
106
+ *
107
+ * CBOR-encodes the message and writes it as a length-delimited frame
108
+ * to the peer's QUIC stream.
109
+ */
110
+ send(message) {
111
+ const targetId = message.targetId;
112
+ const stream = this.streams.get(targetId);
113
+ if (!stream) {
114
+ log.warn(TAG, "no stream for peer:", targetId.slice(0, 16) + "...");
115
+ return;
116
+ }
117
+ const encoded = cbor.encode(message);
118
+ const bytes = new Uint8Array(encoded.buffer, encoded.byteOffset, encoded.byteLength);
119
+ stream.write_message(bytes).catch((err) => {
120
+ log.error(TAG, "write failed for peer:", targetId.slice(0, 16) + "...", err);
121
+ this.removePeer(targetId);
122
+ });
123
+ }
124
+ /**
125
+ * disconnect from all peers and stop accepting connections.
126
+ */
127
+ disconnect() {
128
+ this._disconnected = true;
129
+ for (const [peerId, stream] of this.streams) {
130
+ stream.close();
131
+ this.emit("peer-disconnected", { peerId: peerId });
132
+ }
133
+ this.streams.clear();
134
+ this.readLoops.clear();
135
+ this.intendedPeers.clear();
136
+ this.failedPeers.clear();
137
+ this.alpnHandlers.clear();
138
+ for (const [, state] of this.reconnectState) {
139
+ if (state.timer !== null) {
140
+ clearTimeout(state.timer);
141
+ }
142
+ }
143
+ this.reconnectState.clear();
144
+ this.connectionStateListeners.length = 0;
145
+ this.peerConnectListeners.length = 0;
146
+ this.peerDisconnectListeners.length = 0;
147
+ this.endpointStateListeners.length = 0;
148
+ if (this.identityUnsub) {
149
+ this.identityUnsub();
150
+ this.identityUnsub = null;
151
+ }
152
+ this.setEndpointState("off");
153
+ this.emit("close");
154
+ }
155
+ // --- public API (beyond NetworkAdapter) ---
156
+ /**
157
+ * connect to a peer by node ID.
158
+ *
159
+ * opens a bidirectional QUIC stream to the peer on the automerge sync
160
+ * ALPN, starts a read loop, and emits a peer-candidate event so
161
+ * automerge-repo begins syncing with this peer.
162
+ */
163
+ async addPeer(nodeId) {
164
+ if (this._disconnected) {
165
+ throw new Error("adapter is disconnected");
166
+ }
167
+ // track this peer as one we intend to stay connected to
168
+ this.intendedPeers.add(nodeId);
169
+ this.failedPeers.delete(nodeId);
170
+ this.emitConnectionStateChange();
171
+ // clear any pending reconnection state - this is a fresh attempt
172
+ this.clearReconnectState(nodeId);
173
+ // remember the peer but don't connect while stopped
174
+ if (this._stopped)
175
+ return;
176
+ // skip if already connected
177
+ if (this.streams.has(nodeId)) {
178
+ log.debug(TAG, "already connected to:", nodeId.slice(0, 16) + "...");
179
+ return;
180
+ }
181
+ const midden = await this.ensureMidden();
182
+ const stream = await this.openBiWithRetry(midden, nodeId);
183
+ this.registerStream(nodeId, stream, "outbound");
184
+ }
185
+ /**
186
+ * dial a peer with a short retry loop for the initial connection attempt.
187
+ *
188
+ * discovery (relay-assisted address exchange) can lag a peer's node-id
189
+ * registration by a second or so right after both endpoints come
190
+ * online - dialing during that window fails with "no addressing
191
+ * information available" even though the peer is reachable moments
192
+ * later. retry a handful of times with a short fixed delay before
193
+ * giving up; this is a much tighter loop than the general reconnect
194
+ * backoff below, which is for peers that were connected and then
195
+ * dropped.
196
+ */
197
+ async openBiWithRetry(midden, nodeId, maxAttempts = 4, delayMs = 750) {
198
+ let lastErr;
199
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
200
+ try {
201
+ return await midden.open_bi(nodeId, this.syncAlpn);
202
+ }
203
+ catch (err) {
204
+ lastErr = err;
205
+ log.debug(TAG, `open_bi attempt ${attempt}/${maxAttempts} failed for`, nodeId.slice(0, 16) + "...", err);
206
+ if (attempt < maxAttempts) {
207
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
208
+ }
209
+ }
210
+ }
211
+ throw lastErr;
212
+ }
213
+ /**
214
+ * stop maintaining a connection to a peer.
215
+ *
216
+ * removes the peer from intendedPeers, cancels any pending reconnection,
217
+ * and closes any existing stream. use this when you intentionally want
218
+ * to stop connecting to a peer (as opposed to a transient failure).
219
+ */
220
+ forgetPeer(nodeId) {
221
+ // remove from intended set first so removePeer() won't schedule a reconnect
222
+ this.intendedPeers.delete(nodeId);
223
+ this.failedPeers.delete(nodeId);
224
+ this.clearReconnectState(nodeId);
225
+ // delegate stream cleanup and peer-disconnected emission to removePeer
226
+ this.removePeer(nodeId);
227
+ this.emitConnectionStateChange();
228
+ }
229
+ /** check whether we have an active stream to a peer (transport-level connectivity check). */
230
+ isConnected(nodeId) {
231
+ return this.streams.has(nodeId);
232
+ }
233
+ /**
234
+ * get a summary of the current connection state.
235
+ * used by the UI to show stoplight-style indicators.
236
+ */
237
+ getConnectionSummary() {
238
+ let connected = 0;
239
+ let reconnecting = 0;
240
+ for (const peerId of this.intendedPeers) {
241
+ if (this.streams.has(peerId)) {
242
+ connected++;
243
+ }
244
+ else if (this.reconnectState.has(peerId)) {
245
+ reconnecting++;
246
+ }
247
+ }
248
+ return { connected, reconnecting, failed: this.failedPeers.size };
249
+ }
250
+ /**
251
+ * subscribe to connection state changes.
252
+ * fires whenever a peer connects, disconnects, starts reconnecting,
253
+ * or gives up. returns an unsubscribe function.
254
+ */
255
+ onConnectionStateChange(handler) {
256
+ this.connectionStateListeners.push(handler);
257
+ return () => {
258
+ const idx = this.connectionStateListeners.indexOf(handler);
259
+ if (idx >= 0)
260
+ this.connectionStateListeners.splice(idx, 1);
261
+ };
262
+ }
263
+ /**
264
+ * subscribe to peer connect events. fires when a stream is
265
+ * successfully established with a peer. returns an unsubscribe function.
266
+ */
267
+ onPeerConnect(handler) {
268
+ this.peerConnectListeners.push(handler);
269
+ return () => {
270
+ const idx = this.peerConnectListeners.indexOf(handler);
271
+ if (idx >= 0)
272
+ this.peerConnectListeners.splice(idx, 1);
273
+ };
274
+ }
275
+ /**
276
+ * subscribe to peer disconnect events. fires when a peer's stream
277
+ * is removed (closed or errored). returns an unsubscribe function.
278
+ */
279
+ onPeerDisconnect(handler) {
280
+ this.peerDisconnectListeners.push(handler);
281
+ return () => {
282
+ const idx = this.peerDisconnectListeners.indexOf(handler);
283
+ if (idx >= 0)
284
+ this.peerDisconnectListeners.splice(idx, 1);
285
+ };
286
+ }
287
+ /**
288
+ * retry connection to all failed peers.
289
+ * re-adds them to intendedPeers and starts fresh reconnection attempts.
290
+ */
291
+ retryFailedPeers() {
292
+ if (this._disconnected)
293
+ return;
294
+ const failed = [...this.failedPeers];
295
+ this.failedPeers.clear();
296
+ for (const peerId of failed) {
297
+ this.intendedPeers.add(peerId);
298
+ this.scheduleReconnect(peerId);
299
+ }
300
+ this.emitConnectionStateChange();
301
+ }
302
+ /** get the current iroh endpoint state for UI display. */
303
+ getEndpointState() {
304
+ return this._endpointState;
305
+ }
306
+ /** subscribe to endpoint state changes; returns an unsubscribe function. */
307
+ onEndpointStateChange(handler) {
308
+ this.endpointStateListeners.push(handler);
309
+ return () => {
310
+ const idx = this.endpointStateListeners.indexOf(handler);
311
+ if (idx >= 0)
312
+ this.endpointStateListeners.splice(idx, 1);
313
+ };
314
+ }
315
+ /**
316
+ * stop all P2P transport gracefully.
317
+ * closes all peer streams and cancels reconnections, but preserves
318
+ * intendedPeers so restart() can reconnect them. the node reference is
319
+ * dropped so restart() creates a fresh one via getNode().
320
+ */
321
+ stop() {
322
+ if (this._stopped || this._disconnected)
323
+ return;
324
+ this._stopped = true;
325
+ for (const [peerId, stream] of this.streams) {
326
+ stream.close();
327
+ this.emit("peer-disconnected", { peerId: peerId });
328
+ }
329
+ this.streams.clear();
330
+ this.readLoops.clear();
331
+ this.failedPeers.clear();
332
+ for (const [, state] of this.reconnectState) {
333
+ if (state.timer !== null)
334
+ clearTimeout(state.timer);
335
+ }
336
+ this.reconnectState.clear();
337
+ this.midden = null;
338
+ this.setEndpointState("off");
339
+ this.emitConnectionStateChange();
340
+ }
341
+ /**
342
+ * restart the P2P transport after stop().
343
+ * re-initializes the node and reconnects all remembered peers.
344
+ */
345
+ async restart() {
346
+ if (!this._stopped || this._disconnected)
347
+ return;
348
+ this._stopped = false;
349
+ await this.initialize();
350
+ for (const peerId of this.intendedPeers) {
351
+ this.addPeer(peerId).catch((err) => {
352
+ log.warn(TAG, "restart: failed to re-add peer:", peerId.slice(0, 16) + "...", err);
353
+ });
354
+ }
355
+ }
356
+ /**
357
+ * register a handler for incoming streams with a specific ALPN.
358
+ * the accept loop will dispatch matching streams to this handler
359
+ * instead of closing them. used to support additional protocols
360
+ * alongside automerge sync without forking this adapter.
361
+ */
362
+ registerAlpnHandler(alpn, handler) {
363
+ this.alpnHandlers.set(alpn, handler);
364
+ }
365
+ /**
366
+ * get the midden stream node, initializing it lazily if needed.
367
+ * exposed for use by protocol handlers that need to open outbound
368
+ * streams on a different ALPN.
369
+ */
370
+ async getNode() {
371
+ return this.ensureMidden();
372
+ }
373
+ // --- internals ---
374
+ async checkIdentityAndStart() {
375
+ const identity = await this.opts.getIdentity();
376
+ if (identity) {
377
+ // an identity already exists - start the node and begin accepting
378
+ // connections right away
379
+ log.debug(TAG, "identity found, starting P2P transport");
380
+ await this.initialize();
381
+ }
382
+ else if (this.opts.onIdentityChange) {
383
+ // no identity yet - subscribe to identity changes so transport
384
+ // starts once one is created
385
+ log.debug(TAG, "no identity yet, deferring P2P transport");
386
+ this.identityUnsub = this.opts.onIdentityChange((newIdentity) => {
387
+ if (newIdentity && !this.midden && !this._disconnected) {
388
+ log.debug(TAG, "identity created, starting P2P transport");
389
+ this.initialize().catch((err) => {
390
+ log.error(TAG, "deferred initialization failed:", err);
391
+ });
392
+ }
393
+ });
394
+ }
395
+ else {
396
+ // no identity and no subscription - adapter stays passive
397
+ log.debug(TAG, "no identity and no onIdentityChange provided, adapter is passive");
398
+ }
399
+ }
400
+ async initialize() {
401
+ this.setEndpointState("starting");
402
+ try {
403
+ await this.ensureMidden();
404
+ this.startAcceptLoop();
405
+ this.setEndpointState("online");
406
+ }
407
+ catch (err) {
408
+ log.error(TAG, "failed to initialize node:", err);
409
+ this.setEndpointState("error");
410
+ throw err;
411
+ }
412
+ }
413
+ async ensureMidden() {
414
+ if (this.midden)
415
+ return this.midden;
416
+ this.midden = await this.opts.getNode();
417
+ return this.midden;
418
+ }
419
+ startAcceptLoop() {
420
+ if (this._acceptLoopRunning)
421
+ return;
422
+ this._acceptLoopRunning = true;
423
+ const loop_ = async () => {
424
+ const midden = await this.ensureMidden();
425
+ while (!this._disconnected && !this._stopped) {
426
+ try {
427
+ const stream = await midden.accept();
428
+ if (!stream) {
429
+ // endpoint closed
430
+ log.debug(TAG, "accept loop: endpoint closed");
431
+ break;
432
+ }
433
+ const alpn = stream.alpn();
434
+ const peerId = stream.peer_node_id();
435
+ if (alpn === this.syncAlpn) {
436
+ // automerge-repo sync - handle internally
437
+ log.debug(TAG, "accepted sync connection from:", peerId.slice(0, 16) + "...");
438
+ this.registerStream(peerId, stream, "inbound");
439
+ }
440
+ else {
441
+ const handler = this.alpnHandlers.get(alpn);
442
+ if (handler) {
443
+ log.debug(TAG, "dispatching", alpn, "stream from:", peerId.slice(0, 16) + "...");
444
+ handler(stream);
445
+ }
446
+ else {
447
+ // no handler registered - close the stream
448
+ log.warn(TAG, "dropping inbound stream - no handler registered for ALPN:", alpn, "registered handlers:", Array.from(this.alpnHandlers.keys()));
449
+ stream.close();
450
+ }
451
+ }
452
+ }
453
+ catch (err) {
454
+ if (this._disconnected || this._stopped)
455
+ break;
456
+ log.error(TAG, "accept loop error:", err);
457
+ // brief pause before retrying to avoid tight error loops
458
+ await new Promise((r) => setTimeout(r, 1000));
459
+ }
460
+ }
461
+ this._acceptLoopRunning = false;
462
+ };
463
+ loop_().catch((err) => {
464
+ log.error(TAG, "accept loop crashed:", err);
465
+ this._acceptLoopRunning = false;
466
+ });
467
+ }
468
+ registerStream(peerId, stream, direction) {
469
+ // duplicate-stream handling (simultaneous connect, peer restart): the
470
+ // NEWEST stream always takes over for writes, and the replaced stream
471
+ // is NEVER closed - its read loop keeps draining it until it actually
472
+ // dies (guarded cleanup below ignores non-active streams).
473
+ //
474
+ // why not close the old one? when both peers dial each other at once,
475
+ // "last one wins + close the loser" resolves differently on each side
476
+ // - each side keeps its own dial and closes the stream the other side
477
+ // kept, killing BOTH connections and any in-flight sync requests. and
478
+ // why not a deterministic keep-the-incumbent tiebreak? the incumbent
479
+ // can be a zombie whose death hasn't been detected yet (the peer
480
+ // process was killed and restarted): writes routed to it silently
481
+ // vanish and recovery deadlocks. writing to the newest stream and
482
+ // reading from all of them is correct in every case - sync flows
483
+ // regardless of which stream each side picks for its writes, and dead
484
+ // streams get reaped by their own read-loop errors.
485
+ const existing = this.streams.get(peerId);
486
+ let reannounceDelayMs = 0;
487
+ if (existing) {
488
+ log.debug(TAG, `duplicate stream for ${peerId.slice(0, 16)}...: new ${direction} stream takes writes; old stream stays open for reads until it dies`);
489
+ // cycle the repo-level peer: automerge-repo ignores a repeated
490
+ // peer-candidate for a peer it already considers connected, so a
491
+ // superseding stream (e.g. a restarted peer redialing before we've
492
+ // noticed the old stream died) would otherwise never restart doc
493
+ // sync. an explicit disconnected -> candidate cycle resets the
494
+ // repo's sync state for this peer; the candidate is re-announced a
495
+ // tick later so the repo's own async disconnect cleanup finishes
496
+ // first.
497
+ if (!this._disconnected && !this._stopped) {
498
+ this.emit("peer-disconnected", { peerId: peerId });
499
+ reannounceDelayMs = 100;
500
+ }
501
+ }
502
+ this.streams.set(peerId, stream);
503
+ // connection established - clear any reconnection backoff state
504
+ this.clearReconnectState(peerId);
505
+ this.emitConnectionStateChange();
506
+ // notify peer-connect listeners
507
+ for (const h of this.peerConnectListeners)
508
+ h(peerId);
509
+ // emit peer-candidate so automerge-repo starts syncing. the read loop
510
+ // starts first either way - sync messages can arrive as soon as the
511
+ // candidate is announced.
512
+ this.startReadLoop(peerId, stream);
513
+ const announce = () => {
514
+ if (this._disconnected || this._stopped)
515
+ return;
516
+ if (this.streams.get(peerId) !== stream)
517
+ return; // superseded already
518
+ this.emit("peer-candidate", {
519
+ peerId: peerId,
520
+ peerMetadata: { isEphemeral: false },
521
+ });
522
+ };
523
+ if (reannounceDelayMs > 0) {
524
+ setTimeout(announce, reannounceDelayMs);
525
+ }
526
+ else {
527
+ announce();
528
+ }
529
+ }
530
+ startReadLoop(peerId, stream) {
531
+ this.readLoops.set(peerId, true);
532
+ const loop_ = async () => {
533
+ while (this.readLoops.get(peerId) && !this._disconnected && !this._stopped) {
534
+ try {
535
+ const data = await stream.read_message();
536
+ if (!data) {
537
+ // stream closed cleanly
538
+ log.debug(TAG, "stream closed by peer:", peerId.slice(0, 16) + "...");
539
+ break;
540
+ }
541
+ // CBOR-decode the message
542
+ const message = cbor.decode(data);
543
+ // ensure the senderId is set to the peer's node ID
544
+ message.senderId = peerId;
545
+ this.emit("message", message);
546
+ }
547
+ catch (err) {
548
+ if (this._disconnected || this._stopped)
549
+ break;
550
+ log.error(TAG, "read error from peer:", peerId.slice(0, 16) + "...", err);
551
+ break;
552
+ }
553
+ }
554
+ // only clean up the PEER if this stream is still the active one. if
555
+ // registerStream() superseded us with a newer stream, that one owns
556
+ // the peer now - calling removePeer here would incorrectly kill it.
557
+ // either way, close our own stream so its resources are released.
558
+ if (this.streams.get(peerId) === stream) {
559
+ this.removePeer(peerId);
560
+ }
561
+ else {
562
+ stream.close();
563
+ }
564
+ };
565
+ loop_().catch((err) => {
566
+ log.error(TAG, "read loop crashed for peer:", peerId.slice(0, 16) + "...", err);
567
+ if (this.streams.get(peerId) === stream) {
568
+ this.removePeer(peerId);
569
+ }
570
+ else {
571
+ stream.close();
572
+ }
573
+ });
574
+ }
575
+ removePeer(peerId) {
576
+ const stream = this.streams.get(peerId);
577
+ if (stream) {
578
+ stream.close();
579
+ this.streams.delete(peerId);
580
+ this.emitConnectionStateChange();
581
+ // notify peer-disconnect listeners
582
+ for (const h of this.peerDisconnectListeners)
583
+ h(peerId);
584
+ }
585
+ this.readLoops.delete(peerId);
586
+ if (!this._disconnected && !this._stopped) {
587
+ this.emit("peer-disconnected", { peerId: peerId });
588
+ // if this was an intended peer, schedule a reconnection attempt
589
+ if (this.intendedPeers.has(peerId)) {
590
+ this.scheduleReconnect(peerId);
591
+ }
592
+ }
593
+ }
594
+ // --- reconnection logic ---
595
+ /**
596
+ * schedule a reconnection attempt for a peer using exponential backoff
597
+ * with random jitter.
598
+ */
599
+ scheduleReconnect(peerId) {
600
+ if (this._disconnected || this._stopped)
601
+ return;
602
+ // already reconnected while we were setting up
603
+ if (this.streams.has(peerId))
604
+ return;
605
+ // get or create reconnect state for this peer
606
+ let state = this.reconnectState.get(peerId);
607
+ if (!state) {
608
+ state = { attempt: 0, timer: null };
609
+ this.reconnectState.set(peerId, state);
610
+ }
611
+ // give up after max attempts
612
+ if (state.attempt >= RECONNECT_MAX_ATTEMPTS) {
613
+ log.warn(TAG, "giving up reconnection to peer after", RECONNECT_MAX_ATTEMPTS, "attempts:", peerId.slice(0, 16) + "...");
614
+ this.intendedPeers.delete(peerId);
615
+ this.failedPeers.add(peerId);
616
+ this.clearReconnectState(peerId);
617
+ this.emitConnectionStateChange();
618
+ return;
619
+ }
620
+ // exponential backoff: baseDelay * 2^attempt, capped at maxDelay
621
+ const exponentialDelay = RECONNECT_BASE_DELAY_MS * Math.pow(2, state.attempt);
622
+ const cappedDelay = Math.min(exponentialDelay, RECONNECT_MAX_DELAY_MS);
623
+ // add random jitter to avoid simultaneous-open races
624
+ const jitter = Math.floor(Math.random() * RECONNECT_JITTER_MS);
625
+ const delay = cappedDelay + jitter;
626
+ log.debug(TAG, "scheduling reconnect to peer:", peerId.slice(0, 16) + "...", `(attempt ${state.attempt + 1}/${RECONNECT_MAX_ATTEMPTS}, delay ${delay}ms)`);
627
+ // clear any existing timer (shouldn't happen, but be safe)
628
+ if (state.timer !== null) {
629
+ clearTimeout(state.timer);
630
+ }
631
+ state.timer = setTimeout(() => {
632
+ state.timer = null;
633
+ this.attemptReconnect(peerId);
634
+ }, delay);
635
+ }
636
+ /**
637
+ * attempt to reconnect to a peer. called by the scheduled timer from
638
+ * scheduleReconnect(). on failure, schedules the next attempt.
639
+ */
640
+ async attemptReconnect(peerId) {
641
+ if (this._disconnected || this._stopped)
642
+ return;
643
+ // already reconnected (e.g. peer connected to us via accept loop)
644
+ if (this.streams.has(peerId)) {
645
+ this.clearReconnectState(peerId);
646
+ return;
647
+ }
648
+ // peer was removed from intended set while we were waiting
649
+ if (!this.intendedPeers.has(peerId))
650
+ return;
651
+ const state = this.reconnectState.get(peerId);
652
+ if (state) {
653
+ state.attempt += 1;
654
+ }
655
+ try {
656
+ const midden = await this.ensureMidden();
657
+ const stream = await midden.open_bi(peerId, this.syncAlpn);
658
+ log.debug(TAG, "reconnected to peer:", peerId.slice(0, 16) + "...");
659
+ // registerStream calls clearReconnectState, so no need to do it here
660
+ this.registerStream(peerId, stream, "outbound");
661
+ }
662
+ catch (err) {
663
+ log.warn(TAG, "reconnect attempt failed for peer:", peerId.slice(0, 16) + "...", err);
664
+ // schedule next attempt with increased backoff
665
+ this.scheduleReconnect(peerId);
666
+ }
667
+ }
668
+ /** notify all connection state listeners. */
669
+ emitConnectionStateChange() {
670
+ for (const handler of this.connectionStateListeners) {
671
+ handler();
672
+ }
673
+ }
674
+ setEndpointState(state) {
675
+ if (this._endpointState === state)
676
+ return;
677
+ this._endpointState = state;
678
+ for (const h of this.endpointStateListeners)
679
+ h(state);
680
+ }
681
+ /** cancel and remove any pending reconnect timer for a peer. */
682
+ clearReconnectState(peerId) {
683
+ const state = this.reconnectState.get(peerId);
684
+ if (state) {
685
+ if (state.timer !== null) {
686
+ clearTimeout(state.timer);
687
+ }
688
+ this.reconnectState.delete(peerId);
689
+ }
690
+ }
691
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=iroh-network-adapter.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"iroh-network-adapter.test.d.ts","sourceRoot":"","sources":["../../src/automerge/iroh-network-adapter.test.ts"],"names":[],"mappings":""}