reploid 0.0.0-stage → 0.1.1

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 (233) hide show
  1. package/apps/poolday/config/chat-files.json +27 -0
  2. package/apps/poolday/config/partition-generation.js +21 -0
  3. package/apps/poolday/config/partition-policy.json +74 -0
  4. package/apps/poolday/config/partition-releases.json +3 -0
  5. package/apps/poolday/config/work-profile.json +122 -0
  6. package/apps/poolday/host/chat-view.js +47 -0
  7. package/apps/poolday/host/earning/client.js +199 -0
  8. package/apps/poolday/host/earning/contribution.js +168 -0
  9. package/apps/poolday/host/partition-release.js +33 -0
  10. package/apps/poolday/host/work-partition-artifact-source.js +37 -0
  11. package/apps/poolday/host/work-partition-capsule.js +39 -0
  12. package/apps/poolday/host/work-partition-plan.js +167 -0
  13. package/apps/poolday/host/work-partition-tokenizer-guard.js +21 -0
  14. package/apps/poolday/infrastructure/model-storage-lock.js +22 -0
  15. package/apps/poolday/infrastructure/pack-release-storage.js +52 -0
  16. package/apps/poolday/pool/pack-operation.js +161 -0
  17. package/apps/poolday/pool/pack-release-policy.js +44 -0
  18. package/apps/poolday/pool/peer-pack-data-channel.js +217 -0
  19. package/apps/poolday/providers/work-adapter.js +67 -0
  20. package/apps/poolday/providers/work-device.js +33 -0
  21. package/apps/poolday/providers/work-generation-completion.js +55 -0
  22. package/apps/poolday/providers/work-provider.js +209 -0
  23. package/apps/poolday/providers/work-resident-provider.js +113 -0
  24. package/deploy/artifacts/doppler-gpu-0.7.1.tgz +0 -0
  25. package/docs/fleet-service.md +214 -0
  26. package/docs/native-participant.md +542 -0
  27. package/native-delivery.json +1174 -0
  28. package/package.json +29 -4
  29. package/packages/reploid/src/artifacts/binary.js +25 -0
  30. package/packages/reploid/src/artifacts/canonical-json.js +23 -0
  31. package/packages/reploid/src/artifacts/custody/exchange.js +402 -0
  32. package/packages/reploid/src/artifacts/custody/piece-acquisition.js +70 -0
  33. package/packages/reploid/src/artifacts/custody/runtime.js +404 -0
  34. package/packages/reploid/src/artifacts/custody/supply-budget.js +19 -0
  35. package/packages/reploid/src/artifacts/custody-certificate.js +123 -0
  36. package/packages/reploid/src/artifacts/delivery-attestation.js +64 -0
  37. package/packages/reploid/src/artifacts/generation-terminal.js +36 -0
  38. package/packages/reploid/src/artifacts/identity.js +91 -0
  39. package/packages/reploid/src/artifacts/participant-proofs.js +159 -0
  40. package/packages/reploid/src/artifacts/receipt.js +131 -0
  41. package/packages/reploid/src/artifacts/signatures.js +94 -0
  42. package/packages/reploid/src/artifacts/whole-model-receipt.js +42 -0
  43. package/packages/reploid/src/cancellation.js +44 -0
  44. package/packages/reploid/src/config/defaults.json +110 -0
  45. package/packages/reploid/src/config/index.js +209 -0
  46. package/packages/reploid/src/config/request-overrides.json +9 -0
  47. package/packages/reploid/src/config/transport-defaults.json +7 -0
  48. package/packages/reploid/src/hashing.js +30 -0
  49. package/packages/reploid/src/measurement/capacity-utilization.js +321 -0
  50. package/packages/reploid/src/measurement/index.js +5 -0
  51. package/packages/reploid/src/measurement/readiness-records.js +310 -0
  52. package/packages/reploid/src/mesh/computer-identity.js +50 -0
  53. package/packages/reploid/src/mesh/computer-identity.policy.json +8 -0
  54. package/packages/reploid/src/mesh/contribution.js +141 -0
  55. package/packages/reploid/src/mesh/generation-recovery.js +58 -0
  56. package/packages/reploid/src/mesh/gpu-signature-shader.js +27 -0
  57. package/packages/reploid/src/mesh/gpu-signature.js +136 -0
  58. package/packages/reploid/src/mesh/gpu-signature.policy.json +11 -0
  59. package/packages/reploid/src/mesh/index.js +66 -0
  60. package/packages/reploid/src/mesh/partitions/automatic-ordered-partitions.js +540 -0
  61. package/packages/reploid/src/mesh/partitions/automatic-partitions.js +298 -0
  62. package/packages/reploid/src/mesh/partitions/partition-attempt-evidence.js +282 -0
  63. package/packages/reploid/src/mesh/partitions/partition-attempt-queue-policy.json +6 -0
  64. package/packages/reploid/src/mesh/partitions/partition-attempt-queue.js +112 -0
  65. package/packages/reploid/src/mesh/partitions/partition-canonical-contract.js +167 -0
  66. package/packages/reploid/src/mesh/partitions/partition-canonical-execution.js +168 -0
  67. package/packages/reploid/src/mesh/partitions/partition-chat-canonical.js +49 -0
  68. package/packages/reploid/src/mesh/partitions/partition-chat.js +125 -0
  69. package/packages/reploid/src/mesh/partitions/partition-contract.js +192 -0
  70. package/packages/reploid/src/mesh/partitions/partition-device-admission.js +96 -0
  71. package/packages/reploid/src/mesh/partitions/partition-discovery.js +96 -0
  72. package/packages/reploid/src/mesh/partitions/partition-entry-publication.js +183 -0
  73. package/packages/reploid/src/mesh/partitions/partition-entry.js +434 -0
  74. package/packages/reploid/src/mesh/partitions/partition-generation-request.js +33 -0
  75. package/packages/reploid/src/mesh/partitions/partition-grants.js +277 -0
  76. package/packages/reploid/src/mesh/partitions/partition-link.js +103 -0
  77. package/packages/reploid/src/mesh/partitions/partition-network.js +109 -0
  78. package/packages/reploid/src/mesh/partitions/partition-ordered-chat.js +290 -0
  79. package/packages/reploid/src/mesh/partitions/partition-ordered-peer.js +415 -0
  80. package/packages/reploid/src/mesh/partitions/partition-ordered-publication.js +100 -0
  81. package/packages/reploid/src/mesh/partitions/partition-peer.js +314 -0
  82. package/packages/reploid/src/mesh/partitions/partition-placement.js +143 -0
  83. package/packages/reploid/src/mesh/partitions/partition-preparation-progress.js +25 -0
  84. package/packages/reploid/src/mesh/partitions/partition-receiver-policy.json +7 -0
  85. package/packages/reploid/src/mesh/partitions/partition-reservations.js +75 -0
  86. package/packages/reploid/src/mesh/partitions/partition-route-envelope-policy.json +16 -0
  87. package/packages/reploid/src/mesh/partitions/partition-route-envelope.js +74 -0
  88. package/packages/reploid/src/mesh/partitions/partition-route-execution.js +409 -0
  89. package/packages/reploid/src/mesh/partitions/partition-route-network.js +179 -0
  90. package/packages/reploid/src/mesh/partitions/partition-route-ranking.js +268 -0
  91. package/packages/reploid/src/mesh/partitions/partition-runner.js +319 -0
  92. package/packages/reploid/src/mesh/partitions/partition-stage-assignment.js +109 -0
  93. package/packages/reploid/src/mesh/partitions/partition-stage-commit.js +103 -0
  94. package/packages/reploid/src/mesh/partitions/partition-step-receiver.js +291 -0
  95. package/packages/reploid/src/mesh/partitions/partition-summary.js +122 -0
  96. package/packages/reploid/src/mesh/partitions/preparation-owner.js +40 -0
  97. package/packages/reploid/src/mesh/partitions/resident-error-codes.json +10 -0
  98. package/packages/reploid/src/mesh/partitions/resident-partition.js +333 -0
  99. package/packages/reploid/src/mesh/partitions/resident-route.js +46 -0
  100. package/packages/reploid/src/mesh/partitions/signed-resident-factory.js +59 -0
  101. package/packages/reploid/src/mesh/peer-generation.js +896 -0
  102. package/packages/reploid/src/mesh/peer-identity.js +87 -0
  103. package/packages/reploid/src/mesh/placement-beliefs.js +107 -0
  104. package/packages/reploid/src/mesh/preparation-progress.js +46 -0
  105. package/packages/reploid/src/mesh/provider-hardware.js +11 -0
  106. package/packages/reploid/src/mesh/remote-generation-requests.js +53 -0
  107. package/packages/reploid/src/mesh/swarm-coordination.js +140 -0
  108. package/packages/reploid/src/mesh/web-access-network.js +112 -0
  109. package/packages/reploid/src/mesh/web-access-policy.json +17 -0
  110. package/packages/reploid/src/retrieval/citations.js +156 -0
  111. package/packages/reploid/src/retrieval/claim-support.js +113 -0
  112. package/packages/reploid/src/retrieval/costs.js +101 -0
  113. package/packages/reploid/src/retrieval/fixed-provider.js +34 -0
  114. package/packages/reploid/src/retrieval/index.js +20 -0
  115. package/packages/reploid/src/retrieval/lexical-support.js +27 -0
  116. package/packages/reploid/src/retrieval/model-reviewer.js +67 -0
  117. package/packages/reploid/src/retrieval/page-fetch.json +10 -0
  118. package/packages/reploid/src/retrieval/page-text.js +182 -0
  119. package/packages/reploid/src/retrieval/passage.js +37 -0
  120. package/packages/reploid/src/retrieval/policy.json +14 -0
  121. package/packages/reploid/src/retrieval/retrieval.js +23 -0
  122. package/packages/reploid/src/retrieval/review-queue.js +76 -0
  123. package/packages/reploid/src/retrieval/sources.js +263 -0
  124. package/packages/reploid/src/retrieval/support-review.js +155 -0
  125. package/packages/reploid/src/retrieval/text.js +21 -0
  126. package/packages/reploid/src/rules/contribution.rules.json +8 -0
  127. package/packages/reploid/src/rules/routing.rules.json +24 -0
  128. package/packages/reploid/src/transport/assignment.js +571 -0
  129. package/packages/reploid/src/transport/bounded-channel-writer.js +46 -0
  130. package/packages/reploid/src/transport/index.js +11 -0
  131. package/packages/reploid/src/transport/introduction-admission.js +24 -0
  132. package/packages/reploid/src/transport/introduction-bootstrap.js +112 -0
  133. package/packages/reploid/src/transport/introduction-policy.json +12 -0
  134. package/packages/reploid/src/transport/introduction-records.js +90 -0
  135. package/packages/reploid/src/transport/introductions.js +209 -0
  136. package/packages/reploid/src/transport/partition-data-channel.js +421 -0
  137. package/packages/reploid/src/transport/partition-errors.json +16 -0
  138. package/packages/reploid/src/transport/retry-policy.js +36 -0
  139. package/packages/reploid/src/transport/room.js +502 -0
  140. package/packages/reploid/src/transport/sdp-fingerprint.js +5 -0
  141. package/packages/reploid/src/transport/signaling.js +571 -0
  142. package/packages/reploid/src/transport/swarm.js +1247 -0
  143. package/packages/reploid/src/transport/tool-offer-channel.js +137 -0
  144. package/server/browser-roots.js +42 -0
  145. package/server/browser-web-worker.js +94 -0
  146. package/server/chat-completions.js +45 -0
  147. package/server/fleet/browser-bridge.js +145 -0
  148. package/server/fleet/contract.js +42 -0
  149. package/server/fleet/credit-evidence.js +103 -0
  150. package/server/fleet/credit-recovery.js +175 -0
  151. package/server/fleet/credits.js +178 -0
  152. package/server/fleet/execution-admission.js +32 -0
  153. package/server/fleet/native-host.js +817 -0
  154. package/server/fleet/placement.js +132 -0
  155. package/server/fleet/requester.js +346 -0
  156. package/server/fleet/service.js +311 -0
  157. package/server/fleet/terminal-policy.js +83 -0
  158. package/server/fleet/text-api.js +94 -0
  159. package/server/hashing.js +35 -0
  160. package/server/http-request-owner.js +49 -0
  161. package/server/messages.js +57 -0
  162. package/server/native-canonical-epochs.js +90 -0
  163. package/server/native-change-log.js +40 -0
  164. package/server/native-earning.js +203 -0
  165. package/server/native-generation-journal.js +46 -0
  166. package/server/native-model-config.js +57 -0
  167. package/server/native-model-files.js +382 -0
  168. package/server/native-model-origin.js +54 -0
  169. package/server/native-model-origins.json +12 -0
  170. package/server/native-node.js +85 -0
  171. package/server/native-owner/controls.js +162 -0
  172. package/server/native-owner/monitor.js +251 -0
  173. package/server/native-owner/policy.js +273 -0
  174. package/server/native-owner/rate.js +92 -0
  175. package/server/native-participant.js +866 -0
  176. package/server/native-readiness-log.js +30 -0
  177. package/server/native-release-storage.js +64 -0
  178. package/server/native-shader-cache.js +369 -0
  179. package/server/native-supply-budget.js +66 -0
  180. package/server/native-whole-earning.js +67 -0
  181. package/server/native-whole-model-fill.js +182 -0
  182. package/server/native-whole-model.js +130 -0
  183. package/server/public-web-worker.js +66 -0
  184. package/server/responses.js +92 -0
  185. package/server/search/errors.js +47 -0
  186. package/server/search/page-fetch.js +185 -0
  187. package/server/text-api.js +83 -0
  188. package/server/text-recovery.js +45 -0
  189. package/server/text-request-options.js +23 -0
  190. package/server/text-requester.js +231 -0
  191. package/server/token-auth.js +9 -0
  192. package/server/whole-model-requester.js +99 -0
  193. package/shared/config/chat-models.json +4685 -0
  194. package/shared/config/doppler-execution-contracts.js +32 -0
  195. package/shared/config/doppler-execution-contracts.json +43 -0
  196. package/shared/config/doppler-generation-contract.js +223 -0
  197. package/shared/config/doppler-local-models.js +79 -0
  198. package/shared/config/doppler-package.json +7 -0
  199. package/shared/config/model-manifests/a0dc69e917133a82780823d17fc771c0f6b882d7c86c571deeaef45be2ff108b.json +6434 -0
  200. package/shared/config/model-manifests/e985e3cdad60d0cb57d84b436161e66ab5b3f6589a168de6f00b4610e5f1fb59.json +16534 -0
  201. package/shared/config/model-manifests/fa6eb7bbc3ba63571f8cede780b877d9272bde948697af93dd3d33837cb46137.json +7175 -0
  202. package/shared/config/model-pieces/qwen-0-8b-pieces.json +1506 -0
  203. package/shared/config/model-pieces/qwen-2b-pieces.json +4800 -0
  204. package/shared/config/model-pieces/qwen35b-q4_0-pieces.json +28880 -0
  205. package/shared/config/reploid-library.json +49 -0
  206. package/shared/config/swarm-bootstrap.json +21 -0
  207. package/shared/infrastructure/doppler-runtime-service.js +261 -0
  208. package/shared/pool/executable-pack.js +87 -0
  209. package/shared/pool/pack-operation-adapters.js +124 -0
  210. package/shared/pool/pack-operation-policy.js +75 -0
  211. package/shared/pool/pool-config.json +2280 -0
  212. package/shared/vendor/reploid/adapters/doppler-session.js +372 -0
  213. package/shared/vendor/reploid/artifacts/binary.js +25 -0
  214. package/shared/vendor/reploid/artifacts/canonical-json.js +23 -0
  215. package/shared/vendor/reploid/artifacts/delivery-attestation.js +64 -0
  216. package/shared/vendor/reploid/artifacts/identity.js +91 -0
  217. package/shared/vendor/reploid/artifacts/manifest-identity.js +17 -0
  218. package/shared/vendor/reploid/artifacts/participant-proofs.js +159 -0
  219. package/shared/vendor/reploid/artifacts/signatures.js +94 -0
  220. package/shared/vendor/reploid/config/defaults.json +110 -0
  221. package/shared/vendor/reploid/config/index.js +209 -0
  222. package/shared/vendor/reploid/config/request-overrides.json +9 -0
  223. package/shared/vendor/reploid/hashing.js +30 -0
  224. package/shared/vendor/reploid/mesh/generation-context-cache.js +97 -0
  225. package/shared/vendor/reploid/mesh/partitions/partition-attempt-evidence.js +282 -0
  226. package/shared/vendor/reploid/mesh/partitions/partition-contract.js +192 -0
  227. package/shared/vendor/reploid/mesh/partitions/partition-receiver-policy.json +7 -0
  228. package/shared/vendor/reploid/mesh/partitions/partition-route-ranking.js +268 -0
  229. package/shared/vendor/reploid/mesh/preparation-progress.js +46 -0
  230. package/shared/vendor/reploid/mesh/provider-hardware.js +11 -0
  231. package/shared/vendor/reploid/rules/routing.rules.json +24 -0
  232. package/shared/vendor/reploid/transport/partition-errors.json +16 -0
  233. package/README.md +0 -3
@@ -0,0 +1,1247 @@
1
+ import { fingerprint } from './sdp-fingerprint.js';
2
+ /**
3
+ * @fileoverview WebRTC Swarm Transport
4
+ * Peer-to-peer coordination via WebRTC with secure message validation,
5
+ * session-scoped rooms, and exponential backoff reconnection.
6
+ */
7
+
8
+ import { requireResolvedConfig } from '../config/index.js';
9
+ import { bootstrapIntroductions } from './introduction-bootstrap.js';
10
+ import { createPeerIntroductions, INTRODUCTION_CONTACT, INTRODUCTION_SIGNAL } from './introductions.js';
11
+
12
+ const AUXILIARY_CHANNELS = Object.freeze(['reploid-custody', 'reploid-partitions',
13
+ 'reploid-partitions-input', 'reploid-partitions-control', 'reploid-jobs', 'reploid-partitions-ordered', 'reploid-training']);
14
+ /** Labels this transport admits; consumers that open auxiliary channels must use one of these. */
15
+ export const SWARM_AUXILIARY_CHANNELS = AUXILIARY_CHANNELS;
16
+
17
+ const PROTOCOL_VERSION = 1;
18
+
19
+ // Valid message types
20
+ const MESSAGE_TYPES = new Set([
21
+ INTRODUCTION_CONTACT, INTRODUCTION_SIGNAL,
22
+ 'sync-request', 'sync-response',
23
+ 'goal-update', 'reflection-share',
24
+ 'artifact-announce', 'artifact-request', 'artifact-chunk', 'artifact-ack',
25
+ 'reploid:peer-advertisement',
26
+ 'reploid:identity-challenge', 'reploid:identity-proof',
27
+ 'reploid:generation-request', 'reploid:generation-update',
28
+ 'reploid:generation-result', 'reploid:generation-error',
29
+ 'reploid:generation-cancel', 'reploid:generation-recover', 'reploid:generation-recovery',
30
+ 'reploid:receipt',
31
+ 'reploid:web-offer', 'reploid:web-request', 'reploid:web-response', 'reploid:web-cancel',
32
+ 'reploid:tool-offer', 'reploid:tool-offer-ack',
33
+ 'reploid:custody-request', 'reploid:custody-response', 'reploid:custody-offer',
34
+ 'ping', 'pong',
35
+ 'raft:request-vote', 'raft:request-vote-response',
36
+ 'raft:append-entries', 'raft:append-entries-response',
37
+ 'raft:client-request', 'raft:client-response',
38
+ 'fl:hello', 'fl:round-start', 'fl:update', 'fl:round-commit', 'fl:round-failed'
39
+ ]);
40
+
41
+ const WebRTCSwarm = {
42
+ metadata: {
43
+ id: 'WebRTCSwarm',
44
+ version: '1.0.0',
45
+ genesis: { introduced: 'full' },
46
+ dependencies: ['Utils', 'EventBus'],
47
+ async: true,
48
+ type: 'capability'
49
+ },
50
+
51
+ factory: (deps) => {
52
+ const { Utils, EventBus } = deps;
53
+ const { logger, generateId } = Utils;
54
+ const policy = requireResolvedConfig(deps.config);
55
+ const WebSocket = deps.WebSocket || globalThis.WebSocket;
56
+ const RTCPeerConnection = deps.RTCPeerConnection || globalThis.RTCPeerConnection;
57
+ const MAX_PAYLOAD_SIZE = policy.webrtc.maxMessageBytes;
58
+ const MAX_BACKOFF_MS = policy.webrtc.maxBackoffMs;
59
+ const HEARTBEAT_INTERVAL = policy.webrtc.heartbeatIntervalMs;
60
+ const PEER_TIMEOUT = policy.webrtc.peerTimeoutMs;
61
+ const MAX_PENDING_ICE_PER_PEER = policy.webrtc.maxPendingRemoteIceCandidates;
62
+ const PENDING_ICE_TTL_MS = policy.webrtc.pendingRemoteIceTtlMs;
63
+
64
+ // Configuration
65
+ const CONFIG = {
66
+ signalingServer: policy.webrtc.signalingUrl,
67
+ reconnectBaseMs: policy.webrtc.reconnectBaseMs,
68
+ iceServers: policy.webrtc.rtcConfig.iceServers,
69
+ channelOptions: Object.fromEntries(Object.entries(policy.webrtc.dataChannelOptions).filter(([, value]) => value !== null))
70
+ };
71
+
72
+ // State
73
+ let _peerId = null;
74
+ let _sessionId = null;
75
+ let _roomId = null;
76
+ let _roomToken = null;
77
+ let _signalingWs = null;
78
+ let _connectionState = 'stopped'; // connecting | connected | stopping | stopped | retrying
79
+ let _reconnectAttempt = 0;
80
+ let _reconnectTimer = null;
81
+ let _heartbeatTimer = null;
82
+ let _refreshingRtc = false;
83
+ let _manualStop = false;
84
+ let _generation = 0;
85
+ let introductions = null, bootstrapLifetime = null;
86
+ let adapter = null, unsubscribeAdapter = null, adapterEpoch = 0;
87
+ const negotiationTimers = new Map(), retiredPeerStates = new Map();
88
+ let attemptSequence = 0;
89
+ let _cancelConnect = null;
90
+ let _peers = new Map(); // peerId -> { connection, dataChannel, metadata, status, lastSeen }
91
+ const auxiliaryHandlers = new Map(), auxiliaryChannels = new WeakMap();
92
+ const retainAuxiliary = (peer, channel) => {
93
+ let channels = auxiliaryChannels.get(peer);
94
+ if (!channels) { channels = new Set(); auxiliaryChannels.set(peer, channels); }
95
+ if (!AUXILIARY_CHANNELS.includes(channel.label)
96
+ || [...channels].some(existing => existing.label === channel.label)) {
97
+ channel.close(); throw new Error('Unsupported or duplicate auxiliary channel');
98
+ }
99
+ channels.add(channel);
100
+ channel.addEventListener('close', () => channels.delete(channel), { once: true });
101
+ return channel;
102
+ };
103
+ const incomingChannel = (remotePeerId, peer, channel) => {
104
+ if (_manualStop || _peers.get(remotePeerId) !== peer) { channel.close(); return; }
105
+ if (channel.label === 'reploid' && !peer.dataChannel) {
106
+ peer.dataChannel = channel; setupDataChannel(channel, remotePeerId, peer); return;
107
+ }
108
+ const handler = auxiliaryHandlers.get(channel.label);
109
+ if (!handler) { channel.close(); return; }
110
+ try { handler(remotePeerId, retainAuxiliary(peer, channel)); }
111
+ catch (error) { channel.close(); logger.warn('[WebRTCSwarm] Auxiliary channel rejected:', error); }
112
+ };
113
+ let _messageHandlers = new Map(); // type -> handler function
114
+ let _logicalClock = 0;
115
+ const _pendingIceCandidates = new Map();
116
+ const _latencyByPeer = new Map();
117
+
118
+ // Bandwidth tracking
119
+ const _stats = {
120
+ messagesSent: 0,
121
+ messagesReceived: 0,
122
+ bytesSent: 0,
123
+ bytesReceived: 0,
124
+ startTime: Date.now(),
125
+ rejected: 0,
126
+ relayMessagesSent: 0,
127
+ relayBytesSent: 0,
128
+ relayLastSendAt: null,
129
+ relayLastEnqueueMs: null
130
+ };
131
+
132
+ const setConnectionState = (state, detail = {}) => {
133
+ _connectionState = state;
134
+ EventBus.emit('swarm:state-change', { state: !_manualStop && [..._peers.values()].some(peer => peer.dataChannel?.readyState === 'open') ? 'connected' : state, introductionState: state, ...detail });
135
+ };
136
+
137
+ const reportPeerProgress = (peer, phase) => {
138
+ if (_manualStop || _peers.get(peer.id) !== peer) return;
139
+ peer.phase = phase; peer.updatedAt = Date.now();
140
+ EventBus.emit('swarm:peer-progress', { peerId: peer.id, attemptId: peer.attemptId, phase });
141
+ };
142
+ const peerState = peer => ({
143
+ id: peer.id, attemptId: peer.attemptId, phase: peer.phase || peer.status,
144
+ transport: peer.transport || 'webrtc', updatedAt: peer.updatedAt || peer.lastSeen,
145
+ authenticated: peer.authenticated === true, failure: peer.failure || null,
146
+ iceState: peer.connection?.iceConnectionState || null,
147
+ channelState: peer.dataChannel?.readyState || null
148
+ });
149
+ // Retain bounded diagnostics only; these records never count as live peers.
150
+ const getPeerStates = () => [...retiredPeerStates.values(), ...[..._peers.values()].map(peerState)];
151
+ const preparePeer = async (remote, exchange) => {
152
+ const peer = { id: remote, attemptId: `${_peerId}:${_generation}:${++attemptSequence}`,
153
+ authenticated: false, exchange, connection: null, dataChannel: null,
154
+ metadata: {}, status: 'connecting', lastSeen: Date.now() };
155
+ retiredPeerStates.delete(remote);
156
+ _peers.set(remote, peer);
157
+ reportPeerProgress(peer, 'preparing');
158
+ boundNegotiation(remote, peer);
159
+ try {
160
+ const rtcConfig = deps.getRtcConfig ? await deps.getRtcConfig() : deps.rtcConfig || policy.webrtc.rtcConfig;
161
+ if (_manualStop || _peers.get(remote) !== peer || exchange && !exchange.isActive()) {
162
+ if (_peers.get(remote) === peer) removePeer(remote);
163
+ return null;
164
+ }
165
+ peer.connection = new RTCPeerConnection(rtcConfig);
166
+ peer.rtcConfigKey = JSON.stringify(rtcConfig);
167
+ reportPeerProgress(peer, 'negotiating');
168
+ peer.connection.oniceconnectionstatechange = () => {
169
+ const ice = peer.connection.iceConnectionState;
170
+ reportPeerProgress(peer, ice === 'disconnected' ? 'interrupted'
171
+ : peer.status === 'connected' ? 'connected' : 'negotiating');
172
+ };
173
+ return peer;
174
+ } catch (error) {
175
+ if (_peers.get(remote) === peer) {
176
+ recoverPeer(remote, peer, 'failed', 'Could not prepare the peer connection.');
177
+ EventBus.emit('swarm:connection-failed', { peerId: remote, attemptId: peer.attemptId, message: peer.failure });
178
+ }
179
+ throw error;
180
+ }
181
+ };
182
+
183
+ /**
184
+ * Increment logical clock (Lamport timestamp)
185
+ */
186
+ const tick = () => ++_logicalClock;
187
+
188
+ /**
189
+ * Update logical clock on receive
190
+ */
191
+ const updateClock = (remoteClock) => {
192
+ _logicalClock = Math.max(_logicalClock, remoteClock) + 1;
193
+ };
194
+
195
+ /**
196
+ * Validate message envelope
197
+ */
198
+ const validateEnvelope = (envelope) => {
199
+ if (!envelope || typeof envelope !== 'object') {
200
+ return { valid: false, reason: 'Invalid envelope format' };
201
+ }
202
+ if (envelope.protocolVersion !== PROTOCOL_VERSION) {
203
+ return { valid: false, reason: `Protocol version mismatch: ${envelope.protocolVersion}` };
204
+ }
205
+ if (!MESSAGE_TYPES.has(envelope.type)) {
206
+ return { valid: false, reason: `Unknown message type: ${envelope.type}` };
207
+ }
208
+ if (!envelope.peerId || typeof envelope.peerId !== 'string') {
209
+ return { valid: false, reason: 'Missing or invalid peerId' };
210
+ }
211
+ if (typeof envelope.timestamp !== 'number') {
212
+ return { valid: false, reason: 'Missing or invalid timestamp' };
213
+ }
214
+ if (envelope.payloadSize > MAX_PAYLOAD_SIZE) {
215
+ return { valid: false, reason: `Payload too large: ${envelope.payloadSize}` };
216
+ }
217
+ return { valid: true };
218
+ };
219
+
220
+ /**
221
+ * Wrap message in protocol envelope
222
+ */
223
+ const wrapMessage = (type, payload) => {
224
+ const payloadStr = JSON.stringify(payload || {});
225
+ if (payloadStr.length > MAX_PAYLOAD_SIZE) {
226
+ throw new Error(`Payload exceeds max size: ${payloadStr.length} > ${MAX_PAYLOAD_SIZE}`);
227
+ }
228
+ return {
229
+ protocolVersion: PROTOCOL_VERSION,
230
+ type,
231
+ peerId: _peerId,
232
+ timestamp: tick(),
233
+ payload: payload || {},
234
+ payloadSize: payloadStr.length
235
+ };
236
+ };
237
+
238
+ /**
239
+ * Check if swarm is enabled via URL param or localStorage
240
+ */
241
+ const isEnabled = () => policy.mesh.enabled;
242
+ const getRoomCredentials = () => {
243
+ if (typeof deps.getRoomCredentials !== 'function') throw new Error('Room credentials port is required');
244
+ const credentials = deps.getRoomCredentials();
245
+ if (credentials?.roomId !== policy.mesh.roomId || typeof credentials?.token !== 'string' || credentials.token.length < 32) {
246
+ throw new Error('Room credentials do not match configured room');
247
+ }
248
+ return credentials;
249
+ };
250
+
251
+ /**
252
+ * Initialize the swarm transport
253
+ */
254
+ const init = async () => {
255
+ // Check if enabled via URL param or feature flag
256
+ if (!isEnabled()) {
257
+ logger.info('[WebRTCSwarm] Disabled by resolved configuration');
258
+ return false;
259
+ }
260
+
261
+ _manualStop = false;
262
+ const generation = ++_generation;
263
+
264
+ // Generate or restore IDs
265
+ _peerId = deps.peerId || generateId('peer');
266
+ _sessionId = deps.getSessionId ? deps.getSessionId() : policy.webrtc.sessionId;
267
+ if (!_sessionId) throw new Error('Explicit swarm session identity is required');
268
+
269
+ _roomId = policy.mesh.roomId;
270
+ if (deps.introductions) {
271
+ const created = await createPeerIntroductions({ ...deps.introductions, transport: introductionTransport,
272
+ identity: await deps.introductions.getIdentity(), roomId: _roomId,
273
+ onError: error => EventBus.emit('swarm:introduction-error', { message: error.message }) });
274
+ if (_manualStop || generation !== _generation) { created.close(); return false; }
275
+ introductions = created;
276
+ startHeartbeat();
277
+ if (deps.introductionBootstrap) {
278
+ bootstrapLifetime = new AbortController();
279
+ try {
280
+ await bootstrapIntroductions({ ...deps.introductionBootstrap, introductions, signal: bootstrapLifetime.signal,
281
+ policy: deps.introductions.policy,
282
+ isConnected: id => _peers.get(id)?.dataChannel?.readyState === 'open',
283
+ onState: state => EventBus.emit('swarm:bootstrap-state', state) });
284
+ if (_manualStop || generation !== _generation) return false;
285
+ setConnectionState('connected'); return true;
286
+ } catch (error) {
287
+ if (_manualStop || generation !== _generation) return false;
288
+ EventBus.emit('swarm:bootstrap-error', { message: error.message, failures: error.failures || [] });
289
+ if (!CONFIG.signalingServer && !deps.signalingAdapter) { setConnectionState('awaiting-introduction'); return true; }
290
+ }
291
+ }
292
+ }
293
+ if (deps.signalingAdapter) return connectAdapter();
294
+ if (!CONFIG.signalingServer && introductions) {
295
+ setConnectionState('awaiting-introduction');
296
+ return true;
297
+ }
298
+ // Legacy WebSocket admission retains its own room capability.
299
+ const roomCredentials = getRoomCredentials();
300
+ _roomToken = roomCredentials.token;
301
+ logger.info(`[WebRTCSwarm] Initializing - peerId: ${_peerId}, room: ${_roomId}`);
302
+ return connectToSignaling();
303
+ };
304
+
305
+ const connectAdapter = () => new Promise((resolve) => {
306
+ if (_manualStop) return resolve(false);
307
+ _cancelConnect?.(); unsubscribeAdapter?.();
308
+ const generation = _generation, epoch = ++adapterEpoch;
309
+ const current = deps.signalingAdapter;
310
+ adapter = current;
311
+ let settled = false, timer;
312
+ const settle = value => {
313
+ if (settled) return; settled = true; clearTimeout(timer);
314
+ if (_cancelConnect === cancel) _cancelConnect = null;
315
+ resolve(value);
316
+ };
317
+ const cancel = () => settle(false);
318
+ _cancelConnect = cancel;
319
+ const failed = error => {
320
+ if (_manualStop || generation !== _generation || epoch !== adapterEpoch || adapter !== current) return;
321
+ unsubscribeAdapter?.(); unsubscribeAdapter = null; adapter = null;
322
+ discardSignalingRoutes(); settle(false);
323
+ EventBus.emit('swarm:signaling-unavailable', { message: error?.message || 'Introduction route unavailable' });
324
+ scheduleReconnect(error?.retryAfterMs);
325
+ };
326
+ setConnectionState('connecting');
327
+ timer = setTimeout(() => failed(new Error('Introduction adapter acknowledgement timed out')), policy.webrtc.connectTimeoutMs);
328
+ try {
329
+ unsubscribeAdapter = current.subscribe(async message => {
330
+ if (_manualStop || generation !== _generation || epoch !== adapterEpoch || adapter !== current) return;
331
+ try {
332
+ if (message.type === 'unavailable') return failed(new Error(message.reason));
333
+ if (message.type === 'joined') {
334
+ if (message.peerId !== _peerId || message.roomId !== _roomId || !Array.isArray(message.peers)
335
+ || message.peers.some(id => typeof id !== 'string')) throw new Error('Invalid adapter admission');
336
+ if (settled) return;
337
+ setConnectionState('connected'); _reconnectAttempt = 0; startHeartbeat(); settle(true);
338
+ } else if (!settled) throw new Error('Adapter signaling before admission');
339
+ await handleSignalingMessage(message);
340
+ } catch (error) { failed(error); }
341
+ });
342
+ Promise.resolve(current.publish({ type: 'join', peerId: _peerId, roomId: _roomId })).catch(failed);
343
+ } catch (error) { failed(error); }
344
+ });
345
+
346
+ /**
347
+ * Connect to signaling server with retry
348
+ */
349
+ const connectToSignaling = () => {
350
+ return new Promise((resolve, reject) => {
351
+ if (!CONFIG.signalingServer) {
352
+ reject(new Error('No signaling server configured'));
353
+ return;
354
+ }
355
+
356
+ if (_manualStop) return resolve(false);
357
+ _cancelConnect?.();
358
+ if (_signalingWs) { const old = _signalingWs; _signalingWs = null; old.close(); }
359
+
360
+ setConnectionState('connecting');
361
+ if (_manualStop) return resolve(false);
362
+ logger.info(`[WebRTCSwarm] Connecting to ${CONFIG.signalingServer}`);
363
+
364
+ try {
365
+ const signalingWs = new WebSocket(CONFIG.signalingServer);
366
+ _signalingWs = signalingWs;
367
+
368
+ let settled = false;
369
+ let joinTimer;
370
+ const settle = (value) => {
371
+ if (settled) return;
372
+ settled = true;
373
+ clearTimeout(joinTimer);
374
+ if (_cancelConnect === cancel) _cancelConnect = null;
375
+ resolve(value);
376
+ };
377
+ const cancel = () => settle(false);
378
+ _cancelConnect = cancel;
379
+ const fail = (error) => {
380
+ if (_signalingWs !== signalingWs) return settle(false);
381
+ logger.warn('[WebRTCSwarm] Signaling failed:', error);
382
+ _signalingWs = null;
383
+ settle(false);
384
+ discardSignalingRoutes();
385
+ signalingWs.close();
386
+ if (!_manualStop) scheduleReconnect(error?.retryAfterMs);
387
+ };
388
+ joinTimer = setTimeout(() => fail(new Error('Signaling join acknowledgement timed out')), policy.webrtc.connectTimeoutMs);
389
+
390
+ signalingWs.onopen = () => {
391
+ if (_signalingWs !== signalingWs) {
392
+ signalingWs.close();
393
+ settle(false);
394
+ return;
395
+ }
396
+ if (_manualStop) {
397
+ signalingWs.close();
398
+ settle(false);
399
+ return;
400
+ }
401
+ // An open socket is not proof of namespace admission.
402
+ sendSignaling({
403
+ type: 'join',
404
+ peerId: _peerId,
405
+ roomId: _roomId,
406
+ token: _roomToken,
407
+ metadata: { capabilities: [] }
408
+ });
409
+
410
+ };
411
+
412
+ signalingWs.onmessage = async (event) => {
413
+ if (_signalingWs !== signalingWs || _manualStop) return;
414
+ try {
415
+ const message = JSON.parse(event.data);
416
+ if (message.type === 'error') throw new Error(message.error || 'Signaling rejected the request');
417
+ if (message.type === 'joined') {
418
+ if (message.peerId !== _peerId || message.roomId !== _roomId || !Array.isArray(message.peers)
419
+ || message.peers.some(id => typeof id !== 'string')) throw new Error('Invalid join acknowledgement');
420
+ if (settled) return;
421
+ setConnectionState('connected');
422
+ if (_manualStop || _signalingWs !== signalingWs) return settle(false);
423
+ _reconnectAttempt = 0;
424
+ clearTimeout(_reconnectTimer); _reconnectTimer = null;
425
+ startHeartbeat();
426
+ settle(true);
427
+ } else if (!settled && message.type !== 'welcome') throw new Error('Signaling before join acknowledgement');
428
+ await handleSignalingMessage(message);
429
+ } catch (e) {
430
+ fail(e);
431
+ }
432
+ };
433
+
434
+ signalingWs.onerror = (error) => {
435
+ if (_signalingWs !== signalingWs) {
436
+ settle(false);
437
+ return;
438
+ }
439
+ fail(error);
440
+ };
441
+
442
+ signalingWs.onclose = () => {
443
+ if (_signalingWs !== signalingWs) {
444
+ settle(false);
445
+ return;
446
+ }
447
+ logger.warn('[WebRTCSwarm] Disconnected from signaling server');
448
+ _signalingWs = null;
449
+ discardSignalingRoutes();
450
+
451
+ if (_manualStop) {
452
+ setConnectionState('stopped');
453
+ settle(false);
454
+ return;
455
+ }
456
+
457
+ settle(false);
458
+ scheduleReconnect();
459
+ };
460
+ } catch (e) {
461
+ logger.error('[WebRTCSwarm] Failed to create WebSocket:', e);
462
+ setConnectionState('stopped');
463
+ if (!_manualStop) scheduleReconnect();
464
+ reject(e);
465
+ }
466
+ });
467
+ };
468
+
469
+ /**
470
+ * Schedule reconnection with exponential backoff
471
+ */
472
+ const scheduleReconnect = (retryAfterMs = 0) => {
473
+ if (_manualStop || _reconnectTimer) return;
474
+
475
+ _reconnectAttempt++;
476
+ const exponentialBackoff = Math.min(
477
+ CONFIG.reconnectBaseMs * Math.pow(2, _reconnectAttempt - 1),
478
+ MAX_BACKOFF_MS
479
+ );
480
+ // Trusted host failures can carry an issuer's retry deadline. A successful
481
+ // discovery join must not turn a rate-limited credential port into a loop.
482
+ const backoff = Math.max(exponentialBackoff, Number.isFinite(retryAfterMs)
483
+ ? Math.min(Math.max(retryAfterMs, 0), 2147483647) : 0);
484
+
485
+ logger.info(`[WebRTCSwarm] Reconnecting in ${backoff}ms (attempt ${_reconnectAttempt})`);
486
+ setConnectionState('retrying', { attempt: _reconnectAttempt });
487
+
488
+ _reconnectTimer = setTimeout(() => {
489
+ _reconnectTimer = null;
490
+ if (_manualStop) return;
491
+ (deps.signalingAdapter ? connectAdapter() : connectToSignaling()).catch(e => {
492
+ logger.error('[WebRTCSwarm] Reconnect failed:', e);
493
+ });
494
+ }, backoff);
495
+ };
496
+
497
+ /**
498
+ * Send message to signaling server
499
+ */
500
+ const sendSignaling = (message) => {
501
+ if (adapter) {
502
+ try {
503
+ const result = adapter.publish(structuredClone(message));
504
+ Promise.resolve(result).catch(error => EventBus.emit('swarm:signaling-error', { message: error.message }));
505
+ return result !== false;
506
+ } catch { return false; }
507
+ }
508
+ if (!_signalingWs || _signalingWs.readyState !== WebSocket.OPEN) {
509
+ logger.warn('[WebRTCSwarm] Cannot send signaling: not connected');
510
+ return false;
511
+ }
512
+ try {
513
+ _signalingWs.send(JSON.stringify(message));
514
+ return true;
515
+ } catch (e) {
516
+ logger.error('[WebRTCSwarm] Failed to send signaling:', e);
517
+ return false;
518
+ }
519
+ };
520
+
521
+ /**
522
+ * Handle incoming signaling messages
523
+ */
524
+ const handleSignalingMessage = async (message) => {
525
+ const { type } = message;
526
+ if (['offer', 'answer', 'ice-candidate'].includes(type) && _peers.get(message.peerId)?.exchange) return;
527
+
528
+ switch (type) {
529
+ case 'welcome':
530
+ logger.debug('[WebRTCSwarm] Received welcome from signaling server');
531
+ break;
532
+
533
+ case 'joined':
534
+ logger.info(`[WebRTCSwarm] Joined room ${message.roomId}, existing peers: ${message.peers?.length || 0}`);
535
+ // Connect to existing peers
536
+ for (const remotePeerId of (message.peers || [])) {
537
+ if (_manualStop) return;
538
+ await connectToPeer(remotePeerId);
539
+ }
540
+ break;
541
+
542
+ case 'peer-joined':
543
+ logger.info(`[WebRTCSwarm] Peer joined: ${message.peerId}`);
544
+ if (message.metadata?.transport === 'signaling-relay') {
545
+ registerRelayPeer(message.peerId, message.metadata);
546
+ }
547
+ EventBus.emit('swarm:peer-joined', { peerId: message.peerId });
548
+ break;
549
+
550
+ case 'peer-left':
551
+ logger.info(`[WebRTCSwarm] Peer left: ${message.peerId}`);
552
+ // Directory presence does not determine direct channel liveness.
553
+ if (_peers.get(message.peerId)?.transport === 'signaling-relay') {
554
+ removePeer(message.peerId);
555
+ EventBus.emit('swarm:peer-left', { peerId: message.peerId });
556
+ }
557
+ break;
558
+
559
+ case 'offer':
560
+ await handleOffer(message.peerId, message.offer);
561
+ break;
562
+
563
+ case 'answer':
564
+ await handleAnswer(message.peerId, message.answer);
565
+ break;
566
+
567
+ case 'ice-candidate':
568
+ await handleIceCandidate(message.peerId, message.candidate);
569
+ break;
570
+
571
+ case 'relay-message':
572
+ if (_peers.get(message.peerId)?.dataChannel?.readyState === 'open') return;
573
+ if (!_peers.has(message.peerId)) {
574
+ registerRelayPeer(message.peerId, { transport: 'signaling-relay' });
575
+ }
576
+ handlePeerEnvelope(message.peerId, message.envelope);
577
+ break;
578
+
579
+ case 'error':
580
+ logger.error('[WebRTCSwarm] Signaling error:', message.error);
581
+ break;
582
+
583
+ default:
584
+ logger.debug(`[WebRTCSwarm] Unknown signaling message: ${type}`);
585
+ }
586
+ };
587
+
588
+ const boundNegotiation = (remote, peer) => {
589
+ clearTimeout(negotiationTimers.get(remote));
590
+ negotiationTimers.set(remote, setTimeout(() => {
591
+ negotiationTimers.delete(remote);
592
+ if (_peers.get(remote) === peer && peer.dataChannel?.readyState !== 'open') {
593
+ recoverPeer(remote, peer, 'failed', 'Peer connection timed out; check the invitation and network.');
594
+ EventBus.emit('swarm:connection-failed', { peerId: remote, attemptId: peer.attemptId, message: peer.failure });
595
+ }
596
+ }, policy.webrtc.connectTimeoutMs));
597
+ };
598
+ const sendPeerSignal = (peer, message) => {
599
+ if (peer.exchange) return peer.exchange.publish(message);
600
+ return sendSignaling(message);
601
+ };
602
+
603
+ /**
604
+ * Connect to a remote peer
605
+ */
606
+ const connectToPeer = async (remotePeerId, exchange = null) => {
607
+ if (_manualStop) return;
608
+ // Skip if already connected or connecting
609
+ const existingPeer = _peers.get(remotePeerId);
610
+ if (existingPeer) {
611
+ if (existingPeer.exchange && !exchange) return;
612
+ if (existingPeer.status === 'connected' || existingPeer.status === 'connecting'
613
+ && existingPeer.exchange?.attemptId === exchange?.attemptId) {
614
+ logger.debug(`[WebRTCSwarm] Already connected to ${remotePeerId}, skipping`);
615
+ return;
616
+ }
617
+ // Close stale connection before reconnecting
618
+ logger.info(`[WebRTCSwarm] Closing stale connection to ${remotePeerId}`);
619
+ removePeer(remotePeerId, Boolean(exchange));
620
+ }
621
+
622
+ logger.info(`[WebRTCSwarm] Connecting to peer: ${remotePeerId}`);
623
+
624
+ const peer = await preparePeer(remotePeerId, exchange);
625
+ if (!peer) return;
626
+ const connection = peer.connection;
627
+ let dataChannel;
628
+ try { dataChannel = connection.createDataChannel('reploid', CONFIG.channelOptions); }
629
+ catch (error) { removePeer(remotePeerId); throw error; }
630
+ peer.dataChannel = dataChannel;
631
+
632
+ connection.ondatachannel = event => incomingChannel(remotePeerId, peer, event.channel);
633
+
634
+ // ICE candidate handler
635
+ connection.onicecandidate = (event) => {
636
+ if (event.candidate && !_manualStop && _peers.get(remotePeerId) === peer) {
637
+ Promise.resolve(sendPeerSignal(peer, {
638
+ type: 'ice-candidate',
639
+ peerId: _peerId,
640
+ targetPeer: remotePeerId,
641
+ candidate: event.candidate.toJSON?.() || event.candidate
642
+ })).catch(error => logger.warn('[WebRTCSwarm] Candidate publication failed:', error));
643
+ }
644
+ };
645
+
646
+ // Connection state monitoring
647
+ connection.onconnectionstatechange = () => {
648
+ if (_peers.get(remotePeerId) !== peer) return;
649
+ reportPeerProgress(peer, connection.connectionState === 'disconnected' ? 'interrupted' : peer.status);
650
+ logger.debug(`[WebRTCSwarm] Connection state with ${remotePeerId}: ${connection.connectionState}`);
651
+ // ICE disconnected can recover. The existing heartbeat deadline bounds
652
+ // liveness; do not destroy open custody/partition channels prematurely.
653
+ if (connection.connectionState === 'failed' || connection.connectionState === 'closed') {
654
+ recoverPeer(remotePeerId, peer, connection.connectionState === 'failed' ? 'failed' : 'disconnected');
655
+ }
656
+ };
657
+
658
+ // Data channel handlers
659
+ setupDataChannel(dataChannel, remotePeerId, peer);
660
+
661
+ // Create and send offer
662
+ const offer = await connection.createOffer();
663
+ if (_manualStop || _peers.get(remotePeerId) !== peer) return;
664
+ await connection.setLocalDescription(offer);
665
+ if (_manualStop || _peers.get(remotePeerId) !== peer) return;
666
+
667
+ reportPeerProgress(peer, 'awaiting-answer');
668
+ await sendPeerSignal(peer, {
669
+ type: 'offer',
670
+ peerId: _peerId,
671
+ targetPeer: remotePeerId,
672
+ offer
673
+ });
674
+ };
675
+
676
+ /**
677
+ * Handle incoming WebRTC offer
678
+ */
679
+ const handleOffer = async (remotePeerId, offer, exchange = null) => {
680
+ if (_manualStop) return;
681
+ if (_peers.get(remotePeerId)?.dataChannel?.readyState === 'open') return;
682
+ if (_peers.has(remotePeerId)) removePeer(remotePeerId, Boolean(exchange));
683
+ logger.info(`[WebRTCSwarm] Received offer from: ${remotePeerId}`);
684
+
685
+ const peer = await preparePeer(remotePeerId, exchange);
686
+ if (!peer) return;
687
+ const connection = peer.connection;
688
+
689
+ // ICE candidate handler
690
+ connection.onicecandidate = (event) => {
691
+ if (event.candidate && !_manualStop && _peers.get(remotePeerId) === peer) {
692
+ Promise.resolve(sendPeerSignal(peer, {
693
+ type: 'ice-candidate',
694
+ peerId: _peerId,
695
+ targetPeer: remotePeerId,
696
+ candidate: event.candidate.toJSON?.() || event.candidate
697
+ })).catch(error => logger.warn('[WebRTCSwarm] Candidate publication failed:', error));
698
+ }
699
+ };
700
+
701
+ // Wait for incoming data channel
702
+ connection.ondatachannel = (event) => {
703
+ incomingChannel(remotePeerId, peer, event.channel);
704
+ };
705
+ connection.onconnectionstatechange = () => {
706
+ if (_peers.get(remotePeerId) !== peer) return;
707
+ reportPeerProgress(peer, connection.connectionState === 'disconnected' ? 'interrupted' : peer.status);
708
+ // Preserve transient ICE recovery under the same heartbeat deadline as
709
+ // outgoing connections. Terminal states still retire the peer now.
710
+ if (connection.connectionState === 'failed' || connection.connectionState === 'closed') {
711
+ recoverPeer(remotePeerId, peer, connection.connectionState === 'failed' ? 'failed' : 'disconnected');
712
+ }
713
+ };
714
+
715
+ // Set remote description and create answer
716
+ await connection.setRemoteDescription(offer);
717
+ if (_manualStop || _peers.get(remotePeerId) !== peer) return;
718
+ await flushPendingIceCandidates(remotePeerId, connection);
719
+ const answer = await connection.createAnswer();
720
+ if (_manualStop || _peers.get(remotePeerId) !== peer) return;
721
+ await connection.setLocalDescription(answer);
722
+ if (_manualStop || _peers.get(remotePeerId) !== peer) return;
723
+
724
+ await sendPeerSignal(peer, {
725
+ type: 'answer',
726
+ peerId: _peerId,
727
+ targetPeer: remotePeerId,
728
+ answer
729
+ });
730
+ };
731
+
732
+ /**
733
+ * Handle incoming WebRTC answer
734
+ */
735
+ const handleAnswer = async (remotePeerId, answer) => {
736
+ const peer = _peers.get(remotePeerId);
737
+ if (peer) {
738
+ await peer.connection.setRemoteDescription(answer);
739
+ await flushPendingIceCandidates(remotePeerId, peer.connection);
740
+ reportPeerProgress(peer, peer.status === 'connected' ? 'connected' : 'opening-channel');
741
+ }
742
+ };
743
+
744
+ const queuePendingIceCandidate = (remotePeerId, candidate) => {
745
+ const now = Date.now();
746
+ const pending = (_pendingIceCandidates.get(remotePeerId) || [])
747
+ .filter((entry) => now - entry.receivedAt <= PENDING_ICE_TTL_MS);
748
+ pending.push({ candidate, receivedAt: now });
749
+ _pendingIceCandidates.set(remotePeerId, pending.slice(-MAX_PENDING_ICE_PER_PEER));
750
+ };
751
+
752
+ const flushPendingIceCandidates = async (remotePeerId, connection) => {
753
+ const pending = _pendingIceCandidates.get(remotePeerId) || [];
754
+ _pendingIceCandidates.delete(remotePeerId);
755
+ for (const entry of pending) {
756
+ if (Date.now() - entry.receivedAt > PENDING_ICE_TTL_MS) continue;
757
+ await connection.addIceCandidate(entry.candidate);
758
+ }
759
+ };
760
+
761
+ /**
762
+ * Handle incoming ICE candidate
763
+ */
764
+ const handleIceCandidate = async (remotePeerId, candidate) => {
765
+ const peer = _peers.get(remotePeerId);
766
+ if (!candidate) return;
767
+ if (!peer?.connection || !peer.connection.remoteDescription) {
768
+ queuePendingIceCandidate(remotePeerId, candidate);
769
+ return;
770
+ }
771
+ try {
772
+ await peer.connection.addIceCandidate(candidate);
773
+ } catch (e) {
774
+ logger.error(`[WebRTCSwarm] Failed to add ICE candidate for ${remotePeerId}:`, e);
775
+ }
776
+ };
777
+
778
+ /**
779
+ * Setup data channel handlers
780
+ */
781
+ const setupDataChannel = (dataChannel, remotePeerId, peer) => {
782
+ dataChannel.onopen = () => {
783
+ if (_manualStop || _peers.get(remotePeerId) !== peer) { dataChannel.close(); return; }
784
+ logger.info(`[WebRTCSwarm] Data channel opened with ${remotePeerId}`);
785
+ peer.status = 'connected';
786
+ reportPeerProgress(peer, 'connected');
787
+ clearTimeout(negotiationTimers.get(remotePeerId)); negotiationTimers.delete(remotePeerId);
788
+ introductions?.settled(remotePeerId);
789
+ startHeartbeat();
790
+ if (!_signalingWs && !adapter) setConnectionState('connected');
791
+ introductions?.announce(remotePeerId).catch(error => logger.warn('[WebRTCSwarm] Contact announcement failed:', error));
792
+ EventBus.emit('swarm:peer-connected', { peerId: remotePeerId });
793
+
794
+ // Request initial sync
795
+ sendToPeer(remotePeerId, 'sync-request', {});
796
+ };
797
+
798
+ dataChannel.onmessage = (event) => {
799
+ if (_manualStop || _peers.get(remotePeerId) !== peer) return;
800
+ handlePeerMessage(remotePeerId, event.data);
801
+ };
802
+
803
+ dataChannel.onerror = (error) => {
804
+ logger.error(`[WebRTCSwarm] Data channel error with ${remotePeerId}:`, error);
805
+ };
806
+
807
+ dataChannel.onclose = () => {
808
+ if (_peers.get(remotePeerId) !== peer) return;
809
+ logger.info(`[WebRTCSwarm] Data channel closed with ${remotePeerId}`);
810
+ recoverPeer(remotePeerId, peer);
811
+ };
812
+ };
813
+
814
+ /**
815
+ * Register a relay-backed peer that communicates through the signaling socket.
816
+ */
817
+ const registerRelayPeer = (remotePeerId, metadata = {}) => {
818
+ const existingPeer = _peers.get(remotePeerId);
819
+ if (existingPeer?.dataChannel?.readyState === 'open') return existingPeer;
820
+ if (existingPeer && existingPeer.transport === 'signaling-relay') {
821
+ existingPeer.metadata = { ...existingPeer.metadata, ...metadata };
822
+ existingPeer.lastSeen = Date.now();
823
+ existingPeer.status = 'connected';
824
+ return existingPeer;
825
+ }
826
+
827
+ const peer = {
828
+ id: remotePeerId, attemptId: `${_peerId}:${_generation}:${++attemptSequence}`,
829
+ connection: null,
830
+ dataChannel: null,
831
+ metadata: { ...metadata },
832
+ status: 'connected',
833
+ lastSeen: Date.now(),
834
+ transport: 'signaling-relay'
835
+ };
836
+
837
+ retiredPeerStates.delete(remotePeerId);
838
+ _peers.set(remotePeerId, peer);
839
+ return peer;
840
+ };
841
+
842
+ /**
843
+ * Remove peer and cleanup
844
+ */
845
+ const removePeer = (remotePeerId, preserveIntroduction = false) => {
846
+ clearTimeout(negotiationTimers.get(remotePeerId)); negotiationTimers.delete(remotePeerId);
847
+ const peer = _peers.get(remotePeerId);
848
+ if (peer) {
849
+ _peers.delete(remotePeerId);
850
+ retiredPeerStates.delete(remotePeerId);
851
+ retiredPeerStates.set(remotePeerId, { ...peerState(peer), phase: peer.phase === 'failed' ? 'failed' : 'disconnected',
852
+ channelState: 'closed', updatedAt: Date.now() });
853
+ while (retiredPeerStates.size > 64) retiredPeerStates.delete(retiredPeerStates.keys().next().value);
854
+ if (!preserveIntroduction) introductions?.retirePeer(remotePeerId, peer.exchange?.attemptId);
855
+ try {
856
+ if (peer.dataChannel) peer.dataChannel.close();
857
+ if (peer.connection) peer.connection.close();
858
+ } catch (e) {
859
+ // Ignore cleanup errors
860
+ }
861
+ EventBus.emit('swarm:peer-disconnected', { peerId: remotePeerId, attemptId: peer.attemptId });
862
+ }
863
+ _pendingIceCandidates.delete(remotePeerId);
864
+ _latencyByPeer.delete(remotePeerId);
865
+ };
866
+
867
+ // A healthy rendezvous does not emit another peer-joined event after an ICE
868
+ // connection fails. Rejoin through its existing bounded backoff to obtain a
869
+ // fresh admitted peer list; connectToPeer preserves still-open channels.
870
+ // This restores transport only. Retired requests are never replayed here.
871
+ const recoverPeer = (remotePeerId, peer, phase = 'disconnected', failure = null) => {
872
+ if (_peers.get(remotePeerId) !== peer) return;
873
+ peer.phase = phase; peer.failure = failure;
874
+ removePeer(remotePeerId);
875
+ if (!peer.exchange && peer.transport !== 'signaling-relay') scheduleReconnect();
876
+ };
877
+
878
+ /**
879
+ * Remove all peers
880
+ */
881
+ const discardSignalingRoutes = () => {
882
+ for (const [peerId, peer] of _peers) {
883
+ if (peer.transport === 'signaling-relay' || !peer.exchange && peer.dataChannel?.readyState !== 'open') removePeer(peerId);
884
+ }
885
+ _pendingIceCandidates.clear();
886
+ };
887
+
888
+ /**
889
+ * Handle incoming peer message
890
+ */
891
+ const handlePeerMessage = (remotePeerId, data) => {
892
+ let envelope;
893
+ try {
894
+ // Size check before parse
895
+ if (data.length > MAX_PAYLOAD_SIZE * 2) {
896
+ logger.warn(`[WebRTCSwarm] Message too large from ${remotePeerId}: ${data.length}`);
897
+ _stats.rejected++;
898
+ return;
899
+ }
900
+
901
+ envelope = JSON.parse(data);
902
+ } catch (e) {
903
+ logger.warn(`[WebRTCSwarm] Failed to parse message from ${remotePeerId}`);
904
+ _stats.rejected++;
905
+ return;
906
+ }
907
+
908
+ handlePeerEnvelope(remotePeerId, envelope, data.length);
909
+ };
910
+
911
+ /**
912
+ * Handle a parsed envelope from either WebRTC or signaling relay transport.
913
+ */
914
+ const handlePeerEnvelope = (remotePeerId, envelope, sizeHint = null) => {
915
+ const serializedLength = Number.isFinite(sizeHint) ? sizeHint : JSON.stringify(envelope || {}).length;
916
+
917
+ // Validate envelope
918
+ const validation = validateEnvelope(envelope);
919
+ if (!validation.valid || envelope.peerId !== remotePeerId) {
920
+ logger.warn(`[WebRTCSwarm] Invalid message from ${remotePeerId}: ${validation.reason}`);
921
+ _stats.rejected++;
922
+ return;
923
+ }
924
+
925
+ // Update logical clock
926
+ updateClock(envelope.timestamp);
927
+
928
+ // Update peer last seen
929
+ const peer = _peers.get(remotePeerId);
930
+ if (peer) {
931
+ peer.lastSeen = Date.now();
932
+ }
933
+
934
+ // Track stats
935
+ _stats.messagesReceived++;
936
+ _stats.bytesReceived += serializedLength;
937
+
938
+ // Route to handler
939
+ const handler = _messageHandlers.get(envelope.type);
940
+ if (handler) {
941
+ try {
942
+ handler(remotePeerId, envelope.payload, envelope);
943
+ } catch (e) {
944
+ logger.error(`[WebRTCSwarm] Handler error for ${envelope.type}:`, e);
945
+ }
946
+ }
947
+
948
+ // Emit event for external listeners
949
+ EventBus.emit(`swarm:message:${envelope.type}`, {
950
+ peerId: remotePeerId,
951
+ payload: envelope.payload,
952
+ timestamp: envelope.timestamp
953
+ });
954
+ };
955
+
956
+ /**
957
+ * Send message to specific peer
958
+ */
959
+ const sendToPeer = (remotePeerId, type, payload) => {
960
+ const peer = _peers.get(remotePeerId);
961
+ if (!peer) {
962
+ logger.warn(`[WebRTCSwarm] Cannot send to ${remotePeerId}: not connected`);
963
+ return false;
964
+ }
965
+
966
+ try {
967
+ const envelope = wrapMessage(type, payload);
968
+ const data = JSON.stringify(envelope);
969
+
970
+ if (peer.transport === 'signaling-relay') {
971
+ const startedAt = globalThis.performance?.now?.() ?? Date.now();
972
+ const sent = sendSignaling({
973
+ type: 'relay-message',
974
+ peerId: _peerId,
975
+ roomId: _roomId,
976
+ targetPeer: remotePeerId,
977
+ envelope
978
+ });
979
+ if (sent) {
980
+ _stats.messagesSent++;
981
+ _stats.bytesSent += data.length;
982
+ _stats.relayMessagesSent++;
983
+ _stats.relayBytesSent += data.length;
984
+ _stats.relayLastSendAt = Date.now();
985
+ _stats.relayLastEnqueueMs = (globalThis.performance?.now?.() ?? Date.now()) - startedAt;
986
+ }
987
+ return sent;
988
+ }
989
+
990
+ if (!peer.dataChannel || peer.dataChannel.readyState !== 'open') {
991
+ logger.warn(`[WebRTCSwarm] Cannot send to ${remotePeerId}: not connected`);
992
+ return false;
993
+ }
994
+
995
+ if ((peer.dataChannel.bufferedAmount || 0) + new TextEncoder().encode(data).length > policy.webrtc.maxBufferedBytes) return false;
996
+ peer.dataChannel.send(data);
997
+
998
+ _stats.messagesSent++;
999
+ _stats.bytesSent += data.length;
1000
+
1001
+ return true;
1002
+ } catch (e) {
1003
+ logger.error(`[WebRTCSwarm] Failed to send to ${remotePeerId}:`, e);
1004
+ return false;
1005
+ }
1006
+ };
1007
+
1008
+ /**
1009
+ * Broadcast message to all connected peers
1010
+ */
1011
+ const broadcast = (type, payload) => {
1012
+ let sent = 0;
1013
+ for (const [peerId] of _peers) {
1014
+ if (sendToPeer(peerId, type, payload)) {
1015
+ sent++;
1016
+ }
1017
+ }
1018
+ return sent;
1019
+ };
1020
+
1021
+ /**
1022
+ * Register message handler
1023
+ */
1024
+ const onMessage = (type, handler) => {
1025
+ if (!MESSAGE_TYPES.has(type)) {
1026
+ logger.warn(`[WebRTCSwarm] Registering handler for unknown type: ${type}`);
1027
+ }
1028
+ _messageHandlers.set(type, handler);
1029
+ };
1030
+
1031
+ /**
1032
+ * Start heartbeat timer
1033
+ */
1034
+ const refreshRtcConfiguration = async () => {
1035
+ if (_refreshingRtc || !deps.getRtcConfig || !_peers.size || _manualStop) return;
1036
+ _refreshingRtc = true;
1037
+ const generation = _generation;
1038
+ try {
1039
+ const rtcConfig = await deps.getRtcConfig();
1040
+ if (_manualStop || _generation !== generation) return;
1041
+ const key = JSON.stringify(rtcConfig);
1042
+ for (const peer of _peers.values()) {
1043
+ if (!peer.connection || peer.rtcConfigKey === key) continue;
1044
+ peer.connection.setConfiguration(rtcConfig);
1045
+ peer.rtcConfigKey = key;
1046
+ }
1047
+ } catch {
1048
+ // Retry on the next bounded heartbeat. Keep healthy channels and attempts.
1049
+ logger.warn('[WebRTCSwarm] RTC credential refresh failed; retrying on next heartbeat');
1050
+ } finally { _refreshingRtc = false; }
1051
+ };
1052
+
1053
+ const startHeartbeat = () => {
1054
+ stopHeartbeat();
1055
+ _heartbeatTimer = setInterval(() => {
1056
+ // Send heartbeat to signaling
1057
+ if (adapter || _signalingWs?.readyState === WebSocket.OPEN) sendSignaling({
1058
+ type: 'heartbeat',
1059
+ peerId: _peerId,
1060
+ roomId: _roomId
1061
+ });
1062
+
1063
+ void refreshRtcConfiguration();
1064
+
1065
+ // Ping all peers
1066
+ broadcast('ping', { ts: Date.now() });
1067
+ if (introductions) for (const remote of getConnectedPeers()) {
1068
+ introductions.announce(remote.id).catch(error => logger.warn('[WebRTCSwarm] Contact refresh failed:', error));
1069
+ }
1070
+
1071
+ // Check for stale peers
1072
+ const now = Date.now();
1073
+ for (const [peerId, peer] of _peers) {
1074
+ if (now - peer.lastSeen > PEER_TIMEOUT) {
1075
+ logger.warn(`[WebRTCSwarm] Peer ${peerId} is stale, removing`);
1076
+ recoverPeer(peerId, peer);
1077
+ EventBus.emit('swarm:peer-timeout', { peerId });
1078
+ }
1079
+ }
1080
+ for (const [peerId, candidates] of _pendingIceCandidates) {
1081
+ const liveCandidates = candidates.filter((entry) => now - entry.receivedAt <= PENDING_ICE_TTL_MS);
1082
+ if (liveCandidates.length > 0) _pendingIceCandidates.set(peerId, liveCandidates);
1083
+ else _pendingIceCandidates.delete(peerId);
1084
+ }
1085
+ }, HEARTBEAT_INTERVAL);
1086
+ };
1087
+
1088
+ /**
1089
+ * Stop heartbeat timer
1090
+ */
1091
+ const stopHeartbeat = () => {
1092
+ if (_heartbeatTimer) {
1093
+ clearInterval(_heartbeatTimer);
1094
+ _heartbeatTimer = null;
1095
+ }
1096
+ };
1097
+
1098
+ /**
1099
+ * Disconnect from swarm
1100
+ */
1101
+ const disconnect = () => {
1102
+ _manualStop = true;
1103
+ _generation++;
1104
+ bootstrapLifetime?.abort(); bootstrapLifetime = null;
1105
+ introductions?.close(); introductions = null;
1106
+ unsubscribeAdapter?.(); unsubscribeAdapter = null; adapter = null;
1107
+ _cancelConnect?.();
1108
+ if (_reconnectTimer) {
1109
+ clearTimeout(_reconnectTimer);
1110
+ _reconnectTimer = null;
1111
+ }
1112
+ setConnectionState('stopping');
1113
+ stopHeartbeat();
1114
+
1115
+ // Close all peer connections
1116
+ for (const [peerId] of _peers) {
1117
+ removePeer(peerId);
1118
+ }
1119
+
1120
+ // Close signaling
1121
+ if (_signalingWs) {
1122
+ sendSignaling({ type: 'leave', peerId: _peerId, roomId: _roomId });
1123
+ _signalingWs.close();
1124
+ _signalingWs = null;
1125
+ }
1126
+ setConnectionState('stopped');
1127
+ };
1128
+
1129
+ /**
1130
+ * Get current connection state
1131
+ */
1132
+ const getConnectionState = () => !_manualStop && [..._peers.values()].some(peer => peer.dataChannel?.readyState === 'open')
1133
+ ? 'connected' : _connectionState;
1134
+
1135
+ /**
1136
+ * Get connected peers
1137
+ */
1138
+ const getConnectedPeers = () => {
1139
+ return Array.from(_peers.entries())
1140
+ .filter(([_, peer]) => peer.status === 'connected')
1141
+ .map(([id, peer]) => ({
1142
+ id,
1143
+ metadata: peer.metadata,
1144
+ lastSeen: peer.lastSeen
1145
+ }));
1146
+ };
1147
+
1148
+ /**
1149
+ * Get stats
1150
+ */
1151
+ const getStats = () => ({
1152
+ peerId: _peerId,
1153
+ sessionId: _sessionId,
1154
+ roomId: _roomId,
1155
+ connectionState: getConnectionState(),
1156
+ introductionState: _connectionState,
1157
+ connectedPeers: getConnectedPeers().length,
1158
+ totalPeers: _peers.size,
1159
+ ..._stats,
1160
+ latencyByPeer: Object.fromEntries(_latencyByPeer),
1161
+ pendingIceCandidateCount: Array.from(_pendingIceCandidates.values())
1162
+ .reduce((total, candidates) => total + candidates.length, 0),
1163
+ uptime: Date.now() - _stats.startTime
1164
+ });
1165
+
1166
+ /**
1167
+ * Get logical clock value
1168
+ */
1169
+ const getClock = () => _logicalClock;
1170
+
1171
+ // Register default handlers
1172
+ onMessage('ping', (peerId, payload) => {
1173
+ sendToPeer(peerId, 'pong', { ts: payload.ts, received: Date.now() });
1174
+ });
1175
+
1176
+ onMessage('pong', (peerId, payload) => {
1177
+ const latency = Date.now() - payload.ts;
1178
+ _latencyByPeer.set(peerId, {
1179
+ roundTripMs: latency,
1180
+ measuredAt: Date.now()
1181
+ });
1182
+ EventBus.emit('swarm:latency', { peerId, roundTripMs: latency });
1183
+ logger.debug(`[WebRTCSwarm] Latency to ${peerId}: ${latency}ms`);
1184
+ });
1185
+
1186
+ const introductionTransport = {
1187
+ _getPeerId: () => _peerId, getConnectedPeers, onMessage, sendToPeer,
1188
+ connectIntroducedPeer: connectToPeer,
1189
+ cancelIntroduction(remote, attemptId) {
1190
+ if (!_peers.has(remote)) _pendingIceCandidates.delete(remote);
1191
+ if (_peers.get(remote)?.exchange?.attemptId === attemptId) removePeer(remote);
1192
+ },
1193
+ async acceptIntroductionSignal(remote, message, exchange) {
1194
+ if (message.type === 'offer') return handleOffer(remote, message.offer, exchange);
1195
+ const peer = _peers.get(remote);
1196
+ if (peer && peer.exchange?.attemptId !== exchange.attemptId) throw new Error('Stale introduction attempt');
1197
+ if (message.type === 'answer') return handleAnswer(remote, message.answer);
1198
+ if (message.type === 'ice-candidate') return handleIceCandidate(remote, message.candidate);
1199
+ }
1200
+ };
1201
+ return {
1202
+ getIntroductions: () => introductions,
1203
+ init,
1204
+ disconnect,
1205
+ sendToPeer,
1206
+ broadcast,
1207
+ onMessage,
1208
+ getConnectionState,
1209
+ getConnectedPeers,
1210
+ getPeerStates,
1211
+ observePeerAuthentication(remote, attemptId) {
1212
+ const peer = _peers.get(remote);
1213
+ if (!peer || peer.attemptId !== attemptId || peer.status !== 'connected') return;
1214
+ peer.authenticated = true; reportPeerProgress(peer, peer.phase);
1215
+ },
1216
+ onDataChannel(label, handler) {
1217
+ if (!AUXILIARY_CHANNELS.includes(label) || typeof handler !== 'function') throw new Error('Explicit auxiliary channel handler required');
1218
+ auxiliaryHandlers.set(label, handler);
1219
+ return () => { if (auxiliaryHandlers.get(label) === handler) auxiliaryHandlers.delete(label); };
1220
+ },
1221
+ openDataChannel(remotePeerId, label) {
1222
+ const peer = _peers.get(remotePeerId);
1223
+ if (_manualStop || !AUXILIARY_CHANNELS.includes(label) || peer?.dataChannel?.readyState !== 'open' || !peer.connection) {
1224
+ throw new Error('Connected WebRTC peer required for auxiliary transfer');
1225
+ }
1226
+ return retainAuxiliary(peer, peer.connection.createDataChannel(label, { ordered: true }));
1227
+ },
1228
+ getPeerBinding(remotePeerId) {
1229
+ const peer = _peers.get(remotePeerId);
1230
+ if (peer?.dataChannel?.readyState !== 'open') return null;
1231
+
1232
+ const local = fingerprint(peer.connection?.localDescription), remote = fingerprint(peer.connection?.remoteDescription);
1233
+ return local && remote ? { local, remote } : null;
1234
+ },
1235
+ getStats,
1236
+ getClock,
1237
+ tick, // Expose for SwarmSync to increment clock on local writes
1238
+ // Expose for SwarmSync
1239
+ _getPeerId: () => _peerId,
1240
+ _getSessionId: () => _sessionId
1241
+ };
1242
+ }
1243
+ };
1244
+
1245
+ export default WebRTCSwarm;
1246
+
1247
+ export function createWebRTCSwarm(options) { return WebRTCSwarm.factory(options); }