@yz-social/kdht 0.1.15 → 0.1.17
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/contacts/contact.js +21 -22
- package/contacts/nwebrtc.js +615 -0
- package/contacts/webrtc.js +12 -8
- package/index.js +10 -6
- package/node.html +18 -20
- package/nodes/#nodeUtilities.js# +111 -0
- package/nodes/node.js +18 -16
- package/nodes/nodeProbe.js +8 -4
- package/nodes/nodePubSub.js +89 -0
- package/nodes/nodeRefresh.js +1 -0
- package/nodes/nodeStorage.js +40 -5
- package/nodes/nodeUtilities.js +36 -13
- package/nodes/storageBag.js +134 -0
- package/package.json +3 -3
- package/scripts/bots.js +0 -1
- package/spec/dhtImplementation.js +6 -1
- package/spec/dhtInternalsSpec.js +1 -1
- package/spec/dhtStorageBagSpec.js +127 -0
- package/spec/testWebrtc.js +2 -2
package/contacts/contact.js
CHANGED
|
@@ -96,7 +96,10 @@ export class Contact {
|
|
|
96
96
|
this.host.ilog('disconnecting from network');
|
|
97
97
|
if (!this.host.isStopped()) {
|
|
98
98
|
if (this.host.storage.size) this.host.ilog('Copying', this.host.storage.size, 'stored values');
|
|
99
|
-
await Promise.all(this.host.storage.entries().map(([key, value]) =>
|
|
99
|
+
await Promise.all(this.host.storage.entries().map(([key, value]) => {
|
|
100
|
+
Node.assert(value !== undefined, 'disconnect/copy of undefined stored value');
|
|
101
|
+
return this.storeValue(key, Node.transportValue(value));
|
|
102
|
+
}));
|
|
100
103
|
}
|
|
101
104
|
this.host.stopRefresh();
|
|
102
105
|
for (const contact of this.host.connections) {
|
|
@@ -148,7 +151,7 @@ export class Contact {
|
|
|
148
151
|
if (!sender.isRunning) return null; // sender closed before call.
|
|
149
152
|
if (sender.key === this.key) { // self-send short-circuit
|
|
150
153
|
const result = this.host.receiveRPC(method, sender, ...rest);
|
|
151
|
-
if (!result) this.host.flog('no local result');
|
|
154
|
+
if (!result) this.host.flog('no local result for method', method, ...rest);
|
|
152
155
|
return result;
|
|
153
156
|
}
|
|
154
157
|
if (!await this.connect()) return null;
|
|
@@ -163,33 +166,29 @@ export class Contact {
|
|
|
163
166
|
if (!sender.isRunning) return null; // Sender closed after call.
|
|
164
167
|
return result;
|
|
165
168
|
})
|
|
166
|
-
.finally(() =>
|
|
169
|
+
.finally(() => this.host.noteStatistic(start, 'rpc'));
|
|
167
170
|
}
|
|
168
171
|
getResponsePromise(messageTag) { // Get a promise that will resolve when a response comes in as messageTag.
|
|
169
172
|
return new Promise(resolve => this.host.messageResolvers.set(messageTag, resolve));
|
|
170
173
|
}
|
|
171
|
-
async receiveRPC(messageTag, ...data) { //
|
|
174
|
+
async receiveRPC(messageTag, methodOrResult, ...data) { // Handle a message from another node.
|
|
175
|
+
if (!this.host.isRunning) return this.disconnectTransport();
|
|
176
|
+
// Messages handled directly by the connection, rather than the node.
|
|
177
|
+
if (methodOrResult === 'close') return this.close();
|
|
178
|
+
if (methodOrResult === 'bye') return this.bye();
|
|
179
|
+
|
|
180
|
+
// See if this is a response to something we sent and are waiting for.
|
|
172
181
|
const responder = this.host.messageResolvers.get(messageTag);
|
|
173
|
-
if (responder) {
|
|
174
|
-
let [result] = data;
|
|
182
|
+
if (responder) {
|
|
175
183
|
this.host.messageResolvers.delete(messageTag);
|
|
176
|
-
|
|
177
|
-
responder(result);
|
|
178
|
-
} else if (!this.host.isRunning) {
|
|
179
|
-
this.disconnectTransport();
|
|
180
|
-
// Kludge: In testing, it is possible for a disconnecting node to send a request that will respond to a new session of the same id.
|
|
181
|
-
} else if (typeof(data[0]) !== 'string' || data[0] === 'pong') {
|
|
182
|
-
; //this.host.flog(this.counter, 'received result without responder', messageTag, data, 'at', this.sname);
|
|
183
|
-
} else if (data[0] === 'close') {
|
|
184
|
-
this.close();
|
|
185
|
-
} else if (data[0] === 'bye') {
|
|
186
|
-
this.bye();
|
|
187
|
-
} else { // An incoming request.
|
|
188
|
-
const deserialized = await this.deserializeRequest(...data);
|
|
189
|
-
let response = await this.host.receiveRPC(...deserialized);
|
|
190
|
-
response = this.serializeResponse(response);
|
|
191
|
-
await this.send([messageTag, response]);
|
|
184
|
+
return responder(await this.deserializeResponse(methodOrResult));
|
|
192
185
|
}
|
|
186
|
+
|
|
187
|
+
// An incoming request.
|
|
188
|
+
const deserialized = await this.deserializeRequest(methodOrResult, ...data);
|
|
189
|
+
let response = await this.host.receiveRPC(...deserialized);
|
|
190
|
+
response = this.serializeResponse(response);
|
|
191
|
+
return await this.send([messageTag, response]);
|
|
193
192
|
}
|
|
194
193
|
// Sponsorship
|
|
195
194
|
_sponsors = new Map(); // maps key => contact
|
|
@@ -0,0 +1,615 @@
|
|
|
1
|
+
const { BigInt } = globalThis; // For linters.
|
|
2
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
3
|
+
import { Node } from '../nodes/node.js';
|
|
4
|
+
import { Helper } from '../nodes/helper.js';
|
|
5
|
+
import { Contact } from './contact.js';
|
|
6
|
+
import { WebRTC } from '@yz-social/webrtc';
|
|
7
|
+
|
|
8
|
+
// Connection state classification for safe cleanup
|
|
9
|
+
// Transitional states are unsafe to close - cleanup should wait for stable state
|
|
10
|
+
// Stable states are safe to close - cleanup can proceed immediately
|
|
11
|
+
export const ConnectionStates = {
|
|
12
|
+
// Transitional states - unsafe to close
|
|
13
|
+
TRANSITIONAL: ['new', 'connecting', 'disconnected'],
|
|
14
|
+
|
|
15
|
+
// Stable states - safe to close
|
|
16
|
+
STABLE: ['connected', 'failed', 'closed'],
|
|
17
|
+
|
|
18
|
+
isTransitional(state) {
|
|
19
|
+
return this.TRANSITIONAL.includes(state);
|
|
20
|
+
},
|
|
21
|
+
|
|
22
|
+
isStable(state) {
|
|
23
|
+
return this.STABLE.includes(state);
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// Connection event tracking for stability diagnostics
|
|
28
|
+
export class ConnectionTracker {
|
|
29
|
+
static events = [];
|
|
30
|
+
static maxEvents = 1000;
|
|
31
|
+
static enabled = false;
|
|
32
|
+
|
|
33
|
+
// Resource monitoring properties
|
|
34
|
+
static activeConnections = 0;
|
|
35
|
+
static cleanupSuccesses = 0;
|
|
36
|
+
static cleanupFailures = 0;
|
|
37
|
+
|
|
38
|
+
static enable() { this.enabled = true; }
|
|
39
|
+
static disable() { this.enabled = false; }
|
|
40
|
+
static clear() {
|
|
41
|
+
this.events = [];
|
|
42
|
+
this.activeConnections = 0;
|
|
43
|
+
this.cleanupSuccesses = 0;
|
|
44
|
+
this.cleanupFailures = 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Track when a new WebRTC connection is created
|
|
48
|
+
static trackConnectionCreated() {
|
|
49
|
+
this.activeConnections++;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Track when a connection is closed/cleaned up
|
|
53
|
+
static trackConnectionClosed(success, reason) {
|
|
54
|
+
this.activeConnections = Math.max(0, this.activeConnections - 1);
|
|
55
|
+
if (success) {
|
|
56
|
+
this.cleanupSuccesses++;
|
|
57
|
+
} else {
|
|
58
|
+
this.cleanupFailures++;
|
|
59
|
+
}
|
|
60
|
+
this.log('cleanup_completed', { success, reason });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Get current resource statistics
|
|
64
|
+
static getResourceStats() {
|
|
65
|
+
return {
|
|
66
|
+
activeConnections: this.activeConnections,
|
|
67
|
+
cleanupSuccesses: this.cleanupSuccesses,
|
|
68
|
+
cleanupFailures: this.cleanupFailures,
|
|
69
|
+
totalCleanups: this.cleanupSuccesses + this.cleanupFailures
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
static log(type, details) {
|
|
74
|
+
if (!this.enabled) return;
|
|
75
|
+
const event = {
|
|
76
|
+
timestamp: Date.now(),
|
|
77
|
+
type,
|
|
78
|
+
...details
|
|
79
|
+
};
|
|
80
|
+
this.events.push(event);
|
|
81
|
+
if (this.events.length > this.maxEvents) {
|
|
82
|
+
this.events.shift();
|
|
83
|
+
}
|
|
84
|
+
// Also log to console for real-time debugging
|
|
85
|
+
if (typeof console !== 'undefined') {
|
|
86
|
+
console.log(`[ConnectionTracker] ${type}:`, details);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
static getStats() {
|
|
91
|
+
const stats = {
|
|
92
|
+
totalEvents: this.events.length,
|
|
93
|
+
byType: {},
|
|
94
|
+
connectionAttempts: 0,
|
|
95
|
+
connectionSuccesses: 0,
|
|
96
|
+
connectionFailures: 0,
|
|
97
|
+
disconnects: 0,
|
|
98
|
+
timeouts: 0,
|
|
99
|
+
errors: []
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
for (const event of this.events) {
|
|
103
|
+
stats.byType[event.type] = (stats.byType[event.type] || 0) + 1;
|
|
104
|
+
|
|
105
|
+
switch (event.type) {
|
|
106
|
+
case 'connection_attempt': stats.connectionAttempts++; break;
|
|
107
|
+
case 'connection_success': stats.connectionSuccesses++; break;
|
|
108
|
+
case 'connection_failure': stats.connectionFailures++; break;
|
|
109
|
+
case 'connection_timeout': stats.timeouts++; break;
|
|
110
|
+
case 'disconnect': stats.disconnects++; break;
|
|
111
|
+
case 'error': stats.errors.push(event); break;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
stats.successRate = stats.connectionAttempts > 0
|
|
116
|
+
? (stats.connectionSuccesses / stats.connectionAttempts * 100).toFixed(1) + '%'
|
|
117
|
+
: 'N/A';
|
|
118
|
+
|
|
119
|
+
return stats;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
static getRecentEvents(count = 50) {
|
|
123
|
+
return this.events.slice(-count);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export class WebContact extends Contact { // Our wrapper for the means of contacting a remote node.
|
|
128
|
+
// Can this set all be done more simply?
|
|
129
|
+
get name() { return this.node.name; } // Key of remote node as a string (e.g., as a guid).
|
|
130
|
+
get key() { return this.node.key; } // Key of remote node as a BigInt.
|
|
131
|
+
get isServerNode() { return this.node.isServerNode; } // It it reachable through a server.
|
|
132
|
+
|
|
133
|
+
// Listener tracking for proper cleanup (Requirements 2.2, 2.3)
|
|
134
|
+
_eventListeners = new Map(); // Map<target, Map<event, handler[]>>
|
|
135
|
+
_cleanupInProgress = false; // Prevent concurrent cleanup
|
|
136
|
+
|
|
137
|
+
// Register a listener and track it for later removal
|
|
138
|
+
registerListener(target, event, handler) {
|
|
139
|
+
if (!target) return;
|
|
140
|
+
|
|
141
|
+
// Get or create the event map for this target
|
|
142
|
+
if (!this._eventListeners.has(target)) {
|
|
143
|
+
this._eventListeners.set(target, new Map());
|
|
144
|
+
}
|
|
145
|
+
const eventMap = this._eventListeners.get(target);
|
|
146
|
+
|
|
147
|
+
// Get or create the handler array for this event
|
|
148
|
+
if (!eventMap.has(event)) {
|
|
149
|
+
eventMap.set(event, []);
|
|
150
|
+
}
|
|
151
|
+
eventMap.get(event).push(handler);
|
|
152
|
+
|
|
153
|
+
// Actually add the listener
|
|
154
|
+
target.addEventListener(event, handler);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Remove all tracked listeners
|
|
158
|
+
removeAllListeners() {
|
|
159
|
+
for (const [target, eventMap] of this._eventListeners) {
|
|
160
|
+
for (const [event, handlers] of eventMap) {
|
|
161
|
+
for (const handler of handlers) {
|
|
162
|
+
try {
|
|
163
|
+
target.removeEventListener(event, handler);
|
|
164
|
+
} catch (e) {
|
|
165
|
+
// Target may already be destroyed, ignore
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
this._eventListeners.clear();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Wait for connection to reach a stable state before cleanup (Requirements 1.1, 1.3)
|
|
174
|
+
async waitForStableState(maxWaitMs = 5000) {
|
|
175
|
+
const start = Date.now();
|
|
176
|
+
const state = this.webrtc?.pc?.connectionState;
|
|
177
|
+
|
|
178
|
+
// If no webrtc or already stable, return immediately
|
|
179
|
+
if (!state || ConnectionStates.isStable(state)) {
|
|
180
|
+
return { waited: false, forced: false };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Poll until stable or timeout
|
|
184
|
+
while (Date.now() - start < maxWaitMs) {
|
|
185
|
+
const currentState = this.webrtc?.pc?.connectionState;
|
|
186
|
+
if (!currentState || ConnectionStates.isStable(currentState)) {
|
|
187
|
+
return { waited: true, forced: false };
|
|
188
|
+
}
|
|
189
|
+
await Node.delay(100);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Timeout exceeded - log warning and force cleanup
|
|
193
|
+
ConnectionTracker.log('cleanup_forced', {
|
|
194
|
+
from: this.host?.contact?.sname,
|
|
195
|
+
to: this.sname,
|
|
196
|
+
state: this.webrtc?.pc?.connectionState,
|
|
197
|
+
waitedMs: maxWaitMs
|
|
198
|
+
});
|
|
199
|
+
return { waited: true, forced: true };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Execute cleanup in correct order (Requirements 2.1, 2.4, 2.5, 3.1, 3.4)
|
|
203
|
+
performCleanup(reason) {
|
|
204
|
+
let success = true;
|
|
205
|
+
const connectionState = this.webrtc?.pc?.connectionState;
|
|
206
|
+
|
|
207
|
+
// Step 1: Stop all media tracks
|
|
208
|
+
try {
|
|
209
|
+
this.webrtc?.pc?.getSenders?.()?.forEach(sender => {
|
|
210
|
+
try { sender.track?.stop(); } catch (e) { /* ignore */ }
|
|
211
|
+
});
|
|
212
|
+
} catch (e) {
|
|
213
|
+
success = false;
|
|
214
|
+
ConnectionTracker.log('cleanup_error', {
|
|
215
|
+
step: 'stop_tracks',
|
|
216
|
+
error: e.message,
|
|
217
|
+
from: this.host?.contact?.sname,
|
|
218
|
+
to: this.sname
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Step 2: Remove all event listeners
|
|
223
|
+
try {
|
|
224
|
+
this.removeAllListeners();
|
|
225
|
+
} catch (e) {
|
|
226
|
+
success = false;
|
|
227
|
+
ConnectionTracker.log('cleanup_error', {
|
|
228
|
+
step: 'remove_listeners',
|
|
229
|
+
error: e.message,
|
|
230
|
+
from: this.host?.contact?.sname,
|
|
231
|
+
to: this.sname
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Step 3: Close data channel
|
|
236
|
+
try {
|
|
237
|
+
if (this.unsafeData) {
|
|
238
|
+
this.unsafeData.close?.();
|
|
239
|
+
}
|
|
240
|
+
} catch (e) {
|
|
241
|
+
success = false;
|
|
242
|
+
ConnectionTracker.log('cleanup_error', {
|
|
243
|
+
step: 'close_channel',
|
|
244
|
+
error: e.message,
|
|
245
|
+
from: this.host?.contact?.sname,
|
|
246
|
+
to: this.sname
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Step 4: Close peer connection
|
|
251
|
+
try {
|
|
252
|
+
this.webrtc?.close?.();
|
|
253
|
+
} catch (e) {
|
|
254
|
+
success = false;
|
|
255
|
+
ConnectionTracker.log('cleanup_error', {
|
|
256
|
+
step: 'close_connection',
|
|
257
|
+
error: e.message,
|
|
258
|
+
from: this.host?.contact?.sname,
|
|
259
|
+
to: this.sname
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Step 5: Nullify references (always do this regardless of errors)
|
|
264
|
+
this.webrtc = null;
|
|
265
|
+
this.connection = null;
|
|
266
|
+
this.unsafeData = null;
|
|
267
|
+
|
|
268
|
+
// Log cleanup result to ConnectionTracker
|
|
269
|
+
ConnectionTracker.trackConnectionClosed(success, reason);
|
|
270
|
+
|
|
271
|
+
return success;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// State-aware cleanup entry point (Requirements 1.1, 1.2)
|
|
275
|
+
async safeCleanup(reason) {
|
|
276
|
+
// Prevent concurrent cleanup
|
|
277
|
+
if (this._cleanupInProgress) return;
|
|
278
|
+
this._cleanupInProgress = true;
|
|
279
|
+
|
|
280
|
+
try {
|
|
281
|
+
// Wait for stable state if needed
|
|
282
|
+
await this.waitForStableState();
|
|
283
|
+
|
|
284
|
+
// Perform cleanup in correct order
|
|
285
|
+
this.performCleanup(reason);
|
|
286
|
+
} finally {
|
|
287
|
+
this._cleanupInProgress = false;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
checkResponse(response) { // Return a fetch response, or throw error if response is not a 200 series.
|
|
292
|
+
if (response?.ok) return true;
|
|
293
|
+
this.host.flog(`*** Unable to reach portal ${response?.url || this.sname}, ${response?.status || 'failed fetch'}: ${response?.statusText || 'Unknown reason'}. ***`);
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
// connection:close is far more robust against pooling issues common to some implementations (e.g., NodeJS).
|
|
297
|
+
// https://github.com/nodejs/undici/issues/3492
|
|
298
|
+
async fetchBootstrap(baseURL, label = 'random') { // Promise to ask portal (over http(s)) to convert a portal
|
|
299
|
+
// worker index or the string 'random' to an available sname to which we can connect().
|
|
300
|
+
const url = `${baseURL}/name/${label}`;
|
|
301
|
+
const response = await fetch(url, {headers: { 'Connection': 'close' } }).catch(e => this.host.flog(url, e));
|
|
302
|
+
if (!this.checkResponse(response)) { // The portal webserver is not available. Stop trying to reach this node.
|
|
303
|
+
// TODO: maintain a well-known list of portal servers to try, but even then, do not try to reach nodes that are on an unreachable server.
|
|
304
|
+
this.host.removeContact(this);
|
|
305
|
+
return '';
|
|
306
|
+
}
|
|
307
|
+
try {
|
|
308
|
+
const result = await response.json();
|
|
309
|
+
if (!result) {
|
|
310
|
+
this.host.flog(`*** Empty response from ${url} - server may not be ready. ***`);
|
|
311
|
+
return '';
|
|
312
|
+
}
|
|
313
|
+
return result;
|
|
314
|
+
} catch (e) {
|
|
315
|
+
this.host.flog(`*** Failed to parse response from ${url}: ${e.message} ***`);
|
|
316
|
+
return '';
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
async fetchSignals(url, signalsToSend, retryCount = 0) {
|
|
320
|
+
const maxRetries = 3;
|
|
321
|
+
const response = await fetch(url, {
|
|
322
|
+
method: 'POST',
|
|
323
|
+
headers: { 'Content-Type': 'application/json', 'Connection': 'close' },
|
|
324
|
+
body: JSON.stringify(signalsToSend)
|
|
325
|
+
}).catch(e => this.host.flog(e));
|
|
326
|
+
|
|
327
|
+
if (!this.checkResponse(response)) {
|
|
328
|
+
// Don't retry on client errors (4xx) - the request is malformed
|
|
329
|
+
if (response?.status >= 400 && response?.status < 500) {
|
|
330
|
+
this.host.flog(`*** Client error ${response.status} for ${url} - not retrying ***`);
|
|
331
|
+
return [];
|
|
332
|
+
}
|
|
333
|
+
// Retry on server errors with limit
|
|
334
|
+
if (retryCount < maxRetries) {
|
|
335
|
+
await new Promise(r => setTimeout(r, 1000 * (retryCount + 1))); // Exponential backoff
|
|
336
|
+
return this.fetchSignals(url, signalsToSend, retryCount + 1);
|
|
337
|
+
}
|
|
338
|
+
this.host.flog(`*** Max retries (${maxRetries}) exceeded for ${url} ***`);
|
|
339
|
+
return [];
|
|
340
|
+
}
|
|
341
|
+
return this.checkSignals(await response?.json());
|
|
342
|
+
}
|
|
343
|
+
async signals(senderSname, ...signals) { // Accept directed WebRTC signals from a sender sname, creating if necessary the
|
|
344
|
+
// new contact on host to receive them, and promising a response.
|
|
345
|
+
//this.host.flog('contact signals', senderSname, signals);
|
|
346
|
+
let contact = await this.ensureRemoteContact(senderSname);
|
|
347
|
+
|
|
348
|
+
if (contact.webrtc?.pc) return await contact.webrtc.respond(signals);
|
|
349
|
+
|
|
350
|
+
this.host.noteContactForTransport(contact);
|
|
351
|
+
contact.createWebRTC(false);
|
|
352
|
+
|
|
353
|
+
// Check that webrtc was created successfully before responding
|
|
354
|
+
if (!contact.webrtc) {
|
|
355
|
+
this.host.flog('Failed to create WebRTC for signals from', senderSname);
|
|
356
|
+
return [];
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return await contact.webrtc.respond(signals);
|
|
360
|
+
}
|
|
361
|
+
get webrtcLabel() {
|
|
362
|
+
return `@${this.host.contact.sname} ==> ${this.sname}`;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
createWebRTC(initiate = false, timeoutMS = this.host.timeoutMS || 30e3) { // Ensure we are connected, if possible.
|
|
366
|
+
// Sets up contact to have properties:
|
|
367
|
+
// - connection - a promise for an open webrtc data channel:
|
|
368
|
+
// this.send(string) puts data on the channel
|
|
369
|
+
// incomming messages are dispatched to receiveWebRTC(string)
|
|
370
|
+
// - closed - resolves when webrtc closes.
|
|
371
|
+
// - webrtc - an instance of WebRTC (which may be used for webrtc.respond()
|
|
372
|
+
//
|
|
373
|
+
// If timeoutMS is non-zero and a connection is not established within that time, connection and closed resolve to null.
|
|
374
|
+
//
|
|
375
|
+
// This is synchronous: all side-effects (assignments to this) happen immediately.
|
|
376
|
+
const start = Date.now();
|
|
377
|
+
const { host, node, isServerNode, bootstrapHost } = this;
|
|
378
|
+
this.host.log('starting connection', this.sname, this.connection ? 'exists!!!' : 'fresh', this.counter);
|
|
379
|
+
|
|
380
|
+
// Track connection attempt
|
|
381
|
+
ConnectionTracker.log('connection_attempt', {
|
|
382
|
+
from: host.contact?.sname,
|
|
383
|
+
to: this.sname,
|
|
384
|
+
initiate,
|
|
385
|
+
counter: this.counter,
|
|
386
|
+
existingConnection: !!this.connection
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
let {promise, resolve} = Promise.withResolvers();
|
|
390
|
+
this.closed = promise;
|
|
391
|
+
|
|
392
|
+
// Track connection creation (Requirement 5.1)
|
|
393
|
+
ConnectionTracker.trackConnectionCreated();
|
|
394
|
+
|
|
395
|
+
const webrtc = this.webrtc = new WebRTC({name: this.webrtcLabel,
|
|
396
|
+
debug: host.debug,
|
|
397
|
+
configuration: {iceServers: [
|
|
398
|
+
{urls: [
|
|
399
|
+
'stun:stun1.l.google.com:19302',
|
|
400
|
+
'stun:stun2.l.google.com:19302',
|
|
401
|
+
'stun:stun3.l.google.com:19302',
|
|
402
|
+
'stun:stun4.l.google.com:19302'
|
|
403
|
+
]},
|
|
404
|
+
]},
|
|
405
|
+
polite: this.host.key < this.node.key});
|
|
406
|
+
const onclose = async () => { // Does NOT mean that the far side has gone away. It could just be over maxTransports.
|
|
407
|
+
this.host.log('connection closed');
|
|
408
|
+
|
|
409
|
+
// Track disconnect with reason (Requirements 4.1, 4.3)
|
|
410
|
+
ConnectionTracker.log('disconnect', {
|
|
411
|
+
from: host.contact?.sname,
|
|
412
|
+
to: this.sname,
|
|
413
|
+
counter: this.counter,
|
|
414
|
+
elapsed: Date.now() - start,
|
|
415
|
+
hadWebrtc: !!this.webrtc,
|
|
416
|
+
hostStopped: this.host.isStopped()
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
if (this.webrtc && !this.host.isStopped()) {
|
|
420
|
+
this.host.ilog('connection to', this.sname, 'was not politely closed. Dropping contact.');
|
|
421
|
+
|
|
422
|
+
// Log unexpected disconnect (Requirements 4.1, 4.3)
|
|
423
|
+
ConnectionTracker.log('unexpected_close', {
|
|
424
|
+
from: host.contact?.sname,
|
|
425
|
+
to: this.sname,
|
|
426
|
+
counter: this.counter,
|
|
427
|
+
connectionState: this.webrtc?.pc?.connectionState
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
// Use safeCleanup for proper resource cleanup (Requirements 4.1, 4.2, 4.3)
|
|
431
|
+
await this.safeCleanup('disconnect');
|
|
432
|
+
|
|
433
|
+
// Remove contact from routing table (Requirement 4.4)
|
|
434
|
+
this.host.removeContact(this, false);
|
|
435
|
+
} else {
|
|
436
|
+
// Normal close - just nullify references
|
|
437
|
+
this.webrtc = this.connection = this.unsafeData = null;
|
|
438
|
+
}
|
|
439
|
+
resolve(null); // closed promise
|
|
440
|
+
};
|
|
441
|
+
if (initiate) {
|
|
442
|
+
if (bootstrapHost && !host.connections.length) {
|
|
443
|
+
const url = `${bootstrapHost || 'http://localhost:3000/kdht'}/join/${host.contact.sname}/${this.sname}`;
|
|
444
|
+
this.webrtc.transferSignals = signals => this.fetchSignals(url, signals);
|
|
445
|
+
} else {
|
|
446
|
+
this.webrtc.transferSignals = signals => this.messageSignals(signals);
|
|
447
|
+
}
|
|
448
|
+
} // Otherwise, we just hang on to signals until we're asked to respond().
|
|
449
|
+
|
|
450
|
+
let timeout;
|
|
451
|
+
const kdhtChannelName = 'kdht';
|
|
452
|
+
const channelPromise = webrtc.getDataChannelPromise(kdhtChannelName);
|
|
453
|
+
webrtc.createChannel(kdhtChannelName, {negotiated: true});
|
|
454
|
+
channelPromise.then(async dataChannel => {
|
|
455
|
+
this.host.log('data channel open', this.sname, Date.now() - start, this.counter);
|
|
456
|
+
clearTimeout(timeout);
|
|
457
|
+
|
|
458
|
+
// Track successful connection
|
|
459
|
+
ConnectionTracker.log('connection_success', {
|
|
460
|
+
from: host.contact?.sname,
|
|
461
|
+
to: this.sname,
|
|
462
|
+
counter: this.counter,
|
|
463
|
+
elapsed: Date.now() - start,
|
|
464
|
+
initiate
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
// Use registerListener for proper cleanup tracking (Requirements 2.2, 2.3)
|
|
468
|
+
this.registerListener(dataChannel, 'close', onclose);
|
|
469
|
+
this.registerListener(dataChannel, 'message', event => this.receiveWebRTC(event.data));
|
|
470
|
+
if (this.info || this.debug) await webrtc.reportConnection(true);
|
|
471
|
+
if (webrtc.statsElapsed > 500) {
|
|
472
|
+
this.host.flog(`** slow connection to ${this.sname} took ${webrtc.statsElapsed.toLocaleString()} ms. **`);
|
|
473
|
+
ConnectionTracker.log('slow_connection', {
|
|
474
|
+
from: host.contact?.sname,
|
|
475
|
+
to: this.sname,
|
|
476
|
+
elapsed: webrtc.statsElapsed
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
this.unsafeData = dataChannel;
|
|
480
|
+
return dataChannel;
|
|
481
|
+
});
|
|
482
|
+
if (!timeoutMS) {
|
|
483
|
+
this.connection = channelPromise;
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
const timerPromise = new Promise(expired => {
|
|
487
|
+
timeout = setTimeout(async () => {
|
|
488
|
+
if (this.host.isStopped()) return;
|
|
489
|
+
const now = Date.now();
|
|
490
|
+
this.host.ilog('Unable to connect to', this.sname);
|
|
491
|
+
|
|
492
|
+
// Track timeout (Requirements 3.1, 3.2)
|
|
493
|
+
ConnectionTracker.log('connection_timeout', {
|
|
494
|
+
from: host.contact?.sname,
|
|
495
|
+
to: this.sname,
|
|
496
|
+
counter: this.counter,
|
|
497
|
+
timeoutMS,
|
|
498
|
+
elapsed: now - start
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
// Use safeCleanup for proper resource cleanup (Requirements 3.1, 3.2, 3.3, 3.4)
|
|
502
|
+
// This ensures complete cleanup: tracks → listeners → channel → connection → nullify
|
|
503
|
+
await this.safeCleanup('timeout');
|
|
504
|
+
|
|
505
|
+
// Resolve closed promise
|
|
506
|
+
resolve(null);
|
|
507
|
+
|
|
508
|
+
// Don't remove contact on timeout - the node may still be reachable
|
|
509
|
+
// through other paths. Let the routing table manage stale contacts.
|
|
510
|
+
expired(null);
|
|
511
|
+
}, timeoutMS);
|
|
512
|
+
});
|
|
513
|
+
this.connection = Promise.race([channelPromise, timerPromise]);
|
|
514
|
+
}
|
|
515
|
+
async connect() { // Connect from host to node, promising a possibly cloned contact that has been noted.
|
|
516
|
+
// Creates a connected WebRTC instance.
|
|
517
|
+
const contact = this.host.noteContactForTransport(this);
|
|
518
|
+
///if (contact.connection) contact.host.flog('connect existing', contact.sname, contact.counter);
|
|
519
|
+
|
|
520
|
+
const { host, node, isServerNode, bootstrapHost } = contact;
|
|
521
|
+
// Anyone can connect to a server node using the server's connect endpoint.
|
|
522
|
+
// Anyone in the DHT can connect to another DHT node through a sponsor.
|
|
523
|
+
if (contact.connection) {
|
|
524
|
+
ConnectionTracker.log('connection_reused', {
|
|
525
|
+
from: host.contact?.sname,
|
|
526
|
+
to: this.sname,
|
|
527
|
+
counter: contact.counter
|
|
528
|
+
});
|
|
529
|
+
return contact.connection;
|
|
530
|
+
}
|
|
531
|
+
contact.createWebRTC(true);
|
|
532
|
+
return await this.connection;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
async send(message) { // Promise to send through previously opened connection promise.
|
|
536
|
+
let channel = await this.connection;
|
|
537
|
+
if (!channel) return;
|
|
538
|
+
if (channel.readyState === 'open') channel.send(JSON.stringify(message));
|
|
539
|
+
else this.host.ilog('Tried to send on unopen channel on', this.sname, message);
|
|
540
|
+
}
|
|
541
|
+
synchronousSend(message) { // this.send awaits channel open promise. This is if we know it has been opened.
|
|
542
|
+
if (this.unsafeData?.readyState !== 'open') return; // But it may have since been closed.
|
|
543
|
+
this.host.log('sending', message, 'to', this.sname);
|
|
544
|
+
try {
|
|
545
|
+
this.unsafeData.send(JSON.stringify(message));
|
|
546
|
+
} catch (e) { // Some webrtc can change readyState in background.
|
|
547
|
+
this.host.log(e);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
serializeRequest(messageTag, method, sender, targetKey, ...rest) { // Stringify sender and targetKey.
|
|
551
|
+
Node.assert(sender instanceof Contact, 'no sender', sender);
|
|
552
|
+
// Recursive methods pass a context object instead of a key - don't stringify it
|
|
553
|
+
const recursiveMethods = ['recursiveFindNodes', 'recursiveFindValue', 'recursiveSignals'];
|
|
554
|
+
if (recursiveMethods.includes(method)) {
|
|
555
|
+
return [messageTag, method, sender.sname, targetKey, ...rest];
|
|
556
|
+
}
|
|
557
|
+
return [messageTag, method, sender.sname, targetKey.toString(), ...rest];
|
|
558
|
+
}
|
|
559
|
+
async deserializeRequest(method, sender, targetKey, ...rest) { // Inverse of serializeRequest. Response object will be spread for Node receiveRPC.
|
|
560
|
+
// TODO: Currently, parameters do NOT include messageTag! (Because of how receiveRPC is called without it.)
|
|
561
|
+
// Recursive methods pass a context object instead of a key
|
|
562
|
+
const recursiveMethods = ['recursiveFindNodes', 'recursiveFindValue', 'recursiveSignals'];
|
|
563
|
+
if (recursiveMethods.includes(method)) {
|
|
564
|
+
// For recursive methods, targetKey is actually the context data object
|
|
565
|
+
return [method, await this.ensureRemoteContact(sender), targetKey, ...rest];
|
|
566
|
+
}
|
|
567
|
+
try {
|
|
568
|
+
return [method, await this.ensureRemoteContact(sender), BigInt(targetKey), ...rest];
|
|
569
|
+
} catch (e) {
|
|
570
|
+
this.host.flog('Error deserializing request:', method, 'targetKey:', targetKey, 'error:', e.message);
|
|
571
|
+
throw e;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
isSignalResponse(response) {
|
|
575
|
+
const first = response[0];
|
|
576
|
+
if (!first) return false;
|
|
577
|
+
if (('description' in first) || ('candidate' in first)) return true;
|
|
578
|
+
return false;
|
|
579
|
+
}
|
|
580
|
+
serializeResponse(response) {
|
|
581
|
+
if (!this.host.constructor.isContactsResult(response)) return response;
|
|
582
|
+
if (this.isSignalResponse(response)) return response;
|
|
583
|
+
return response.map(helper => [helper.contact.sname, helper.distance.toString()]);
|
|
584
|
+
}
|
|
585
|
+
async deserializeResponse(result) {
|
|
586
|
+
let response;
|
|
587
|
+
if (!Node.isContactsResult(result)) return result;
|
|
588
|
+
if (!result.length) return result;
|
|
589
|
+
if (this.isSignalResponse(result)) return result;
|
|
590
|
+
return await Promise.all(result.map(async ([sname, distance]) =>
|
|
591
|
+
new Helper(await this.ensureRemoteContact(sname, this), BigInt(distance))));
|
|
592
|
+
}
|
|
593
|
+
async transmitRPC(messageTag, method, sender, ...rest) { // Must return a promise.
|
|
594
|
+
// this.host.log('transmit to', this.sname, this.connection ? 'with connection' : 'WITHOUT connection');
|
|
595
|
+
const responsePromise = this.getResponsePromise(messageTag);
|
|
596
|
+
await this.send([messageTag, method, sender, ...rest]);
|
|
597
|
+
return await Promise.race([responsePromise, this.rpcTimeout(method, ...rest), this.closed]);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
async receiveWebRTC(dataString) { // Handle receipt of a WebRTC data channel message that was sent to this contact.
|
|
601
|
+
// The message could the start of an RPC sent from the peer, or it could be a response to an RPC that we made.
|
|
602
|
+
// As we do the latter, we generate and note (in transmitRPC) a message tag included in the message.
|
|
603
|
+
// If we find that in our messageResolvers tags, then the message is a response.
|
|
604
|
+
const [messageTag, ...data] = JSON.parse(dataString);
|
|
605
|
+
await this.receiveRPC(messageTag, ...data);
|
|
606
|
+
}
|
|
607
|
+
async disconnectTransport(andNotify = true) {
|
|
608
|
+
if (!this.connection) return;
|
|
609
|
+
super.disconnectTransport(andNotify);
|
|
610
|
+
|
|
611
|
+
// Use safeCleanup for proper resource cleanup (Requirements 2.1, 2.5)
|
|
612
|
+
// This ensures cleanup follows correct order: tracks → listeners → channel → connection → nullify
|
|
613
|
+
await this.safeCleanup('close');
|
|
614
|
+
}
|
|
615
|
+
}
|