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.
- package/apps/poolday/config/chat-files.json +27 -0
- package/apps/poolday/config/partition-generation.js +21 -0
- package/apps/poolday/config/partition-policy.json +74 -0
- package/apps/poolday/config/partition-releases.json +3 -0
- package/apps/poolday/config/work-profile.json +122 -0
- package/apps/poolday/host/chat-view.js +47 -0
- package/apps/poolday/host/earning/client.js +199 -0
- package/apps/poolday/host/earning/contribution.js +168 -0
- package/apps/poolday/host/partition-release.js +33 -0
- package/apps/poolday/host/work-partition-artifact-source.js +37 -0
- package/apps/poolday/host/work-partition-capsule.js +39 -0
- package/apps/poolday/host/work-partition-plan.js +167 -0
- package/apps/poolday/host/work-partition-tokenizer-guard.js +21 -0
- package/apps/poolday/infrastructure/model-storage-lock.js +22 -0
- package/apps/poolday/infrastructure/pack-release-storage.js +52 -0
- package/apps/poolday/pool/pack-operation.js +161 -0
- package/apps/poolday/pool/pack-release-policy.js +44 -0
- package/apps/poolday/pool/peer-pack-data-channel.js +217 -0
- package/apps/poolday/providers/work-adapter.js +67 -0
- package/apps/poolday/providers/work-device.js +33 -0
- package/apps/poolday/providers/work-generation-completion.js +55 -0
- package/apps/poolday/providers/work-provider.js +209 -0
- package/apps/poolday/providers/work-resident-provider.js +113 -0
- package/deploy/artifacts/doppler-gpu-0.7.1.tgz +0 -0
- package/docs/fleet-service.md +214 -0
- package/docs/native-participant.md +542 -0
- package/native-delivery.json +1174 -0
- package/package.json +29 -4
- package/packages/reploid/src/artifacts/binary.js +25 -0
- package/packages/reploid/src/artifacts/canonical-json.js +23 -0
- package/packages/reploid/src/artifacts/custody/exchange.js +402 -0
- package/packages/reploid/src/artifacts/custody/piece-acquisition.js +70 -0
- package/packages/reploid/src/artifacts/custody/runtime.js +404 -0
- package/packages/reploid/src/artifacts/custody/supply-budget.js +19 -0
- package/packages/reploid/src/artifacts/custody-certificate.js +123 -0
- package/packages/reploid/src/artifacts/delivery-attestation.js +64 -0
- package/packages/reploid/src/artifacts/generation-terminal.js +36 -0
- package/packages/reploid/src/artifacts/identity.js +91 -0
- package/packages/reploid/src/artifacts/participant-proofs.js +159 -0
- package/packages/reploid/src/artifacts/receipt.js +131 -0
- package/packages/reploid/src/artifacts/signatures.js +94 -0
- package/packages/reploid/src/artifacts/whole-model-receipt.js +42 -0
- package/packages/reploid/src/cancellation.js +44 -0
- package/packages/reploid/src/config/defaults.json +110 -0
- package/packages/reploid/src/config/index.js +209 -0
- package/packages/reploid/src/config/request-overrides.json +9 -0
- package/packages/reploid/src/config/transport-defaults.json +7 -0
- package/packages/reploid/src/hashing.js +30 -0
- package/packages/reploid/src/measurement/capacity-utilization.js +321 -0
- package/packages/reploid/src/measurement/index.js +5 -0
- package/packages/reploid/src/measurement/readiness-records.js +310 -0
- package/packages/reploid/src/mesh/computer-identity.js +50 -0
- package/packages/reploid/src/mesh/computer-identity.policy.json +8 -0
- package/packages/reploid/src/mesh/contribution.js +141 -0
- package/packages/reploid/src/mesh/generation-recovery.js +58 -0
- package/packages/reploid/src/mesh/gpu-signature-shader.js +27 -0
- package/packages/reploid/src/mesh/gpu-signature.js +136 -0
- package/packages/reploid/src/mesh/gpu-signature.policy.json +11 -0
- package/packages/reploid/src/mesh/index.js +66 -0
- package/packages/reploid/src/mesh/partitions/automatic-ordered-partitions.js +540 -0
- package/packages/reploid/src/mesh/partitions/automatic-partitions.js +298 -0
- package/packages/reploid/src/mesh/partitions/partition-attempt-evidence.js +282 -0
- package/packages/reploid/src/mesh/partitions/partition-attempt-queue-policy.json +6 -0
- package/packages/reploid/src/mesh/partitions/partition-attempt-queue.js +112 -0
- package/packages/reploid/src/mesh/partitions/partition-canonical-contract.js +167 -0
- package/packages/reploid/src/mesh/partitions/partition-canonical-execution.js +168 -0
- package/packages/reploid/src/mesh/partitions/partition-chat-canonical.js +49 -0
- package/packages/reploid/src/mesh/partitions/partition-chat.js +125 -0
- package/packages/reploid/src/mesh/partitions/partition-contract.js +192 -0
- package/packages/reploid/src/mesh/partitions/partition-device-admission.js +96 -0
- package/packages/reploid/src/mesh/partitions/partition-discovery.js +96 -0
- package/packages/reploid/src/mesh/partitions/partition-entry-publication.js +183 -0
- package/packages/reploid/src/mesh/partitions/partition-entry.js +434 -0
- package/packages/reploid/src/mesh/partitions/partition-generation-request.js +33 -0
- package/packages/reploid/src/mesh/partitions/partition-grants.js +277 -0
- package/packages/reploid/src/mesh/partitions/partition-link.js +103 -0
- package/packages/reploid/src/mesh/partitions/partition-network.js +109 -0
- package/packages/reploid/src/mesh/partitions/partition-ordered-chat.js +290 -0
- package/packages/reploid/src/mesh/partitions/partition-ordered-peer.js +415 -0
- package/packages/reploid/src/mesh/partitions/partition-ordered-publication.js +100 -0
- package/packages/reploid/src/mesh/partitions/partition-peer.js +314 -0
- package/packages/reploid/src/mesh/partitions/partition-placement.js +143 -0
- package/packages/reploid/src/mesh/partitions/partition-preparation-progress.js +25 -0
- package/packages/reploid/src/mesh/partitions/partition-receiver-policy.json +7 -0
- package/packages/reploid/src/mesh/partitions/partition-reservations.js +75 -0
- package/packages/reploid/src/mesh/partitions/partition-route-envelope-policy.json +16 -0
- package/packages/reploid/src/mesh/partitions/partition-route-envelope.js +74 -0
- package/packages/reploid/src/mesh/partitions/partition-route-execution.js +409 -0
- package/packages/reploid/src/mesh/partitions/partition-route-network.js +179 -0
- package/packages/reploid/src/mesh/partitions/partition-route-ranking.js +268 -0
- package/packages/reploid/src/mesh/partitions/partition-runner.js +319 -0
- package/packages/reploid/src/mesh/partitions/partition-stage-assignment.js +109 -0
- package/packages/reploid/src/mesh/partitions/partition-stage-commit.js +103 -0
- package/packages/reploid/src/mesh/partitions/partition-step-receiver.js +291 -0
- package/packages/reploid/src/mesh/partitions/partition-summary.js +122 -0
- package/packages/reploid/src/mesh/partitions/preparation-owner.js +40 -0
- package/packages/reploid/src/mesh/partitions/resident-error-codes.json +10 -0
- package/packages/reploid/src/mesh/partitions/resident-partition.js +333 -0
- package/packages/reploid/src/mesh/partitions/resident-route.js +46 -0
- package/packages/reploid/src/mesh/partitions/signed-resident-factory.js +59 -0
- package/packages/reploid/src/mesh/peer-generation.js +896 -0
- package/packages/reploid/src/mesh/peer-identity.js +87 -0
- package/packages/reploid/src/mesh/placement-beliefs.js +107 -0
- package/packages/reploid/src/mesh/preparation-progress.js +46 -0
- package/packages/reploid/src/mesh/provider-hardware.js +11 -0
- package/packages/reploid/src/mesh/remote-generation-requests.js +53 -0
- package/packages/reploid/src/mesh/swarm-coordination.js +140 -0
- package/packages/reploid/src/mesh/web-access-network.js +112 -0
- package/packages/reploid/src/mesh/web-access-policy.json +17 -0
- package/packages/reploid/src/retrieval/citations.js +156 -0
- package/packages/reploid/src/retrieval/claim-support.js +113 -0
- package/packages/reploid/src/retrieval/costs.js +101 -0
- package/packages/reploid/src/retrieval/fixed-provider.js +34 -0
- package/packages/reploid/src/retrieval/index.js +20 -0
- package/packages/reploid/src/retrieval/lexical-support.js +27 -0
- package/packages/reploid/src/retrieval/model-reviewer.js +67 -0
- package/packages/reploid/src/retrieval/page-fetch.json +10 -0
- package/packages/reploid/src/retrieval/page-text.js +182 -0
- package/packages/reploid/src/retrieval/passage.js +37 -0
- package/packages/reploid/src/retrieval/policy.json +14 -0
- package/packages/reploid/src/retrieval/retrieval.js +23 -0
- package/packages/reploid/src/retrieval/review-queue.js +76 -0
- package/packages/reploid/src/retrieval/sources.js +263 -0
- package/packages/reploid/src/retrieval/support-review.js +155 -0
- package/packages/reploid/src/retrieval/text.js +21 -0
- package/packages/reploid/src/rules/contribution.rules.json +8 -0
- package/packages/reploid/src/rules/routing.rules.json +24 -0
- package/packages/reploid/src/transport/assignment.js +571 -0
- package/packages/reploid/src/transport/bounded-channel-writer.js +46 -0
- package/packages/reploid/src/transport/index.js +11 -0
- package/packages/reploid/src/transport/introduction-admission.js +24 -0
- package/packages/reploid/src/transport/introduction-bootstrap.js +112 -0
- package/packages/reploid/src/transport/introduction-policy.json +12 -0
- package/packages/reploid/src/transport/introduction-records.js +90 -0
- package/packages/reploid/src/transport/introductions.js +209 -0
- package/packages/reploid/src/transport/partition-data-channel.js +421 -0
- package/packages/reploid/src/transport/partition-errors.json +16 -0
- package/packages/reploid/src/transport/retry-policy.js +36 -0
- package/packages/reploid/src/transport/room.js +502 -0
- package/packages/reploid/src/transport/sdp-fingerprint.js +5 -0
- package/packages/reploid/src/transport/signaling.js +571 -0
- package/packages/reploid/src/transport/swarm.js +1247 -0
- package/packages/reploid/src/transport/tool-offer-channel.js +137 -0
- package/server/browser-roots.js +42 -0
- package/server/browser-web-worker.js +94 -0
- package/server/chat-completions.js +45 -0
- package/server/fleet/browser-bridge.js +145 -0
- package/server/fleet/contract.js +42 -0
- package/server/fleet/credit-evidence.js +103 -0
- package/server/fleet/credit-recovery.js +175 -0
- package/server/fleet/credits.js +178 -0
- package/server/fleet/execution-admission.js +32 -0
- package/server/fleet/native-host.js +817 -0
- package/server/fleet/placement.js +132 -0
- package/server/fleet/requester.js +346 -0
- package/server/fleet/service.js +311 -0
- package/server/fleet/terminal-policy.js +83 -0
- package/server/fleet/text-api.js +94 -0
- package/server/hashing.js +35 -0
- package/server/http-request-owner.js +49 -0
- package/server/messages.js +57 -0
- package/server/native-canonical-epochs.js +90 -0
- package/server/native-change-log.js +40 -0
- package/server/native-earning.js +203 -0
- package/server/native-generation-journal.js +46 -0
- package/server/native-model-config.js +57 -0
- package/server/native-model-files.js +382 -0
- package/server/native-model-origin.js +54 -0
- package/server/native-model-origins.json +12 -0
- package/server/native-node.js +85 -0
- package/server/native-owner/controls.js +162 -0
- package/server/native-owner/monitor.js +251 -0
- package/server/native-owner/policy.js +273 -0
- package/server/native-owner/rate.js +92 -0
- package/server/native-participant.js +866 -0
- package/server/native-readiness-log.js +30 -0
- package/server/native-release-storage.js +64 -0
- package/server/native-shader-cache.js +369 -0
- package/server/native-supply-budget.js +66 -0
- package/server/native-whole-earning.js +67 -0
- package/server/native-whole-model-fill.js +182 -0
- package/server/native-whole-model.js +130 -0
- package/server/public-web-worker.js +66 -0
- package/server/responses.js +92 -0
- package/server/search/errors.js +47 -0
- package/server/search/page-fetch.js +185 -0
- package/server/text-api.js +83 -0
- package/server/text-recovery.js +45 -0
- package/server/text-request-options.js +23 -0
- package/server/text-requester.js +231 -0
- package/server/token-auth.js +9 -0
- package/server/whole-model-requester.js +99 -0
- package/shared/config/chat-models.json +4685 -0
- package/shared/config/doppler-execution-contracts.js +32 -0
- package/shared/config/doppler-execution-contracts.json +43 -0
- package/shared/config/doppler-generation-contract.js +223 -0
- package/shared/config/doppler-local-models.js +79 -0
- package/shared/config/doppler-package.json +7 -0
- package/shared/config/model-manifests/a0dc69e917133a82780823d17fc771c0f6b882d7c86c571deeaef45be2ff108b.json +6434 -0
- package/shared/config/model-manifests/e985e3cdad60d0cb57d84b436161e66ab5b3f6589a168de6f00b4610e5f1fb59.json +16534 -0
- package/shared/config/model-manifests/fa6eb7bbc3ba63571f8cede780b877d9272bde948697af93dd3d33837cb46137.json +7175 -0
- package/shared/config/model-pieces/qwen-0-8b-pieces.json +1506 -0
- package/shared/config/model-pieces/qwen-2b-pieces.json +4800 -0
- package/shared/config/model-pieces/qwen35b-q4_0-pieces.json +28880 -0
- package/shared/config/reploid-library.json +49 -0
- package/shared/config/swarm-bootstrap.json +21 -0
- package/shared/infrastructure/doppler-runtime-service.js +261 -0
- package/shared/pool/executable-pack.js +87 -0
- package/shared/pool/pack-operation-adapters.js +124 -0
- package/shared/pool/pack-operation-policy.js +75 -0
- package/shared/pool/pool-config.json +2280 -0
- package/shared/vendor/reploid/adapters/doppler-session.js +372 -0
- package/shared/vendor/reploid/artifacts/binary.js +25 -0
- package/shared/vendor/reploid/artifacts/canonical-json.js +23 -0
- package/shared/vendor/reploid/artifacts/delivery-attestation.js +64 -0
- package/shared/vendor/reploid/artifacts/identity.js +91 -0
- package/shared/vendor/reploid/artifacts/manifest-identity.js +17 -0
- package/shared/vendor/reploid/artifacts/participant-proofs.js +159 -0
- package/shared/vendor/reploid/artifacts/signatures.js +94 -0
- package/shared/vendor/reploid/config/defaults.json +110 -0
- package/shared/vendor/reploid/config/index.js +209 -0
- package/shared/vendor/reploid/config/request-overrides.json +9 -0
- package/shared/vendor/reploid/hashing.js +30 -0
- package/shared/vendor/reploid/mesh/generation-context-cache.js +97 -0
- package/shared/vendor/reploid/mesh/partitions/partition-attempt-evidence.js +282 -0
- package/shared/vendor/reploid/mesh/partitions/partition-contract.js +192 -0
- package/shared/vendor/reploid/mesh/partitions/partition-receiver-policy.json +7 -0
- package/shared/vendor/reploid/mesh/partitions/partition-route-ranking.js +268 -0
- package/shared/vendor/reploid/mesh/preparation-progress.js +46 -0
- package/shared/vendor/reploid/mesh/provider-hardware.js +11 -0
- package/shared/vendor/reploid/rules/routing.rules.json +24 -0
- package/shared/vendor/reploid/transport/partition-errors.json +16 -0
- 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); }
|