@hashtree/worker 0.1.26 → 0.2.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/README.md +16 -16
- package/package.json +23 -19
- package/src/app-runtime.ts +393 -0
- package/src/capabilities/blossomBandwidthTracker.ts +74 -0
- package/src/capabilities/blossomTransport.ts +179 -0
- package/src/capabilities/connectivity.ts +54 -0
- package/src/capabilities/idbStorage.ts +94 -0
- package/src/capabilities/meshRouterStore.ts +426 -0
- package/src/capabilities/rootResolver.ts +497 -0
- package/src/client-id.ts +137 -0
- package/src/client.ts +501 -0
- package/{dist/entry.js → src/entry.ts} +1 -1
- package/src/htree-path.ts +53 -0
- package/src/htree-url.ts +156 -0
- package/src/index.ts +76 -0
- package/src/mediaStreaming.ts +64 -0
- package/src/p2p/boundedQueue.ts +168 -0
- package/src/p2p/errorMessage.ts +6 -0
- package/src/p2p/index.ts +48 -0
- package/src/p2p/lruCache.ts +78 -0
- package/src/p2p/meshQueryRouter.ts +361 -0
- package/src/p2p/protocol.ts +11 -0
- package/src/p2p/queryForwardingMachine.ts +197 -0
- package/src/p2p/signaling.ts +284 -0
- package/src/p2p/uploadRateLimiter.ts +85 -0
- package/src/p2p/webrtcController.ts +1168 -0
- package/src/p2p/webrtcProxy.ts +519 -0
- package/src/privacyGuards.ts +31 -0
- package/src/protocol.ts +124 -0
- package/src/relay/identity.ts +86 -0
- package/src/relay/mediaHandler.ts +1633 -0
- package/src/relay/ndk.ts +590 -0
- package/{dist/iris/nostr-wasm.js → src/relay/nostr-wasm.ts} +4 -1
- package/src/relay/nostr.ts +249 -0
- package/src/relay/protocol.ts +361 -0
- package/src/relay/publicAssetUrl.ts +25 -0
- package/src/relay/rootPathResolver.ts +50 -0
- package/src/relay/shims.d.ts +17 -0
- package/src/relay/signing.ts +332 -0
- package/src/relay/treeRootCache.ts +354 -0
- package/src/relay/treeRootSubscription.ts +577 -0
- package/src/relay/utils/constants.ts +139 -0
- package/src/relay/utils/errorMessage.ts +7 -0
- package/src/relay/utils/lruCache.ts +79 -0
- package/src/relay/webrtc.ts +5 -0
- package/src/relay/webrtcSignaling.ts +108 -0
- package/src/relay/worker.ts +1787 -0
- package/src/relay-client.ts +265 -0
- package/src/relay-entry.ts +1 -0
- package/src/runtime-network.ts +134 -0
- package/src/runtime.ts +153 -0
- package/src/transferableBytes.ts +5 -0
- package/src/tree-root.ts +851 -0
- package/src/types.ts +8 -0
- package/src/worker.ts +975 -0
- package/LICENSE +0 -21
- package/dist/app-runtime.d.ts +0 -60
- package/dist/app-runtime.d.ts.map +0 -1
- package/dist/app-runtime.js +0 -271
- package/dist/app-runtime.js.map +0 -1
- package/dist/capabilities/blossomBandwidthTracker.d.ts +0 -26
- package/dist/capabilities/blossomBandwidthTracker.d.ts.map +0 -1
- package/dist/capabilities/blossomBandwidthTracker.js +0 -53
- package/dist/capabilities/blossomBandwidthTracker.js.map +0 -1
- package/dist/capabilities/blossomTransport.d.ts +0 -22
- package/dist/capabilities/blossomTransport.d.ts.map +0 -1
- package/dist/capabilities/blossomTransport.js +0 -144
- package/dist/capabilities/blossomTransport.js.map +0 -1
- package/dist/capabilities/connectivity.d.ts +0 -3
- package/dist/capabilities/connectivity.d.ts.map +0 -1
- package/dist/capabilities/connectivity.js +0 -49
- package/dist/capabilities/connectivity.js.map +0 -1
- package/dist/capabilities/idbStorage.d.ts +0 -25
- package/dist/capabilities/idbStorage.d.ts.map +0 -1
- package/dist/capabilities/idbStorage.js +0 -73
- package/dist/capabilities/idbStorage.js.map +0 -1
- package/dist/capabilities/meshRouterStore.d.ts +0 -71
- package/dist/capabilities/meshRouterStore.d.ts.map +0 -1
- package/dist/capabilities/meshRouterStore.js +0 -316
- package/dist/capabilities/meshRouterStore.js.map +0 -1
- package/dist/capabilities/rootResolver.d.ts +0 -10
- package/dist/capabilities/rootResolver.d.ts.map +0 -1
- package/dist/capabilities/rootResolver.js +0 -393
- package/dist/capabilities/rootResolver.js.map +0 -1
- package/dist/client-id.d.ts +0 -18
- package/dist/client-id.d.ts.map +0 -1
- package/dist/client-id.js +0 -98
- package/dist/client-id.js.map +0 -1
- package/dist/client.d.ts +0 -61
- package/dist/client.d.ts.map +0 -1
- package/dist/client.js +0 -417
- package/dist/client.js.map +0 -1
- package/dist/entry.d.ts +0 -2
- package/dist/entry.d.ts.map +0 -1
- package/dist/entry.js.map +0 -1
- package/dist/htree-path.d.ts +0 -13
- package/dist/htree-path.d.ts.map +0 -1
- package/dist/htree-path.js +0 -38
- package/dist/htree-path.js.map +0 -1
- package/dist/htree-url.d.ts +0 -22
- package/dist/htree-url.d.ts.map +0 -1
- package/dist/htree-url.js +0 -118
- package/dist/htree-url.js.map +0 -1
- package/dist/index.d.ts +0 -17
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -8
- package/dist/index.js.map +0 -1
- package/dist/iris/identity.d.ts +0 -36
- package/dist/iris/identity.d.ts.map +0 -1
- package/dist/iris/identity.js +0 -78
- package/dist/iris/identity.js.map +0 -1
- package/dist/iris/mediaHandler.d.ts +0 -64
- package/dist/iris/mediaHandler.d.ts.map +0 -1
- package/dist/iris/mediaHandler.js +0 -1285
- package/dist/iris/mediaHandler.js.map +0 -1
- package/dist/iris/ndk.d.ts +0 -96
- package/dist/iris/ndk.d.ts.map +0 -1
- package/dist/iris/ndk.js +0 -502
- package/dist/iris/ndk.js.map +0 -1
- package/dist/iris/nostr-wasm.d.ts +0 -14
- package/dist/iris/nostr-wasm.d.ts.map +0 -1
- package/dist/iris/nostr-wasm.js.map +0 -1
- package/dist/iris/nostr.d.ts +0 -60
- package/dist/iris/nostr.d.ts.map +0 -1
- package/dist/iris/nostr.js +0 -207
- package/dist/iris/nostr.js.map +0 -1
- package/dist/iris/protocol.d.ts +0 -583
- package/dist/iris/protocol.d.ts.map +0 -1
- package/dist/iris/protocol.js +0 -16
- package/dist/iris/protocol.js.map +0 -1
- package/dist/iris/publicAssetUrl.d.ts +0 -6
- package/dist/iris/publicAssetUrl.d.ts.map +0 -1
- package/dist/iris/publicAssetUrl.js +0 -14
- package/dist/iris/publicAssetUrl.js.map +0 -1
- package/dist/iris/rootPathResolver.d.ts +0 -9
- package/dist/iris/rootPathResolver.d.ts.map +0 -1
- package/dist/iris/rootPathResolver.js +0 -32
- package/dist/iris/rootPathResolver.js.map +0 -1
- package/dist/iris/signing.d.ts +0 -50
- package/dist/iris/signing.d.ts.map +0 -1
- package/dist/iris/signing.js +0 -299
- package/dist/iris/signing.js.map +0 -1
- package/dist/iris/treeRootCache.d.ts +0 -86
- package/dist/iris/treeRootCache.d.ts.map +0 -1
- package/dist/iris/treeRootCache.js +0 -269
- package/dist/iris/treeRootCache.js.map +0 -1
- package/dist/iris/treeRootSubscription.d.ts +0 -55
- package/dist/iris/treeRootSubscription.d.ts.map +0 -1
- package/dist/iris/treeRootSubscription.js +0 -479
- package/dist/iris/treeRootSubscription.js.map +0 -1
- package/dist/iris/utils/constants.d.ts +0 -76
- package/dist/iris/utils/constants.d.ts.map +0 -1
- package/dist/iris/utils/constants.js +0 -113
- package/dist/iris/utils/constants.js.map +0 -1
- package/dist/iris/utils/errorMessage.d.ts +0 -5
- package/dist/iris/utils/errorMessage.d.ts.map +0 -1
- package/dist/iris/utils/errorMessage.js +0 -8
- package/dist/iris/utils/errorMessage.js.map +0 -1
- package/dist/iris/utils/lruCache.d.ts +0 -26
- package/dist/iris/utils/lruCache.d.ts.map +0 -1
- package/dist/iris/utils/lruCache.js +0 -66
- package/dist/iris/utils/lruCache.js.map +0 -1
- package/dist/iris/webrtc.d.ts +0 -2
- package/dist/iris/webrtc.d.ts.map +0 -1
- package/dist/iris/webrtc.js +0 -3
- package/dist/iris/webrtc.js.map +0 -1
- package/dist/iris/webrtcSignaling.d.ts +0 -37
- package/dist/iris/webrtcSignaling.d.ts.map +0 -1
- package/dist/iris/webrtcSignaling.js +0 -86
- package/dist/iris/webrtcSignaling.js.map +0 -1
- package/dist/iris/worker.d.ts +0 -12
- package/dist/iris/worker.d.ts.map +0 -1
- package/dist/iris/worker.js +0 -1529
- package/dist/iris/worker.js.map +0 -1
- package/dist/iris-client.d.ts +0 -31
- package/dist/iris-client.d.ts.map +0 -1
- package/dist/iris-client.js +0 -197
- package/dist/iris-client.js.map +0 -1
- package/dist/iris-entry.d.ts +0 -2
- package/dist/iris-entry.d.ts.map +0 -1
- package/dist/iris-entry.js +0 -2
- package/dist/iris-entry.js.map +0 -1
- package/dist/mediaStreaming.d.ts +0 -7
- package/dist/mediaStreaming.d.ts.map +0 -1
- package/dist/mediaStreaming.js +0 -48
- package/dist/mediaStreaming.js.map +0 -1
- package/dist/p2p/boundedQueue.d.ts +0 -79
- package/dist/p2p/boundedQueue.d.ts.map +0 -1
- package/dist/p2p/boundedQueue.js +0 -134
- package/dist/p2p/boundedQueue.js.map +0 -1
- package/dist/p2p/errorMessage.d.ts +0 -5
- package/dist/p2p/errorMessage.d.ts.map +0 -1
- package/dist/p2p/errorMessage.js +0 -7
- package/dist/p2p/errorMessage.js.map +0 -1
- package/dist/p2p/index.d.ts +0 -8
- package/dist/p2p/index.d.ts.map +0 -1
- package/dist/p2p/index.js +0 -6
- package/dist/p2p/index.js.map +0 -1
- package/dist/p2p/lruCache.d.ts +0 -26
- package/dist/p2p/lruCache.d.ts.map +0 -1
- package/dist/p2p/lruCache.js +0 -65
- package/dist/p2p/lruCache.js.map +0 -1
- package/dist/p2p/meshQueryRouter.d.ts +0 -44
- package/dist/p2p/meshQueryRouter.d.ts.map +0 -1
- package/dist/p2p/meshQueryRouter.js +0 -228
- package/dist/p2p/meshQueryRouter.js.map +0 -1
- package/dist/p2p/protocol.d.ts +0 -10
- package/dist/p2p/protocol.d.ts.map +0 -1
- package/dist/p2p/protocol.js +0 -2
- package/dist/p2p/protocol.js.map +0 -1
- package/dist/p2p/queryForwardingMachine.d.ts +0 -46
- package/dist/p2p/queryForwardingMachine.d.ts.map +0 -1
- package/dist/p2p/queryForwardingMachine.js +0 -144
- package/dist/p2p/queryForwardingMachine.js.map +0 -1
- package/dist/p2p/signaling.d.ts +0 -63
- package/dist/p2p/signaling.d.ts.map +0 -1
- package/dist/p2p/signaling.js +0 -185
- package/dist/p2p/signaling.js.map +0 -1
- package/dist/p2p/uploadRateLimiter.d.ts +0 -21
- package/dist/p2p/uploadRateLimiter.d.ts.map +0 -1
- package/dist/p2p/uploadRateLimiter.js +0 -62
- package/dist/p2p/uploadRateLimiter.js.map +0 -1
- package/dist/p2p/webrtcController.d.ts +0 -168
- package/dist/p2p/webrtcController.d.ts.map +0 -1
- package/dist/p2p/webrtcController.js +0 -902
- package/dist/p2p/webrtcController.js.map +0 -1
- package/dist/p2p/webrtcProxy.d.ts +0 -62
- package/dist/p2p/webrtcProxy.d.ts.map +0 -1
- package/dist/p2p/webrtcProxy.js +0 -447
- package/dist/p2p/webrtcProxy.js.map +0 -1
- package/dist/privacyGuards.d.ts +0 -14
- package/dist/privacyGuards.d.ts.map +0 -1
- package/dist/privacyGuards.js +0 -27
- package/dist/privacyGuards.js.map +0 -1
- package/dist/protocol.d.ts +0 -225
- package/dist/protocol.d.ts.map +0 -1
- package/dist/protocol.js +0 -2
- package/dist/protocol.js.map +0 -1
- package/dist/runtime-network.d.ts +0 -23
- package/dist/runtime-network.d.ts.map +0 -1
- package/dist/runtime-network.js +0 -105
- package/dist/runtime-network.js.map +0 -1
- package/dist/runtime.d.ts +0 -23
- package/dist/runtime.d.ts.map +0 -1
- package/dist/runtime.js +0 -122
- package/dist/runtime.js.map +0 -1
- package/dist/tree-root.d.ts +0 -201
- package/dist/tree-root.d.ts.map +0 -1
- package/dist/tree-root.js +0 -632
- package/dist/tree-root.js.map +0 -1
- package/dist/types.d.ts +0 -2
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js +0 -2
- package/dist/types.js.map +0 -1
- package/dist/worker.d.ts +0 -9
- package/dist/worker.d.ts.map +0 -1
- package/dist/worker.js +0 -797
- package/dist/worker.js.map +0 -1
|
@@ -0,0 +1,1168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Worker WebRTC Controller
|
|
3
|
+
*
|
|
4
|
+
* Controls WebRTC connections from the worker thread.
|
|
5
|
+
* Main thread proxy executes RTCPeerConnection operations.
|
|
6
|
+
*
|
|
7
|
+
* Worker owns:
|
|
8
|
+
* - Peer state tracking
|
|
9
|
+
* - Connection lifecycle decisions
|
|
10
|
+
* - Data protocol (request/response)
|
|
11
|
+
* - Signaling message handling
|
|
12
|
+
*
|
|
13
|
+
* Main thread proxy owns:
|
|
14
|
+
* - RTCPeerConnection instances (not available in workers)
|
|
15
|
+
* - Data channel I/O
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { Store } from '@hashtree/core';
|
|
19
|
+
import { fromHex, sha256, toHex } from '@hashtree/core';
|
|
20
|
+
import type { WebRTCCommand, WebRTCEvent } from './protocol.js';
|
|
21
|
+
import {
|
|
22
|
+
MAX_HTL,
|
|
23
|
+
MSG_TYPE_REQUEST,
|
|
24
|
+
MSG_TYPE_RESPONSE,
|
|
25
|
+
FRAGMENT_SIZE,
|
|
26
|
+
PeerId,
|
|
27
|
+
encodeRequest,
|
|
28
|
+
encodeResponse,
|
|
29
|
+
parseMessage,
|
|
30
|
+
createRequest,
|
|
31
|
+
createResponse,
|
|
32
|
+
createFragmentResponse,
|
|
33
|
+
hashToKey,
|
|
34
|
+
verifyHash,
|
|
35
|
+
generatePeerHTLConfig,
|
|
36
|
+
type SignalingMessage,
|
|
37
|
+
type PeerPool,
|
|
38
|
+
type DataRequest,
|
|
39
|
+
type DataResponse,
|
|
40
|
+
type PeerHTLConfig,
|
|
41
|
+
type PendingRequest,
|
|
42
|
+
type SelectionStrategy,
|
|
43
|
+
type RequestDispatchConfig,
|
|
44
|
+
PeerSelector,
|
|
45
|
+
buildHedgedWavePlan,
|
|
46
|
+
normalizeDispatchConfig,
|
|
47
|
+
syncSelectorPeers,
|
|
48
|
+
} from '@hashtree/nostr';
|
|
49
|
+
import { LRUCache } from './lruCache.js';
|
|
50
|
+
import { MeshQueryRouter, encodeForwardRequest, type MeshPeerQueryOptions } from './meshQueryRouter.js';
|
|
51
|
+
|
|
52
|
+
const PEER_METADATA_POINTER_SLOT_KEY = 'hashtree-webrtc/peer-metadata/latest/v1';
|
|
53
|
+
const DEFAULT_REQUEST_DISPATCH: RequestDispatchConfig = {
|
|
54
|
+
initialFanout: 2,
|
|
55
|
+
hedgeFanout: 1,
|
|
56
|
+
maxFanout: 8,
|
|
57
|
+
hedgeIntervalMs: 120,
|
|
58
|
+
};
|
|
59
|
+
const ACTIVE_REQUEST_RANK_PENALTY = 3;
|
|
60
|
+
|
|
61
|
+
// ============================================================================
|
|
62
|
+
// Types
|
|
63
|
+
// ============================================================================
|
|
64
|
+
|
|
65
|
+
interface WorkerPeer {
|
|
66
|
+
peerId: string;
|
|
67
|
+
pubkey: string;
|
|
68
|
+
pool: PeerPool;
|
|
69
|
+
direction: 'inbound' | 'outbound';
|
|
70
|
+
state: 'connecting' | 'connected' | 'disconnected';
|
|
71
|
+
dataChannelReady: boolean;
|
|
72
|
+
answerCreated: boolean; // Track if we've already created an answer (inbound only)
|
|
73
|
+
htlConfig: PeerHTLConfig;
|
|
74
|
+
pendingRequests: Map<string, PendingRequest>;
|
|
75
|
+
stats: PeerStats;
|
|
76
|
+
createdAt: number;
|
|
77
|
+
connectedAt?: number;
|
|
78
|
+
// Backpressure state
|
|
79
|
+
bufferPaused: boolean;
|
|
80
|
+
deferredRequests: DataRequest[];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface PeerStats {
|
|
84
|
+
requestsSent: number;
|
|
85
|
+
requestsReceived: number;
|
|
86
|
+
responsesSent: number;
|
|
87
|
+
responsesReceived: number;
|
|
88
|
+
bytesSent: number;
|
|
89
|
+
bytesReceived: number;
|
|
90
|
+
forwardedRequests: number;
|
|
91
|
+
forwardedResolved: number;
|
|
92
|
+
forwardedSuppressed: number;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
interface InFlightPeerRequest {
|
|
96
|
+
peerId: string;
|
|
97
|
+
settled: boolean;
|
|
98
|
+
promise: Promise<{ peerId: string; data: Uint8Array | null; elapsedMs: number }>;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface WebRTCControllerConfig {
|
|
102
|
+
pubkey: string;
|
|
103
|
+
localStore: Store;
|
|
104
|
+
sendCommand: (cmd: WebRTCCommand) => void;
|
|
105
|
+
sendSignaling: (msg: SignalingMessage, recipientPubkey?: string) => Promise<void>;
|
|
106
|
+
upstreamFetch?: (hash: Uint8Array) => Promise<Uint8Array | null>;
|
|
107
|
+
getFollows?: () => Set<string>;
|
|
108
|
+
requestTimeout?: number;
|
|
109
|
+
forwardRateLimit?: {
|
|
110
|
+
maxForwardsPerPeerWindow?: number;
|
|
111
|
+
windowMs?: number;
|
|
112
|
+
};
|
|
113
|
+
requestSelectionStrategy?: SelectionStrategy;
|
|
114
|
+
requestFairnessEnabled?: boolean;
|
|
115
|
+
requestDispatch?: RequestDispatchConfig;
|
|
116
|
+
debug?: boolean;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
type PeerClassifier = (pubkey: string) => PeerPool;
|
|
120
|
+
type PoolConnectionConfig = { maxConnections: number; satisfiedConnections: number };
|
|
121
|
+
|
|
122
|
+
// ============================================================================
|
|
123
|
+
// Controller
|
|
124
|
+
// ============================================================================
|
|
125
|
+
|
|
126
|
+
export class WebRTCController {
|
|
127
|
+
private myPeerId: PeerId;
|
|
128
|
+
private peers = new Map<string, WorkerPeer>();
|
|
129
|
+
private pendingRemoteCandidates = new Map<string, RTCIceCandidateInit[]>();
|
|
130
|
+
private localStore: Store;
|
|
131
|
+
private sendCommand: (cmd: WebRTCCommand) => void;
|
|
132
|
+
private sendSignaling: (msg: SignalingMessage, recipientPubkey?: string) => Promise<void>;
|
|
133
|
+
private classifyPeer: PeerClassifier;
|
|
134
|
+
private requestTimeout: number;
|
|
135
|
+
private debug: boolean;
|
|
136
|
+
private recentRequests = new LRUCache<string, number>(1000);
|
|
137
|
+
private readonly activePeerRequests = new Map<string, number>();
|
|
138
|
+
private readonly meshRouter: MeshQueryRouter;
|
|
139
|
+
private readonly peerSelector: PeerSelector;
|
|
140
|
+
private routing: {
|
|
141
|
+
selectionStrategy: SelectionStrategy;
|
|
142
|
+
fairnessEnabled: boolean;
|
|
143
|
+
dispatch: RequestDispatchConfig;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
// Pool configuration - reasonable defaults, settings sync will override
|
|
147
|
+
private poolConfig: Record<PeerPool, PoolConnectionConfig> = {
|
|
148
|
+
follows: { maxConnections: 20, satisfiedConnections: 10 },
|
|
149
|
+
other: { maxConnections: 16, satisfiedConnections: 8 },
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
// Hello interval - 5s for faster peer discovery
|
|
153
|
+
private helloInterval?: ReturnType<typeof setInterval>;
|
|
154
|
+
private readonly HELLO_INTERVAL = 5000;
|
|
155
|
+
|
|
156
|
+
constructor(config: WebRTCControllerConfig) {
|
|
157
|
+
this.myPeerId = new PeerId(config.pubkey);
|
|
158
|
+
this.localStore = config.localStore;
|
|
159
|
+
this.sendCommand = config.sendCommand;
|
|
160
|
+
this.sendSignaling = config.sendSignaling;
|
|
161
|
+
this.requestTimeout = config.requestTimeout ?? 1000;
|
|
162
|
+
this.debug = config.debug ?? false;
|
|
163
|
+
this.routing = {
|
|
164
|
+
selectionStrategy: config.requestSelectionStrategy ?? 'titForTat',
|
|
165
|
+
fairnessEnabled: config.requestFairnessEnabled ?? true,
|
|
166
|
+
dispatch: config.requestDispatch ?? DEFAULT_REQUEST_DISPATCH,
|
|
167
|
+
};
|
|
168
|
+
this.peerSelector = PeerSelector.withStrategy(this.routing.selectionStrategy);
|
|
169
|
+
this.peerSelector.setFairness(this.routing.fairnessEnabled);
|
|
170
|
+
this.meshRouter = new MeshQueryRouter({
|
|
171
|
+
localStore: this.localStore,
|
|
172
|
+
requestTimeoutMs: this.requestTimeout,
|
|
173
|
+
upstreamFetch: config.upstreamFetch,
|
|
174
|
+
queryPeers: (hash, options) => this.queryPeersWithDispatch(hash, options),
|
|
175
|
+
maxForwardsPerPeerWindow: config.forwardRateLimit?.maxForwardsPerPeerWindow,
|
|
176
|
+
forwardRateLimitWindowMs: config.forwardRateLimit?.windowMs,
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// Default classifier: check if pubkey is in follows
|
|
180
|
+
const getFollows = config.getFollows ?? (() => new Set<string>());
|
|
181
|
+
this.classifyPeer = (pubkey: string) => {
|
|
182
|
+
const follows = getFollows();
|
|
183
|
+
const isFollow = follows.has(pubkey);
|
|
184
|
+
return isFollow ? 'follows' : 'other';
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ============================================================================
|
|
189
|
+
// Lifecycle
|
|
190
|
+
// ============================================================================
|
|
191
|
+
|
|
192
|
+
start(): void {
|
|
193
|
+
this.log('Starting WebRTC controller');
|
|
194
|
+
|
|
195
|
+
// Send hello periodically
|
|
196
|
+
this.helloInterval = setInterval(() => {
|
|
197
|
+
this.sendHello();
|
|
198
|
+
}, this.HELLO_INTERVAL);
|
|
199
|
+
|
|
200
|
+
// Send initial hello
|
|
201
|
+
this.sendHello();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
stop(): void {
|
|
205
|
+
this.log('Stopping WebRTC controller');
|
|
206
|
+
|
|
207
|
+
if (this.helloInterval) {
|
|
208
|
+
clearInterval(this.helloInterval);
|
|
209
|
+
this.helloInterval = undefined;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Close all peers
|
|
213
|
+
for (const peerId of this.peers.keys()) {
|
|
214
|
+
this.closePeer(peerId);
|
|
215
|
+
}
|
|
216
|
+
this.meshRouter.stop();
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ============================================================================
|
|
220
|
+
// Signaling
|
|
221
|
+
// ============================================================================
|
|
222
|
+
|
|
223
|
+
private sendHello(): void {
|
|
224
|
+
const msg: SignalingMessage = {
|
|
225
|
+
type: 'hello',
|
|
226
|
+
peerId: this.myPeerId.toString(),
|
|
227
|
+
};
|
|
228
|
+
this.sendSignaling(msg).catch(err => {
|
|
229
|
+
console.error('[WebRTC] sendSignaling error:', err);
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Public method to trigger a hello broadcast.
|
|
235
|
+
* Used for testing to force peer discovery after follows are set up.
|
|
236
|
+
*/
|
|
237
|
+
broadcastHello(): void {
|
|
238
|
+
this.sendHello();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Handle incoming signaling message (from Nostr kind 25050)
|
|
243
|
+
*
|
|
244
|
+
* `peerId` is the remote endpoint identity.
|
|
245
|
+
*/
|
|
246
|
+
async handleSignalingMessage(msg: SignalingMessage, senderPubkey: string): Promise<void> {
|
|
247
|
+
this.log(`Signaling from ${senderPubkey.slice(0, 8)}:`, msg.type);
|
|
248
|
+
|
|
249
|
+
switch (msg.type) {
|
|
250
|
+
case 'hello':
|
|
251
|
+
await this.handleHello(senderPubkey);
|
|
252
|
+
break;
|
|
253
|
+
|
|
254
|
+
case 'offer':
|
|
255
|
+
if (msg.peerId === this.myPeerId.toString()) {
|
|
256
|
+
return; // Skip messages from ourselves
|
|
257
|
+
}
|
|
258
|
+
if (this.isMessageForUs(msg)) {
|
|
259
|
+
// Construct RTCSessionDescriptionInit from flat sdp field
|
|
260
|
+
await this.handleOffer(msg.peerId, senderPubkey, { type: 'offer', sdp: msg.sdp });
|
|
261
|
+
}
|
|
262
|
+
break;
|
|
263
|
+
|
|
264
|
+
case 'answer':
|
|
265
|
+
if (msg.peerId === this.myPeerId.toString()) {
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
if (this.isMessageForUs(msg)) {
|
|
269
|
+
// Construct RTCSessionDescriptionInit from flat sdp field
|
|
270
|
+
await this.handleAnswer(msg.peerId, { type: 'answer', sdp: msg.sdp });
|
|
271
|
+
}
|
|
272
|
+
break;
|
|
273
|
+
|
|
274
|
+
case 'candidate':
|
|
275
|
+
if (msg.peerId === this.myPeerId.toString()) {
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
if (this.isMessageForUs(msg)) {
|
|
279
|
+
// Construct RTCIceCandidateInit from flat fields
|
|
280
|
+
await this.handleIceCandidate(msg.peerId, {
|
|
281
|
+
candidate: msg.candidate,
|
|
282
|
+
sdpMLineIndex: msg.sdpMLineIndex,
|
|
283
|
+
sdpMid: msg.sdpMid,
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
break;
|
|
287
|
+
|
|
288
|
+
case 'candidates':
|
|
289
|
+
if (msg.peerId === this.myPeerId.toString()) {
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
if (this.isMessageForUs(msg)) {
|
|
293
|
+
for (const c of msg.candidates) {
|
|
294
|
+
await this.handleIceCandidate(msg.peerId, {
|
|
295
|
+
candidate: c.candidate,
|
|
296
|
+
sdpMLineIndex: c.sdpMLineIndex,
|
|
297
|
+
sdpMid: c.sdpMid,
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
break;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
private isMessageForUs(msg: SignalingMessage): boolean {
|
|
306
|
+
if ('targetPeerId' in msg && msg.targetPeerId) {
|
|
307
|
+
return msg.targetPeerId === this.myPeerId.toString();
|
|
308
|
+
}
|
|
309
|
+
return true;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
private async handleHello(senderPubkey: string): Promise<void> {
|
|
313
|
+
const peerId = new PeerId(senderPubkey).toString();
|
|
314
|
+
|
|
315
|
+
// Already connected?
|
|
316
|
+
if (this.peers.has(peerId)) {
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Check pool limits
|
|
321
|
+
const pool = this.classifyPeer(senderPubkey);
|
|
322
|
+
if (!this.shouldConnect(pool)) {
|
|
323
|
+
this.log(`Pool ${pool} at capacity, ignoring hello`);
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// In 'other' pool, only allow 1 connection per pubkey
|
|
328
|
+
if (pool === 'other' && this.hasOtherPoolPubkey(senderPubkey)) {
|
|
329
|
+
this.log(`Already have connection from ${senderPubkey.slice(0, 8)} in other pool`);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Tie-breaking: lower endpoint ID initiates
|
|
334
|
+
const shouldInitiate = this.myPeerId.toString() < peerId;
|
|
335
|
+
if (shouldInitiate) {
|
|
336
|
+
this.log(`Initiating connection to ${peerId.slice(0, 20)}`);
|
|
337
|
+
await this.createOutboundPeer(peerId, senderPubkey, pool);
|
|
338
|
+
} else {
|
|
339
|
+
this.log(`Waiting for offer from ${peerId.slice(0, 20)}`);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
private async handleOffer(peerId: string, pubkey: string, offer: RTCSessionDescriptionInit): Promise<void> {
|
|
344
|
+
this.log(`handleOffer from ${pubkey.slice(0, 8)}, peerId: ${peerId.slice(0, 20)}`);
|
|
345
|
+
let peer = this.peers.get(peerId);
|
|
346
|
+
if (!peer) {
|
|
347
|
+
const pool = this.classifyPeer(pubkey);
|
|
348
|
+
if (!this.shouldConnect(pool)) {
|
|
349
|
+
this.log(`Pool ${pool} at capacity, rejecting offer`);
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
if (pool === 'other' && this.hasOtherPoolPubkey(pubkey)) {
|
|
353
|
+
this.log(`Already have connection from ${pubkey.slice(0, 8)} in other pool, rejecting offer`);
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
this.log(`Creating inbound peer for ${pubkey.slice(0, 8)}`);
|
|
357
|
+
peer = this.createPeer(peerId, pubkey, pool, 'inbound');
|
|
358
|
+
} else if (peer.direction === 'outbound' && peer.state === 'connecting') {
|
|
359
|
+
const isPolite = this.myPeerId.toString() < peerId;
|
|
360
|
+
if (!isPolite) {
|
|
361
|
+
this.log(`Ignoring offer collision from ${pubkey.slice(0, 8)} as impolite peer`);
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Perfect negotiation: the polite peer abandons its local offer and
|
|
366
|
+
// switches into answerer mode for the remote offer.
|
|
367
|
+
peer.direction = 'inbound';
|
|
368
|
+
peer.answerCreated = false;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
this.log(`Setting remote description for ${peerId.slice(0, 20)}`);
|
|
372
|
+
this.sendCommand({ type: 'rtc:setRemoteDescription', peerId, sdp: offer });
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
private async handleAnswer(peerId: string, answer: RTCSessionDescriptionInit): Promise<void> {
|
|
376
|
+
const peer = this.peers.get(peerId);
|
|
377
|
+
if (!peer) {
|
|
378
|
+
this.log(`Answer for unknown peer: ${peerId}`);
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
this.sendCommand({ type: 'rtc:setRemoteDescription', peerId, sdp: answer });
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
private async handleIceCandidate(peerId: string, candidate: RTCIceCandidateInit): Promise<void> {
|
|
386
|
+
const peer = this.peers.get(peerId);
|
|
387
|
+
if (!peer) {
|
|
388
|
+
const queued = this.pendingRemoteCandidates.get(peerId) ?? [];
|
|
389
|
+
queued.push(candidate);
|
|
390
|
+
this.pendingRemoteCandidates.set(peerId, queued);
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
this.sendCommand({ type: 'rtc:addIceCandidate', peerId, candidate });
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// ============================================================================
|
|
398
|
+
// Peer Management
|
|
399
|
+
// ============================================================================
|
|
400
|
+
|
|
401
|
+
private shouldConnect(pool: PeerPool): boolean {
|
|
402
|
+
const config = this.poolConfig[pool];
|
|
403
|
+
const count = this.getPoolCount(pool);
|
|
404
|
+
return count < config.maxConnections;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
private getPoolCount(pool: PeerPool): number {
|
|
408
|
+
let count = 0;
|
|
409
|
+
for (const peer of this.peers.values()) {
|
|
410
|
+
if (peer.pool === pool && peer.state !== 'disconnected') {
|
|
411
|
+
count++;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
return count;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Check if we already have a connection from this pubkey in the 'other' pool.
|
|
419
|
+
* In the 'other' pool, we only allow 1 connection per pubkey to prevent spam.
|
|
420
|
+
*/
|
|
421
|
+
private hasOtherPoolPubkey(pubkey: string): boolean {
|
|
422
|
+
for (const peer of this.peers.values()) {
|
|
423
|
+
if (peer.pool === 'other' && peer.pubkey === pubkey && peer.state !== 'disconnected') {
|
|
424
|
+
return true;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
return false;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
private createPeer(peerId: string, pubkey: string, pool: PeerPool, direction: 'inbound' | 'outbound'): WorkerPeer {
|
|
431
|
+
const peer: WorkerPeer = {
|
|
432
|
+
peerId,
|
|
433
|
+
pubkey,
|
|
434
|
+
pool,
|
|
435
|
+
direction,
|
|
436
|
+
state: 'connecting',
|
|
437
|
+
dataChannelReady: false,
|
|
438
|
+
answerCreated: false,
|
|
439
|
+
htlConfig: generatePeerHTLConfig(),
|
|
440
|
+
pendingRequests: new Map(),
|
|
441
|
+
stats: {
|
|
442
|
+
requestsSent: 0,
|
|
443
|
+
requestsReceived: 0,
|
|
444
|
+
responsesSent: 0,
|
|
445
|
+
responsesReceived: 0,
|
|
446
|
+
bytesSent: 0,
|
|
447
|
+
bytesReceived: 0,
|
|
448
|
+
forwardedRequests: 0,
|
|
449
|
+
forwardedResolved: 0,
|
|
450
|
+
forwardedSuppressed: 0,
|
|
451
|
+
},
|
|
452
|
+
createdAt: Date.now(),
|
|
453
|
+
bufferPaused: false,
|
|
454
|
+
deferredRequests: [],
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
this.peers.set(peerId, peer);
|
|
458
|
+
this.peerSelector.addPeer(peerId);
|
|
459
|
+
this.meshRouter.registerPeer({
|
|
460
|
+
peerId,
|
|
461
|
+
canSend: () => peer.dataChannelReady,
|
|
462
|
+
getHtlConfig: () => peer.htlConfig,
|
|
463
|
+
sendRequest: (hash, htl) => this.sendRequestToPeer(peer, hash, htl),
|
|
464
|
+
sendResponse: async (hash, data) => this.sendResponse(peer, hash, data),
|
|
465
|
+
onForwardedRequest: () => {
|
|
466
|
+
peer.stats.forwardedRequests++;
|
|
467
|
+
},
|
|
468
|
+
onForwardedResolved: () => {
|
|
469
|
+
peer.stats.forwardedResolved++;
|
|
470
|
+
},
|
|
471
|
+
onForwardedSuppressed: () => {
|
|
472
|
+
peer.stats.forwardedSuppressed++;
|
|
473
|
+
},
|
|
474
|
+
});
|
|
475
|
+
this.sendCommand({ type: 'rtc:createPeer', peerId, pubkey });
|
|
476
|
+
|
|
477
|
+
return peer;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
private async createOutboundPeer(peerId: string, pubkey: string, pool: PeerPool): Promise<void> {
|
|
481
|
+
this.createPeer(peerId, pubkey, pool, 'outbound');
|
|
482
|
+
// Proxy will create peer and we'll get rtc:peerCreated, then request offer
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
private closePeer(peerId: string): void {
|
|
486
|
+
const peer = this.peers.get(peerId);
|
|
487
|
+
if (!peer) return;
|
|
488
|
+
|
|
489
|
+
// Clear pending requests
|
|
490
|
+
for (const [hashKey, pending] of peer.pendingRequests.entries()) {
|
|
491
|
+
clearTimeout(pending.timeout);
|
|
492
|
+
peer.pendingRequests.delete(hashKey);
|
|
493
|
+
this.releasePeerRequest(peer.peerId);
|
|
494
|
+
pending.resolve(null);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
peer.state = 'disconnected';
|
|
498
|
+
this.sendCommand({ type: 'rtc:closePeer', peerId });
|
|
499
|
+
this.peers.delete(peerId);
|
|
500
|
+
this.pendingRemoteCandidates.delete(peerId);
|
|
501
|
+
this.peerSelector.removePeer(peerId);
|
|
502
|
+
this.meshRouter.removePeer(peerId);
|
|
503
|
+
|
|
504
|
+
this.log(`Closed peer: ${peerId.slice(0, 20)}`);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// ============================================================================
|
|
508
|
+
// Proxy Events
|
|
509
|
+
// ============================================================================
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Handle event from main thread proxy
|
|
513
|
+
*/
|
|
514
|
+
handleProxyEvent(event: WebRTCEvent): void {
|
|
515
|
+
switch (event.type) {
|
|
516
|
+
case 'rtc:peerCreated':
|
|
517
|
+
this.onPeerCreated(event.peerId);
|
|
518
|
+
break;
|
|
519
|
+
|
|
520
|
+
case 'rtc:peerStateChange':
|
|
521
|
+
this.onPeerStateChange(event.peerId, event.state);
|
|
522
|
+
break;
|
|
523
|
+
|
|
524
|
+
case 'rtc:peerClosed':
|
|
525
|
+
this.onPeerClosed(event.peerId);
|
|
526
|
+
break;
|
|
527
|
+
|
|
528
|
+
case 'rtc:offerCreated':
|
|
529
|
+
this.onOfferCreated(event.peerId, event.sdp);
|
|
530
|
+
break;
|
|
531
|
+
|
|
532
|
+
case 'rtc:answerCreated':
|
|
533
|
+
this.onAnswerCreated(event.peerId, event.sdp);
|
|
534
|
+
break;
|
|
535
|
+
|
|
536
|
+
case 'rtc:descriptionSet':
|
|
537
|
+
this.onDescriptionSet(event.peerId, event.error);
|
|
538
|
+
break;
|
|
539
|
+
|
|
540
|
+
case 'rtc:iceCandidate':
|
|
541
|
+
this.onIceCandidate(event.peerId, event.candidate);
|
|
542
|
+
break;
|
|
543
|
+
|
|
544
|
+
case 'rtc:dataChannelOpen':
|
|
545
|
+
this.onDataChannelOpen(event.peerId);
|
|
546
|
+
break;
|
|
547
|
+
|
|
548
|
+
case 'rtc:dataChannelMessage':
|
|
549
|
+
this.onDataChannelMessage(event.peerId, event.data);
|
|
550
|
+
break;
|
|
551
|
+
|
|
552
|
+
case 'rtc:dataChannelClose':
|
|
553
|
+
this.onDataChannelClose(event.peerId);
|
|
554
|
+
break;
|
|
555
|
+
|
|
556
|
+
case 'rtc:dataChannelError':
|
|
557
|
+
this.onDataChannelError(event.peerId, event.error);
|
|
558
|
+
break;
|
|
559
|
+
|
|
560
|
+
case 'rtc:bufferHigh':
|
|
561
|
+
this.onBufferHigh(event.peerId);
|
|
562
|
+
break;
|
|
563
|
+
|
|
564
|
+
case 'rtc:bufferLow':
|
|
565
|
+
this.onBufferLow(event.peerId);
|
|
566
|
+
break;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
private onPeerCreated(peerId: string): void {
|
|
571
|
+
const peer = this.peers.get(peerId);
|
|
572
|
+
if (!peer) return;
|
|
573
|
+
|
|
574
|
+
const queuedCandidates = this.pendingRemoteCandidates.get(peerId);
|
|
575
|
+
if (queuedCandidates?.length) {
|
|
576
|
+
for (const candidate of queuedCandidates) {
|
|
577
|
+
this.sendCommand({ type: 'rtc:addIceCandidate', peerId, candidate });
|
|
578
|
+
}
|
|
579
|
+
this.pendingRemoteCandidates.delete(peerId);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// If outbound, create offer
|
|
583
|
+
if (peer.direction === 'outbound') {
|
|
584
|
+
this.sendCommand({ type: 'rtc:createOffer', peerId });
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
private onPeerStateChange(peerId: string, state: RTCPeerConnectionState): void {
|
|
589
|
+
const peer = this.peers.get(peerId);
|
|
590
|
+
if (!peer) return;
|
|
591
|
+
|
|
592
|
+
this.log(`Peer ${peerId.slice(0, 20)} state: ${state}`);
|
|
593
|
+
|
|
594
|
+
if (state === 'connected') {
|
|
595
|
+
peer.state = 'connected';
|
|
596
|
+
peer.connectedAt = Date.now();
|
|
597
|
+
} else if (state === 'failed' || state === 'closed') {
|
|
598
|
+
this.closePeer(peerId);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
private onPeerClosed(peerId: string): void {
|
|
603
|
+
this.peers.delete(peerId);
|
|
604
|
+
this.peerSelector.removePeer(peerId);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
private onOfferCreated(peerId: string, sdp: RTCSessionDescriptionInit): void {
|
|
608
|
+
const peer = this.peers.get(peerId);
|
|
609
|
+
if (!peer) return;
|
|
610
|
+
|
|
611
|
+
// Set local description
|
|
612
|
+
this.sendCommand({ type: 'rtc:setLocalDescription', peerId, sdp });
|
|
613
|
+
|
|
614
|
+
// Send offer via signaling using endpoint identities.
|
|
615
|
+
const msg: SignalingMessage = {
|
|
616
|
+
type: 'offer',
|
|
617
|
+
sdp: sdp.sdp!,
|
|
618
|
+
targetPeerId: peerId,
|
|
619
|
+
peerId: this.myPeerId.toString(),
|
|
620
|
+
};
|
|
621
|
+
this.sendSignaling(msg, peer.pubkey);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
private onAnswerCreated(peerId: string, sdp: RTCSessionDescriptionInit): void {
|
|
625
|
+
this.log(`onAnswerCreated for ${peerId.slice(0, 20)}`);
|
|
626
|
+
const peer = this.peers.get(peerId);
|
|
627
|
+
if (!peer) {
|
|
628
|
+
this.log(`onAnswerCreated: peer not found for ${peerId.slice(0, 20)}`);
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
this.sendCommand({ type: 'rtc:setLocalDescription', peerId, sdp });
|
|
633
|
+
|
|
634
|
+
this.log(`Sending answer to ${peer.pubkey.slice(0, 8)}`);
|
|
635
|
+
const msg: SignalingMessage = {
|
|
636
|
+
type: 'answer',
|
|
637
|
+
sdp: sdp.sdp!,
|
|
638
|
+
targetPeerId: peerId,
|
|
639
|
+
peerId: this.myPeerId.toString(),
|
|
640
|
+
};
|
|
641
|
+
this.sendSignaling(msg, peer.pubkey);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
private onDescriptionSet(peerId: string, error?: string): void {
|
|
645
|
+
if (error) {
|
|
646
|
+
this.log(`Description set error for ${peerId.slice(0, 20)}: ${error}`);
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
const peer = this.peers.get(peerId);
|
|
651
|
+
if (!peer) {
|
|
652
|
+
this.log(`onDescriptionSet: peer not found for ${peerId.slice(0, 20)}`);
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
this.log(`onDescriptionSet for ${peerId.slice(0, 20)}: direction=${peer.direction}, state=${peer.state}, answerCreated=${peer.answerCreated}`);
|
|
657
|
+
|
|
658
|
+
if (peer.direction === 'inbound' && peer.state === 'connecting' && !peer.answerCreated) {
|
|
659
|
+
peer.answerCreated = true;
|
|
660
|
+
this.log(`Creating answer for ${peerId.slice(0, 20)}`);
|
|
661
|
+
this.sendCommand({ type: 'rtc:createAnswer', peerId });
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
private onIceCandidate(peerId: string, candidate: RTCIceCandidateInit | null): void {
|
|
666
|
+
if (!candidate || !candidate.candidate) return;
|
|
667
|
+
|
|
668
|
+
const peer = this.peers.get(peerId);
|
|
669
|
+
if (!peer) return;
|
|
670
|
+
|
|
671
|
+
// Send candidate via signaling using endpoint identities.
|
|
672
|
+
const msg: SignalingMessage = {
|
|
673
|
+
type: 'candidate',
|
|
674
|
+
candidate: candidate.candidate,
|
|
675
|
+
sdpMLineIndex: candidate.sdpMLineIndex ?? undefined,
|
|
676
|
+
sdpMid: candidate.sdpMid ?? undefined,
|
|
677
|
+
targetPeerId: peerId,
|
|
678
|
+
peerId: this.myPeerId.toString(),
|
|
679
|
+
};
|
|
680
|
+
this.sendSignaling(msg, peer.pubkey);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
private onDataChannelOpen(peerId: string): void {
|
|
684
|
+
const peer = this.peers.get(peerId);
|
|
685
|
+
if (!peer) return;
|
|
686
|
+
|
|
687
|
+
peer.dataChannelReady = true;
|
|
688
|
+
this.log(`Data channel open: ${peerId.slice(0, 20)}`);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
private onDataChannelClose(peerId: string): void {
|
|
692
|
+
const peer = this.peers.get(peerId);
|
|
693
|
+
if (!peer) return;
|
|
694
|
+
|
|
695
|
+
peer.dataChannelReady = false;
|
|
696
|
+
this.closePeer(peerId);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
private onDataChannelError(peerId: string, error: string): void {
|
|
700
|
+
this.log(`Data channel error for ${peerId}: ${error}`);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
private onBufferHigh(peerId: string): void {
|
|
704
|
+
const peer = this.peers.get(peerId);
|
|
705
|
+
if (!peer) return;
|
|
706
|
+
|
|
707
|
+
peer.bufferPaused = true;
|
|
708
|
+
this.log(`Buffer high for ${peerId.slice(0, 20)}, pausing responses`);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
private onBufferLow(peerId: string): void {
|
|
712
|
+
const peer = this.peers.get(peerId);
|
|
713
|
+
if (!peer) return;
|
|
714
|
+
|
|
715
|
+
peer.bufferPaused = false;
|
|
716
|
+
this.log(`Buffer low for ${peerId.slice(0, 20)}, resuming responses`);
|
|
717
|
+
|
|
718
|
+
// Process deferred requests
|
|
719
|
+
this.processDeferredRequests(peer);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
private async processDeferredRequests(peer: WorkerPeer): Promise<void> {
|
|
723
|
+
while (!peer.bufferPaused && peer.deferredRequests.length > 0) {
|
|
724
|
+
const req = peer.deferredRequests.shift()!;
|
|
725
|
+
await this.processRequest(peer, req);
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
private orderedConnectedPeers(excludePeerId?: string): WorkerPeer[] {
|
|
730
|
+
const connectedAll = Array.from(this.peers.values())
|
|
731
|
+
.filter((peer) => peer.dataChannelReady);
|
|
732
|
+
if (connectedAll.length === 0) return [];
|
|
733
|
+
|
|
734
|
+
const peerIds = connectedAll.map((peer) => peer.peerId);
|
|
735
|
+
syncSelectorPeers(this.peerSelector, peerIds);
|
|
736
|
+
|
|
737
|
+
const connectedPeers = connectedAll
|
|
738
|
+
.filter((peer) => !excludePeerId || peer.peerId !== excludePeerId);
|
|
739
|
+
const selectorOrder = this.peerSelector.selectPeers();
|
|
740
|
+
const rank = new Map<string, number>(selectorOrder.map((peerId, idx) => [peerId, idx]));
|
|
741
|
+
|
|
742
|
+
connectedPeers.sort((a, b) => {
|
|
743
|
+
const leftBackedOff = this.peerSelector.isPeerBackedOff(a.peerId);
|
|
744
|
+
const rightBackedOff = this.peerSelector.isPeerBackedOff(b.peerId);
|
|
745
|
+
if (leftBackedOff !== rightBackedOff) return leftBackedOff ? 1 : -1;
|
|
746
|
+
if (a.pool === 'follows' && b.pool !== 'follows') return -1;
|
|
747
|
+
if (a.pool !== 'follows' && b.pool === 'follows') return 1;
|
|
748
|
+
const leftRank = rank.get(a.peerId) ?? Number.MAX_SAFE_INTEGER;
|
|
749
|
+
const rightRank = rank.get(b.peerId) ?? Number.MAX_SAFE_INTEGER;
|
|
750
|
+
const leftLoad = this.activePeerRequests.get(a.peerId) ?? 0;
|
|
751
|
+
const rightLoad = this.activePeerRequests.get(b.peerId) ?? 0;
|
|
752
|
+
return (leftRank + leftLoad * ACTIVE_REQUEST_RANK_PENALTY) -
|
|
753
|
+
(rightRank + rightLoad * ACTIVE_REQUEST_RANK_PENALTY);
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
return connectedPeers;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
private async peerMetadataPointerHash(): Promise<Uint8Array> {
|
|
760
|
+
return sha256(new TextEncoder().encode(PEER_METADATA_POINTER_SLOT_KEY));
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
private createInFlightRequest(peer: WorkerPeer, hash: Uint8Array, htl: number): InFlightPeerRequest {
|
|
764
|
+
const hashKey = hashToKey(hash);
|
|
765
|
+
const startedAt = Date.now();
|
|
766
|
+
this.reservePeerRequest(peer.peerId);
|
|
767
|
+
this.peerSelector.recordRequest(peer.peerId, 40);
|
|
768
|
+
|
|
769
|
+
const promise = new Promise<{ peerId: string; data: Uint8Array | null; elapsedMs: number }>((resolve) => {
|
|
770
|
+
const timeout = setTimeout(() => {
|
|
771
|
+
peer.pendingRequests.delete(hashKey);
|
|
772
|
+
this.releasePeerRequest(peer.peerId);
|
|
773
|
+
this.peerSelector.recordTimeout(peer.peerId);
|
|
774
|
+
resolve({ peerId: peer.peerId, data: null, elapsedMs: Math.max(1, Date.now() - startedAt) });
|
|
775
|
+
}, this.requestTimeout);
|
|
776
|
+
|
|
777
|
+
peer.pendingRequests.set(hashKey, {
|
|
778
|
+
hash,
|
|
779
|
+
startedAt,
|
|
780
|
+
resolve: (data: Uint8Array | null) => {
|
|
781
|
+
resolve({ peerId: peer.peerId, data, elapsedMs: Math.max(1, Date.now() - startedAt) });
|
|
782
|
+
},
|
|
783
|
+
timeout,
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
peer.stats.requestsSent++;
|
|
787
|
+
const req = createRequest(hash, htl);
|
|
788
|
+
const encoded = new Uint8Array(encodeRequest(req));
|
|
789
|
+
this.sendDataToPeer(peer, encoded);
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
return {
|
|
793
|
+
peerId: peer.peerId,
|
|
794
|
+
settled: false,
|
|
795
|
+
promise,
|
|
796
|
+
};
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
private async waitForInFlightResult(
|
|
800
|
+
inFlight: InFlightPeerRequest[],
|
|
801
|
+
waitMs: number,
|
|
802
|
+
): Promise<{ task: InFlightPeerRequest; data: Uint8Array | null; elapsedMs: number } | null> {
|
|
803
|
+
const active = inFlight.filter((task) => !task.settled);
|
|
804
|
+
if (active.length === 0 || waitMs <= 0) return null;
|
|
805
|
+
const timeout = new Promise<null>((resolve) => {
|
|
806
|
+
setTimeout(() => resolve(null), waitMs);
|
|
807
|
+
});
|
|
808
|
+
const outcome = await Promise.race([
|
|
809
|
+
timeout,
|
|
810
|
+
...active.map((task) => task.promise.then((result) => ({
|
|
811
|
+
task,
|
|
812
|
+
data: result.data,
|
|
813
|
+
elapsedMs: result.elapsedMs,
|
|
814
|
+
}))),
|
|
815
|
+
]);
|
|
816
|
+
if (!outcome) return null;
|
|
817
|
+
outcome.task.settled = true;
|
|
818
|
+
return outcome;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
private clearPendingHashFromPeers(hashKey: string, keepPeerId?: string): void {
|
|
822
|
+
for (const peer of this.peers.values()) {
|
|
823
|
+
if (keepPeerId && peer.peerId === keepPeerId) continue;
|
|
824
|
+
const pending = peer.pendingRequests.get(hashKey);
|
|
825
|
+
if (!pending) continue;
|
|
826
|
+
clearTimeout(pending.timeout);
|
|
827
|
+
peer.pendingRequests.delete(hashKey);
|
|
828
|
+
this.releasePeerRequest(peer.peerId);
|
|
829
|
+
pending.resolve(null);
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
private reservePeerRequest(peerId: string): void {
|
|
834
|
+
this.activePeerRequests.set(peerId, (this.activePeerRequests.get(peerId) ?? 0) + 1);
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
private releasePeerRequest(peerId: string): void {
|
|
838
|
+
const next = (this.activePeerRequests.get(peerId) ?? 0) - 1;
|
|
839
|
+
if (next <= 0) {
|
|
840
|
+
this.activePeerRequests.delete(peerId);
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
this.activePeerRequests.set(peerId, next);
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
/**
|
|
847
|
+
* Persist selector metadata snapshot to local store.
|
|
848
|
+
* Returns the snapshot hash.
|
|
849
|
+
*/
|
|
850
|
+
async persistPeerMetadata(): Promise<Uint8Array | null> {
|
|
851
|
+
const snapshot = this.peerSelector.exportPeerMetadataSnapshot();
|
|
852
|
+
const bytes = new TextEncoder().encode(JSON.stringify(snapshot));
|
|
853
|
+
const snapshotHash = await sha256(bytes);
|
|
854
|
+
await this.localStore.put(snapshotHash, bytes);
|
|
855
|
+
|
|
856
|
+
const pointerHash = await this.peerMetadataPointerHash();
|
|
857
|
+
await this.localStore.delete(pointerHash);
|
|
858
|
+
await this.localStore.put(pointerHash, new TextEncoder().encode(toHex(snapshotHash)));
|
|
859
|
+
return snapshotHash;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/**
|
|
863
|
+
* Load selector metadata snapshot from local store.
|
|
864
|
+
*/
|
|
865
|
+
async loadPeerMetadata(): Promise<boolean> {
|
|
866
|
+
const pointerHash = await this.peerMetadataPointerHash();
|
|
867
|
+
const pointerBytes = await this.localStore.get(pointerHash);
|
|
868
|
+
if (!pointerBytes) return false;
|
|
869
|
+
|
|
870
|
+
const pointerHex = new TextDecoder().decode(pointerBytes).trim();
|
|
871
|
+
if (pointerHex.length !== 64) return false;
|
|
872
|
+
const snapshotHash = fromHex(pointerHex);
|
|
873
|
+
if (snapshotHash.length !== 32) return false;
|
|
874
|
+
|
|
875
|
+
const snapshotBytes = await this.localStore.get(snapshotHash);
|
|
876
|
+
if (!snapshotBytes) return false;
|
|
877
|
+
|
|
878
|
+
let snapshot: unknown;
|
|
879
|
+
try {
|
|
880
|
+
snapshot = JSON.parse(new TextDecoder().decode(snapshotBytes));
|
|
881
|
+
} catch {
|
|
882
|
+
return false;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
this.peerSelector.importPeerMetadataSnapshot(snapshot as any);
|
|
886
|
+
syncSelectorPeers(this.peerSelector, Array.from(this.peers.keys()));
|
|
887
|
+
return true;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// ============================================================================
|
|
891
|
+
// Data Protocol
|
|
892
|
+
// ============================================================================
|
|
893
|
+
|
|
894
|
+
private sendDataToPeer(peer: WorkerPeer, data: Uint8Array): void {
|
|
895
|
+
peer.stats.bytesSent += data.byteLength;
|
|
896
|
+
this.sendCommand({ type: 'rtc:sendData', peerId: peer.peerId, data });
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
private async onDataChannelMessage(peerId: string, data: Uint8Array): Promise<void> {
|
|
900
|
+
const peer = this.peers.get(peerId);
|
|
901
|
+
if (!peer) return;
|
|
902
|
+
|
|
903
|
+
// Count all inbound DataChannel bytes (requests + responses + protocol overhead).
|
|
904
|
+
peer.stats.bytesReceived += data.byteLength;
|
|
905
|
+
|
|
906
|
+
const msg = parseMessage(data);
|
|
907
|
+
if (!msg) {
|
|
908
|
+
this.log(`Failed to parse message from ${peerId}`);
|
|
909
|
+
return;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
if (msg.type === MSG_TYPE_REQUEST) {
|
|
913
|
+
await this.handleRequest(peer, msg.body);
|
|
914
|
+
} else if (msg.type === MSG_TYPE_RESPONSE) {
|
|
915
|
+
await this.handleResponse(peer, msg.body);
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
private async handleRequest(peer: WorkerPeer, req: DataRequest): Promise<void> {
|
|
920
|
+
peer.stats.requestsReceived++;
|
|
921
|
+
|
|
922
|
+
// If buffer is full, defer the request for later processing
|
|
923
|
+
if (peer.bufferPaused) {
|
|
924
|
+
// Limit deferred requests to prevent memory issues
|
|
925
|
+
if (peer.deferredRequests.length < 100) {
|
|
926
|
+
peer.deferredRequests.push(req);
|
|
927
|
+
}
|
|
928
|
+
return;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
await this.processRequest(peer, req);
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
private async processRequest(peer: WorkerPeer, req: DataRequest): Promise<void> {
|
|
935
|
+
await this.meshRouter.handleRequest(peer.peerId, req);
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
private async handleResponse(peer: WorkerPeer, res: DataResponse): Promise<void> {
|
|
939
|
+
peer.stats.responsesReceived++;
|
|
940
|
+
|
|
941
|
+
const hashKey = hashToKey(res.h);
|
|
942
|
+
const pending = peer.pendingRequests.get(hashKey);
|
|
943
|
+
|
|
944
|
+
if (!pending) {
|
|
945
|
+
const hasRequesters = this.meshRouter.hasInFlight(hashKey);
|
|
946
|
+
// Late response: cache if we requested this hash recently
|
|
947
|
+
const requestedAt = this.recentRequests.get(hashKey);
|
|
948
|
+
if (!requestedAt && !hasRequesters) return;
|
|
949
|
+
if (requestedAt && Date.now() - requestedAt > 60000) {
|
|
950
|
+
this.recentRequests.delete(hashKey);
|
|
951
|
+
if (!hasRequesters) return;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
const valid = await verifyHash(res.d, res.h);
|
|
955
|
+
if (valid) {
|
|
956
|
+
await this.localStore.put(res.h, res.d);
|
|
957
|
+
if (requestedAt) {
|
|
958
|
+
this.recentRequests.delete(hashKey);
|
|
959
|
+
}
|
|
960
|
+
if (hasRequesters) {
|
|
961
|
+
await this.meshRouter.resolve(res.h, res.d);
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
return;
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
clearTimeout(pending.timeout);
|
|
968
|
+
peer.pendingRequests.delete(hashKey);
|
|
969
|
+
this.releasePeerRequest(peer.peerId);
|
|
970
|
+
|
|
971
|
+
// Verify hash
|
|
972
|
+
const valid = await verifyHash(res.d, res.h);
|
|
973
|
+
const elapsedMs = pending.startedAt ? Math.max(1, Date.now() - pending.startedAt) : this.requestTimeout;
|
|
974
|
+
if (valid) {
|
|
975
|
+
// Store locally
|
|
976
|
+
await this.localStore.put(res.h, res.d);
|
|
977
|
+
this.peerSelector.recordSuccess(peer.peerId, elapsedMs, res.d.length);
|
|
978
|
+
pending.resolve(res.d);
|
|
979
|
+
|
|
980
|
+
await this.meshRouter.resolve(res.h, res.d);
|
|
981
|
+
} else {
|
|
982
|
+
this.log(`Hash mismatch from ${peer.peerId}`);
|
|
983
|
+
this.peerSelector.recordFailure(peer.peerId);
|
|
984
|
+
pending.resolve(null);
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
private async sendResponse(peer: WorkerPeer, hash: Uint8Array, data: Uint8Array): Promise<void> {
|
|
989
|
+
if (!peer.dataChannelReady) return;
|
|
990
|
+
|
|
991
|
+
peer.stats.responsesSent++;
|
|
992
|
+
|
|
993
|
+
// Fragment if needed
|
|
994
|
+
if (data.length > FRAGMENT_SIZE) {
|
|
995
|
+
const totalFragments = Math.ceil(data.length / FRAGMENT_SIZE);
|
|
996
|
+
for (let i = 0; i < totalFragments; i++) {
|
|
997
|
+
const start = i * FRAGMENT_SIZE;
|
|
998
|
+
const end = Math.min(start + FRAGMENT_SIZE, data.length);
|
|
999
|
+
const fragment = data.slice(start, end);
|
|
1000
|
+
const res = createFragmentResponse(hash, fragment, i, totalFragments);
|
|
1001
|
+
const encoded = new Uint8Array(encodeResponse(res));
|
|
1002
|
+
this.sendDataToPeer(peer, encoded);
|
|
1003
|
+
}
|
|
1004
|
+
} else {
|
|
1005
|
+
const res = createResponse(hash, data);
|
|
1006
|
+
const encoded = new Uint8Array(encodeResponse(res));
|
|
1007
|
+
this.sendDataToPeer(peer, encoded);
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
private sendRequestToPeer(peer: WorkerPeer, hash: Uint8Array, htl: number): boolean {
|
|
1012
|
+
if (!peer.dataChannelReady) {
|
|
1013
|
+
return false;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
const encoded = encodeForwardRequest(hash, htl);
|
|
1017
|
+
this.sendDataToPeer(peer, encoded);
|
|
1018
|
+
return true;
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
private async queryPeersWithDispatch(hash: Uint8Array, options: MeshPeerQueryOptions): Promise<Uint8Array | null> {
|
|
1022
|
+
const orderedPeers = this.orderedConnectedPeers(options.excludePeerId);
|
|
1023
|
+
if (orderedPeers.length === 0) return null;
|
|
1024
|
+
|
|
1025
|
+
const dispatch = normalizeDispatchConfig(this.routing.dispatch, orderedPeers.length);
|
|
1026
|
+
const wavePlan = buildHedgedWavePlan(orderedPeers.length, dispatch);
|
|
1027
|
+
if (wavePlan.length === 0) return null;
|
|
1028
|
+
|
|
1029
|
+
const deadline = Date.now() + this.requestTimeout;
|
|
1030
|
+
const inFlight: InFlightPeerRequest[] = [];
|
|
1031
|
+
let nextPeerIdx = 0;
|
|
1032
|
+
|
|
1033
|
+
for (let waveIdx = 0; waveIdx < wavePlan.length; waveIdx++) {
|
|
1034
|
+
const waveSize = wavePlan[waveIdx];
|
|
1035
|
+
const from = nextPeerIdx;
|
|
1036
|
+
const to = Math.min(from + waveSize, orderedPeers.length);
|
|
1037
|
+
nextPeerIdx = to;
|
|
1038
|
+
|
|
1039
|
+
for (const peer of orderedPeers.slice(from, to)) {
|
|
1040
|
+
inFlight.push(this.createInFlightRequest(peer, hash, options.htl));
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
const isLastWave = waveIdx === wavePlan.length - 1 || nextPeerIdx >= orderedPeers.length;
|
|
1044
|
+
const windowEnd = isLastWave
|
|
1045
|
+
? deadline
|
|
1046
|
+
: Math.min(deadline, Date.now() + dispatch.hedgeIntervalMs);
|
|
1047
|
+
|
|
1048
|
+
while (Date.now() < windowEnd) {
|
|
1049
|
+
const remaining = windowEnd - Date.now();
|
|
1050
|
+
const result = await this.waitForInFlightResult(inFlight, remaining);
|
|
1051
|
+
if (!result) break;
|
|
1052
|
+
if (!result.data) continue;
|
|
1053
|
+
|
|
1054
|
+
this.clearPendingHashFromPeers(hashToKey(hash), result.task.peerId);
|
|
1055
|
+
return result.data;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
if (Date.now() >= deadline) break;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
this.clearPendingHashFromPeers(hashToKey(hash));
|
|
1062
|
+
return null;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
// ============================================================================
|
|
1066
|
+
// Public API
|
|
1067
|
+
// ============================================================================
|
|
1068
|
+
|
|
1069
|
+
/**
|
|
1070
|
+
* Request data from peers
|
|
1071
|
+
*/
|
|
1072
|
+
async get(hash: Uint8Array): Promise<Uint8Array | null> {
|
|
1073
|
+
const hashKey = hashToKey(hash);
|
|
1074
|
+
this.recentRequests.set(hashKey, Date.now());
|
|
1075
|
+
return this.queryPeersWithDispatch(hash, { htl: MAX_HTL });
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
/**
|
|
1079
|
+
* Get peer stats for UI
|
|
1080
|
+
*/
|
|
1081
|
+
getPeerStats(): Array<{
|
|
1082
|
+
peerId: string;
|
|
1083
|
+
pubkey: string;
|
|
1084
|
+
connected: boolean;
|
|
1085
|
+
pool: PeerPool;
|
|
1086
|
+
requestsSent: number;
|
|
1087
|
+
requestsReceived: number;
|
|
1088
|
+
responsesSent: number;
|
|
1089
|
+
responsesReceived: number;
|
|
1090
|
+
bytesSent: number;
|
|
1091
|
+
bytesReceived: number;
|
|
1092
|
+
forwardedRequests: number;
|
|
1093
|
+
forwardedResolved: number;
|
|
1094
|
+
forwardedSuppressed: number;
|
|
1095
|
+
}> {
|
|
1096
|
+
return Array.from(this.peers.values()).map(peer => ({
|
|
1097
|
+
peerId: peer.peerId,
|
|
1098
|
+
pubkey: peer.pubkey,
|
|
1099
|
+
connected: peer.state === 'connected' && peer.dataChannelReady,
|
|
1100
|
+
pool: peer.pool,
|
|
1101
|
+
requestsSent: peer.stats.requestsSent,
|
|
1102
|
+
requestsReceived: peer.stats.requestsReceived,
|
|
1103
|
+
responsesSent: peer.stats.responsesSent,
|
|
1104
|
+
responsesReceived: peer.stats.responsesReceived,
|
|
1105
|
+
bytesSent: peer.stats.bytesSent,
|
|
1106
|
+
bytesReceived: peer.stats.bytesReceived,
|
|
1107
|
+
forwardedRequests: peer.stats.forwardedRequests,
|
|
1108
|
+
forwardedResolved: peer.stats.forwardedResolved,
|
|
1109
|
+
forwardedSuppressed: peer.stats.forwardedSuppressed,
|
|
1110
|
+
}));
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
/**
|
|
1114
|
+
* Get connected peer count
|
|
1115
|
+
*/
|
|
1116
|
+
getConnectedCount(): number {
|
|
1117
|
+
let count = 0;
|
|
1118
|
+
for (const peer of this.peers.values()) {
|
|
1119
|
+
if (peer.state === 'connected' && peer.dataChannelReady) {
|
|
1120
|
+
count++;
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
return count;
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
/**
|
|
1127
|
+
* Set pool configuration
|
|
1128
|
+
*/
|
|
1129
|
+
setPoolConfig(config: { follows: { max: number; satisfied: number }; other: { max: number; satisfied: number } }): void {
|
|
1130
|
+
this.poolConfig = {
|
|
1131
|
+
follows: { maxConnections: config.follows.max, satisfiedConnections: config.follows.satisfied },
|
|
1132
|
+
other: { maxConnections: config.other.max, satisfiedConnections: config.other.satisfied },
|
|
1133
|
+
};
|
|
1134
|
+
this.log('Pool config updated:', this.poolConfig);
|
|
1135
|
+
|
|
1136
|
+
// Re-broadcast hello to trigger peer discovery with new limits
|
|
1137
|
+
this.sendHello();
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
setForwardRateLimit(config?: { maxForwardsPerPeerWindow?: number; windowMs?: number }): void {
|
|
1141
|
+
this.meshRouter.setForwardRateLimit(config);
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
/**
|
|
1145
|
+
* Update identity (pubkey) and restart signaling if already running.
|
|
1146
|
+
* This keeps peerId consistent with the current account.
|
|
1147
|
+
*/
|
|
1148
|
+
setIdentity(pubkey: string): void {
|
|
1149
|
+
if (this.myPeerId.pubkey === pubkey) return;
|
|
1150
|
+
|
|
1151
|
+
const wasStarted = !!this.helloInterval;
|
|
1152
|
+
this.stop();
|
|
1153
|
+
this.myPeerId = new PeerId(pubkey);
|
|
1154
|
+
if (wasStarted) {
|
|
1155
|
+
this.start();
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
// ============================================================================
|
|
1160
|
+
// Helpers
|
|
1161
|
+
// ============================================================================
|
|
1162
|
+
|
|
1163
|
+
private log(...args: unknown[]): void {
|
|
1164
|
+
if (this.debug) {
|
|
1165
|
+
console.log('[WebRTC]', ...args);
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
}
|